Compare commits

...

91 Commits

Author SHA1 Message Date
df88ce9315 更新makefile 2025-11-01 17:21:23 +08:00
8b54c7abcd 增加设备测试按钮 2025-11-01 14:59:15 +08:00
5958df8f57 调整展示 2025-11-01 14:38:05 +08:00
b4662a2b10 修bug 2025-10-31 22:01:08 +08:00
840fb98988 修bug 2025-10-31 21:56:33 +08:00
fcd1b1c140 调整调用方 2025-10-31 21:35:09 +08:00
22f633e20b 修改api.js 2025-10-31 18:25:29 +08:00
e704249d75 更新swag 2025-10-31 18:14:31 +08:00
0ff82657ab 更新接口和调用方 2025-10-29 19:42:48 +08:00
fff309f56b 修bug 2025-10-25 15:42:19 +08:00
d3700c8835 实现通知记录界面 2025-10-25 15:34:28 +08:00
ea44c9caea 增加新接口定义 2025-10-25 15:27:27 +08:00
ab8ea6977f 优化逻辑 2025-10-24 14:23:58 +08:00
d5f91b9b12 跨群调栏 2025-10-23 18:41:08 +08:00
a8d9b033f3 优化展示 2025-10-23 18:27:11 +08:00
e1fb3055bb 优化展示 2025-10-23 18:23:31 +08:00
6d452394aa 修bug 2025-10-23 18:18:07 +08:00
62801326cb 群内调栏 2025-10-23 18:05:41 +08:00
ee2f3e12d2 优化显示 2025-10-23 16:12:04 +08:00
e341e53fe2 修bug 2025-10-23 16:07:21 +08:00
03eed8202b 支持移除猪栏 2025-10-23 15:47:13 +08:00
624592a63d 支持移除猪栏 2025-10-23 15:45:14 +08:00
2191bf2bdf 支持分配猪只 2025-10-23 15:29:29 +08:00
d7d68684e4 增加jsdoc 2025-10-23 15:17:31 +08:00
d85cfb303b 展示未分配数量 2025-10-23 14:43:45 +08:00
2e5c04d2d4 展示未分配数量 2025-10-23 14:42:00 +08:00
2fe6f0c576 不活跃禁用按钮 2025-10-23 14:13:24 +08:00
c176fadafe 猪群管理 2025-10-23 14:05:30 +08:00
ced688a7a3 调整卡片 2025-10-23 13:48:17 +08:00
263af9fa3a 调整卡片 2025-10-23 13:46:54 +08:00
1a55330734 修bug 2025-10-23 13:26:20 +08:00
0026f24002 修bug 2025-10-23 13:21:27 +08:00
2d6bb69387 修bug 2025-10-23 13:18:12 +08:00
70e3a4f2b0 猪群管理让用户选择猪栏 2025-10-23 12:14:39 +08:00
98cd24ee29 向猪栏卡片中传入需要的信息 2025-10-23 12:06:13 +08:00
accbb1a9f6 增加猪群管理界面 2025-10-23 11:51:19 +08:00
d38feb938d 更新swag 2025-10-23 10:54:10 +08:00
edfc641f93 修改猪栏卡片和猪舍时列表不折叠 2025-10-23 10:22:11 +08:00
0f9429be45 修改猪栏卡片批次展示 2025-10-23 10:15:45 +08:00
dc3f311037 栏舍管理界面 2025-10-22 18:55:51 +08:00
828c3bbe36 调整文件位置 2025-10-22 15:39:42 +08:00
0a5e1635f1 优化注释 2025-10-22 15:17:44 +08:00
1df5854201 修bug 2025-10-20 21:06:59 +08:00
2b1dd4c6a5 修bug 2025-10-20 20:59:44 +08:00
52a647b88a 优化展示 2025-10-20 19:22:23 +08:00
b1a8611554 实现所有监控展示 2025-10-20 16:31:16 +08:00
0cddf99456 优化列表 2025-10-20 16:14:59 +08:00
1b45e61daf 设备命令日志界面 2025-10-20 15:48:08 +08:00
9c6467176c 修bug 2025-10-20 15:15:43 +08:00
fdc568753c 修bug 2025-10-20 15:07:30 +08:00
a3e9465b88 修bug 2025-10-20 14:55:34 +08:00
96c36f9ce1 修bug 2025-10-20 14:54:52 +08:00
c68dff6123 修bug 2025-10-20 14:52:25 +08:00
a457b9713c 更新后端api 2025-10-19 21:38:04 +08:00
76d01af86c 枚举改中文 2025-10-18 12:47:09 +08:00
b3ab17660a 修bug 2025-10-01 00:29:21 +08:00
5737973197 修bug 2025-10-01 00:19:46 +08:00
9a701b339b 调整设备管理界面 2025-09-30 23:52:59 +08:00
7710abcf9e 实现设备模板管理界面 2025-09-30 23:17:32 +08:00
edef58568d 侧边栏改为两级 2025-09-30 23:05:27 +08:00
1c3b3a5151 修bug 2025-09-30 22:36:36 +08:00
a2a9cc1450 实现登录 2025-09-30 22:35:36 +08:00
effc2c06e0 根据后端新接口重构设备管理界面 2025-09-30 22:20:50 +08:00
e5c2d38559 修复设置延时任务时时间永远为1 2025-09-23 22:08:43 +08:00
a1bc980d75 todo 2025-09-23 19:38:35 +08:00
ff3b0db02d 让后端判断content_type 2025-09-23 19:34:19 +08:00
c1ac0a17b2 优化展示 2025-09-23 17:08:42 +08:00
2deda9cb81 编辑计划详情 2025-09-22 18:22:29 +08:00
3affadad0d 编辑计划详情 2025-09-22 18:10:12 +08:00
4c6ca7d836 编辑计划详情 2025-09-22 17:46:28 +08:00
0bc7cf2f66 改名 2025-09-22 17:21:08 +08:00
3a250e8d73 改样式 2025-09-22 17:16:05 +08:00
a67f55d7c8 增加编辑任务界面 2025-09-22 17:10:21 +08:00
932c88ea75 增加编辑任务按钮 2025-09-22 17:00:30 +08:00
35152ea3fe 增加列表排序 2025-09-22 16:27:27 +08:00
7b73014ab0 增加ID列 2025-09-22 16:21:36 +08:00
fe0409fe4e 增加启动和停止按钮逻辑 2025-09-22 15:21:06 +08:00
69cbcf5ff6 1. 调整计划列表展示项
2. 调整设备列表展示项
2025-09-22 00:35:29 +08:00
2a4d41d4cc 1. 调整计划列表展示项
2. 调整设备列表展示项
2025-09-22 00:32:26 +08:00
72ea9cfbc9 1. 调整计划列表展示项
2. 调整设备列表展示项
2025-09-21 23:39:38 +08:00
a47c191cbb 1. 调整计划列表展示项
2. 调整设备列表展示项
2025-09-21 23:39:21 +08:00
9a6561d4ae makefile 2025-09-21 23:24:22 +08:00
21b357a97c 修复content_type默认值的问题 2025-09-21 20:20:22 +08:00
69f673b494 cron表达式可视化配置替换成自定义组件 2025-09-21 17:53:32 +08:00
c97178b68b 增加创建计划组件 2025-09-21 13:32:51 +08:00
02e9f9b9a3 去掉普通设备选中时高亮 2025-09-20 17:34:18 +08:00
983b929d90 区域主控高亮
编辑时展示原有属性
2025-09-20 17:30:45 +08:00
e48d5ab2d8 错误处理 2025-09-20 17:13:30 +08:00
320df0b3d4 错误处理 2025-09-20 17:03:47 +08:00
6fce84c002 字段对齐 2025-09-20 16:58:00 +08:00
d695c3e8d4 折叠普通设备 2025-09-20 16:55:10 +08:00
125 changed files with 34585 additions and 1132 deletions

26
.gitignore vendored
View File

@@ -1,12 +1,22 @@
# ---> Vue
# gitignore template for Vue.js projects
#
# Recommended template: Node.gitignore
# Logs
npm-debug.log*
yarn-error.log*
# TODO: where does this rule come from?
# Dependencies
node_modules/
# Build output
dist/
# Environment variables
.env*
*.local
# IDE config
.idea/
# Docs
docs/_book
# TODO: where does this rule come from?
# Tests
test/
.idea/

6
Makefile Normal file
View File

@@ -0,0 +1,6 @@
dev:
npm run dev
# 启用谷歌浏览器MCP服务器
mcp-chrome:
node "C:\nvm4w\nodejs\node_modules\chrome-devtools-mcp\build\src\index.js"

3
TODO.txt Normal file
View File

@@ -0,0 +1,3 @@
TODO
1. 编辑计划详情时不要出现 A -> B -> A 的循环引用

File diff suppressed because it is too large Load Diff

38
node_modules/.package-lock.json generated vendored
View File

@@ -3836,6 +3836,17 @@
"node": ">=10"
}
},
"node_modules/cron-parser": {
"version": "5.4.0",
"resolved": "https://registry.npmmirror.com/cron-parser/-/cron-parser-5.4.0.tgz",
"integrity": "sha512-HxYB8vTvnQFx4dLsZpGRa0uHp6X3qIzS3ZJgJ9v6l/5TJMgeWQbLkR5yiJ5hOxGbc9+jCADDnydIe15ReLZnJA==",
"dependencies": {
"luxon": "^3.7.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -6633,6 +6644,14 @@
"yallist": "^3.0.2"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmmirror.com/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "0.30.19",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.19.tgz",
@@ -9657,6 +9676,20 @@
"node": ">= 0.6"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"optional": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.12.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.12.0.tgz",
@@ -9919,6 +9952,11 @@
"integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==",
"dev": true
},
"node_modules/vue3-cron-plus-picker": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/vue3-cron-plus-picker/-/vue3-cron-plus-picker-1.0.2.tgz",
"integrity": "sha512-SUVmAb2qSPMuAm5GIU0wQZyUawiiL3OKEy1HAZs94eZz+neKF+wEPNP4wICWMU78u4LzeCNni2Njnhf8bsqkcw=="
},
"node_modules/watchpack": {
"version": "2.4.4",
"resolved": "https://registry.npmmirror.com/watchpack/-/watchpack-2.4.4.tgz",

21
node_modules/cron-parser/LICENSE generated vendored Normal file
View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014-2023 Harri Siirak
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

398
node_modules/cron-parser/README.md generated vendored Normal file
View File

@@ -0,0 +1,398 @@
# cron-parser
[![Build Status](https://github.com/harrisiirak/cron-parser/actions/workflows/push.yml/badge.svg?branch=master)](https://github.com/harrisiirak/cron-parser/actions/workflows/push.yml)
[![NPM version](https://badge.fury.io/js/cron-parser.png)](http://badge.fury.io/js/cron-parser)
![Statements](./coverage/badge-statements.svg)
A JavaScript library for parsing and manipulating cron expressions. Features timezone support, DST handling, and iterator capabilities.
[API documentation](https://harrisiirak.github.io/cron-parser/)
## Requirements
- Node.js >= 18
- TypeScript >= 5
## Installation
```bash
npm install cron-parser
```
## Cron Format
```
* * * * * *
┬ ┬ ┬ ┬ ┬ ┬
│ │ │ │ │ │
│ │ │ │ │ └─ day of week (0-7, 1L-7L) (0 or 7 is Sun)
│ │ │ │ └────── month (1-12, JAN-DEC)
│ │ │ └─────────── day of month (1-31, L)
│ │ └──────────────── hour (0-23)
│ └───────────────────── minute (0-59)
└────────────────────────── second (0-59, optional)
```
### Special Characters
| Character | Description | Example |
| --------- | ------------------------- | ---------------------------------------------------------------------- |
| `*` | Any value | `* * * * *` (every minute) |
| `?` | Any value (alias for `*`) | `? * * * *` (every minute) |
| `,` | Value list separator | `1,2,3 * * * *` (1st, 2nd, and 3rd minute) |
| `-` | Range of values | `1-5 * * * *` (every minute from 1 through 5) |
| `/` | Step values | `*/5 * * * *` (every 5th minute) |
| `L` | Last day of month/week | `0 0 L * *` (midnight on last day of month) |
| `#` | Nth day of month | `0 0 * * 1#1` (first Monday of month) |
| `H` | Randomized value | `H * * * *` (every n minute where n is randomly picked within [0, 59]) |
### Predefined Expressions
| Expression | Description | Equivalent |
| ----------- | ----------------------------------------- | --------------- |
| `@yearly` | Once a year at midnight of January 1 | `0 0 0 1 1 *` |
| `@monthly` | Once a month at midnight of first day | `0 0 0 1 * *` |
| `@weekly` | Once a week at midnight on Sunday | `0 0 0 * * 0` |
| `@daily` | Once a day at midnight | `0 0 0 * * *` |
| `@hourly` | Once an hour at the beginning of the hour | `0 0 * * * *` |
| `@minutely` | Once a minute | `0 * * * * *` |
| `@secondly` | Once a second | `* * * * * *` |
| `@weekdays` | Every weekday at midnight | `0 0 0 * * 1-5` |
| `@weekends` | Every weekend at midnight | `0 0 0 * * 0,6` |
### Field Values
| Field | Values | Special Characters | Aliases |
| ------------ | ------ | ------------------------------- | ------------------------------ |
| second | 0-59 | `*` `?` `,` `-` `/` `H` | |
| minute | 0-59 | `*` `?` `,` `-` `/` `H` | |
| hour | 0-23 | `*` `?` `,` `-` `/` `H` | |
| day of month | 1-31 | `*` `?` `,` `-` `/` `H` `L` | |
| month | 1-12 | `*` `?` `,` `-` `/` `H` | `JAN`-`DEC` |
| day of week | 0-7 | `*` `?` `,` `-` `/` `H` `L` `#` | `SUN`-`SAT` (0 or 7 is Sunday) |
## Options
| Option | Type | Description |
| ----------- | ------------------------ | --------------------------------------------------------------------------------------------------------------------------- |
| currentDate | Date \| string \| number | Current date. Defaults to current local time in UTC. If not provided but startDate is set, startDate is used as currentDate |
| endDate | Date \| string \| number | End date of iteration range. Sets iteration range end point |
| startDate | Date \| string \| number | Start date of iteration range. Set iteration range start point |
| tz | string | Timezone (e.g., 'Europe/London') |
| hashSeed | string | A seed to be used in conjunction with the `H` special character |
| strict | boolean | Enable strict mode validation |
When using string dates, the following formats are supported:
- ISO8601
- HTTP and RFC2822
- SQL
## Basic Usage
### Expression Parsing
```typescript
import { CronExpressionParser } from 'cron-parser';
try {
const interval = CronExpressionParser.parse('*/2 * * * *');
// Get next date
console.log('Next:', interval.next().toString());
// Get next 3 dates
console.log(
'Next 3:',
interval.take(3).map((date) => date.toString()),
);
// Get previous date
console.log('Previous:', interval.prev().toString());
} catch (err) {
console.log('Error:', err.message);
}
```
### With Options
```typescript
import { CronExpressionParser } from 'cron-parser';
const options = {
currentDate: '2023-01-01T00:00:00Z',
endDate: '2024-01-01T00:00:00Z',
tz: 'Europe/London',
};
try {
const interval = CronExpressionParser.parse('0 0 * * *', options);
console.log('Next:', interval.next().toString());
} catch (err) {
console.log('Error:', err.message);
}
```
### Date Range Handling
The library provides handling of date ranges with automatic adjustment of the `currentDate`:
**startDate as fallback**: If `currentDate` is not provided but `startDate` is, the `startDate` will be used as the `currentDate`.
```typescript
const options = {
startDate: '2023-01-01T00:00:00Z', // No currentDate provided
};
// currentDate will be set to 2023-01-01T00:00:00Z automatically
const interval = CronExpressionParser.parse('0 0 * * *', options);
```
**Automatic clamping**: If `currentDate` is outside the bounds defined by `startDate` and `endDate`, it will be automatically adjusted:
```typescript
const options = {
currentDate: '2022-01-01T00:00:00Z', // Before startDate
startDate: '2023-01-01T00:00:00Z',
endDate: '2024-01-01T00:00:00Z',
};
// currentDate will be clamped to startDate (2023-01-01T00:00:00Z)
const interval = CronExpressionParser.parse('0 0 * * *', options);
```
**Validation during iteration**: While the initial `currentDate` is automatically adjusted, the library still validates date bounds during iteration:
```typescript
const options = {
currentDate: '2023-12-31T00:00:00Z',
endDate: '2024-01-01T00:00:00Z', // Very close end date
};
const interval = CronExpressionParser.parse('0 0 * * *', options);
console.log('Next:', interval.next().toString()); // Works fine
// This will throw an error because it would exceed endDate
try {
console.log('Next:', interval.next().toString());
} catch (err) {
console.log('Error:', err.message); // "Out of the time span range"
}
```
This behavior simplifies working with date ranges by removing the need to manually ensure that `currentDate` is within bounds, reducing confusion and making the API more intuitive.
### Crontab File Operations
For working with crontab files, use the CronFileParser:
```typescript
import { CronFileParser } from 'cron-parser';
// Async file parsing
try {
const result = await CronFileParser.parseFile('/path/to/crontab');
console.log('Variables:', result.variables);
console.log('Expressions:', result.expressions);
console.log('Errors:', result.errors);
} catch (err) {
console.log('Error:', err.message);
}
// Sync file parsing
try {
const result = CronFileParser.parseFileSync('/path/to/crontab');
console.log('Variables:', result.variables);
console.log('Expressions:', result.expressions);
console.log('Errors:', result.errors);
} catch (err) {
console.log('Error:', err.message);
}
```
## Advanced Features
### Strict Mode
In several implementations of CRON, it's ambiguous to specify both the Day Of Month and Day Of Week parameters simultaneously, as it's unclear which one should take precedence. Despite this ambiguity, this library allows both parameters to be set by default, although the resultant behavior might not align with your expectations.
To resolve this ambiguity, you can activate the strict mode of the library. When strict mode is enabled, the library enforces several validation rules:
1. **Day Of Month and Day Of Week**: Prevents the simultaneous setting of both Day Of Month and Day Of Week fields
2. **Complete Expression**: Requires all 6 fields to be present in the expression (second, minute, hour, day of month, month, day of week)
3. **Non-empty Expression**: Rejects empty expressions that would otherwise default to '0 \* \* \* \* \*'
These validations help ensure that your cron expressions are unambiguous and correctly formatted.
```typescript
import { CronExpressionParser } from 'cron-parser';
// This will throw an error in strict mode because it uses both dayOfMonth and dayOfWeek
const options = {
currentDate: new Date('Mon, 12 Sep 2022 14:00:00'),
strict: true,
};
try {
// This will throw an error in strict mode
CronExpressionParser.parse('0 0 12 1-31 * 1', options);
} catch (err) {
console.log('Error:', err.message);
// Error: Cannot use both dayOfMonth and dayOfWeek together in strict mode!
}
// This will also throw an error in strict mode because it has fewer than 6 fields
try {
CronExpressionParser.parse('0 20 15 * *', { strict: true });
} catch (err) {
console.log('Error:', err.message);
// Error: Invalid cron expression, expected 6 fields
}
```
### Last Day of Month/Week Support
The library supports parsing the range `0L - 7L` in the `weekday` position of the cron expression, where the `L` means "last occurrence of this weekday for the month in progress".
For example, the following expression will run on the last Monday of the month at midnight:
```typescript
import { CronExpressionParser } from 'cron-parser';
// Last Monday of every month at midnight
const lastMonday = CronExpressionParser.parse('0 0 0 * * 1L');
// You can also combine L expressions with other weekday expressions
// This will run every Monday and the last Wednesday of the month
const mixedWeekdays = CronExpressionParser.parse('0 0 0 * * 1,3L');
// Last day of every month
const lastDay = CronExpressionParser.parse('0 0 L * *');
```
### Using Iterator
```typescript
import { CronExpressionParser } from 'cron-parser';
const interval = CronExpressionParser.parse('0 */2 * * *');
// Using for...of
for (const date of interval) {
console.log('Iterator value:', date.toString());
if (someCondition) break;
}
// Using take() for a specific number of iterations
const nextFiveDates = interval.take(5);
console.log(
'Next 5 dates:',
nextFiveDates.map((date) => date.toString()),
);
```
### Timezone Support
The library provides robust timezone support using Luxon, handling DST transitions correctly:
```typescript
import { CronExpressionParser } from 'cron-parser';
const options = {
currentDate: '2023-03-26T01:00:00',
tz: 'Europe/London',
};
const interval = CronExpressionParser.parse('0 * * * *', options);
// Will correctly handle DST transition
console.log('Next dates during DST transition:');
console.log(interval.next().toString());
console.log(interval.next().toString());
console.log(interval.next().toString());
```
### Field Manipulation
You can modify cron fields programmatically using `CronFieldCollection.from` and construct a new expression:
```typescript
import { CronExpressionParser, CronFieldCollection, CronHour, CronMinute } from 'cron-parser';
// Parse original expression
const interval = CronExpressionParser.parse('0 7 * * 1-5');
// Create new collection with modified fields using raw values
const modified = CronFieldCollection.from(interval.fields, {
hour: [8],
minute: [30],
dayOfWeek: [1, 3, 5],
});
console.log(modified.stringify()); // "30 8 * * 1,3,5"
// You can also use CronField instances
const modified2 = CronFieldCollection.from(interval.fields, {
hour: new CronHour([15]),
minute: new CronMinute([30]),
});
console.log(modified2.stringify()); // "30 15 * * 1-5"
```
The `CronFieldCollection.from` method accepts either CronField instances or raw values that would be valid for creating new CronField instances. This is particularly useful when you need to modify only specific fields while keeping others unchanged.
### Hash support
The library supports adding [jitter](https://en.wikipedia.org/wiki/Jitter) to the returned intervals using the `H` special character in a field. When `H` is specified instead of `*`, a random value is used (`H` is replaced by `23`, where 23 is picked randomly, within the valid range of the field).
This jitter allows to spread the load when it comes to job scheduling. This feature is inspired by Jenkins's cron syntax.
```typescript
import { CronExpressionParser } from 'cron-parser';
// At 23:<randomized> on every day-of-week from Monday through Friday.
const interval = CronExpressionParser.parse('H 23 * * 1-5');
// At <randomized>:30 everyday.
const interval = CronExpressionParser.parse('30 H * * *');
// At every minutes of <randomized> hour at <randomized> second everyday.
const interval = CronExpressionParser.parse('H * H * * *');
// At every 5th minute starting from a random offset.
// For example, if the random offset is 3, it will run at minutes 3, 8, 13, 18, etc.
const interval = CronExpressionParser.parse('H/5 * * * *');
// At a random minute within the range 0-10 everyday.
const interval = CronExpressionParser.parse('H(0-10) * * * *');
// At every 5th minute starting from a random offset within the range 0-4.
// For example, if the random offset is 2, it will run at minutes 2, 7, 12, 17, etc.
// The random offset is constrained to be less than the step value.
const interval = CronExpressionParser.parse('H(0-29)/5 * * * *');
// At every minute of the third <randomized> day of the month
const interval = CronExpressionParser.parse('* * * * H#3');
```
The randomness is seed-able using the `hashSeed` option of `CronExpressionOptions`:
```typescript
import { CronExpressionParser } from 'cron-parser';
const options = {
currentDate: '2023-03-26T01:00:00',
hashSeed: 'main-backup', // Generally, hashSeed would be a job name for example
};
const interval = CronExpressionParser.parse('H * * * H', options);
console.log(interval.stringify()); // "12 * * * 4"
const otherInterval = CronExpressionParser.parse('H * * * H', options);
// Using the same seed will always return the same jitter
console.log(otherInterval.stringify()); // "12 * * * 4"
```
## License
MIT

497
node_modules/cron-parser/dist/CronDate.js generated vendored Normal file
View File

@@ -0,0 +1,497 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CronDate = exports.DAYS_IN_MONTH = exports.DateMathOp = exports.TimeUnit = void 0;
const luxon_1 = require("luxon");
var TimeUnit;
(function (TimeUnit) {
TimeUnit["Second"] = "Second";
TimeUnit["Minute"] = "Minute";
TimeUnit["Hour"] = "Hour";
TimeUnit["Day"] = "Day";
TimeUnit["Month"] = "Month";
TimeUnit["Year"] = "Year";
})(TimeUnit || (exports.TimeUnit = TimeUnit = {}));
var DateMathOp;
(function (DateMathOp) {
DateMathOp["Add"] = "Add";
DateMathOp["Subtract"] = "Subtract";
})(DateMathOp || (exports.DateMathOp = DateMathOp = {}));
exports.DAYS_IN_MONTH = Object.freeze([31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]);
/**
* CronDate class that wraps the Luxon DateTime object to provide
* a consistent API for working with dates and times in the context of cron.
*/
class CronDate {
#date;
#dstStart = null;
#dstEnd = null;
/**
* Maps the verb to the appropriate method
*/
#verbMap = {
add: {
[TimeUnit.Year]: this.addYear.bind(this),
[TimeUnit.Month]: this.addMonth.bind(this),
[TimeUnit.Day]: this.addDay.bind(this),
[TimeUnit.Hour]: this.addHour.bind(this),
[TimeUnit.Minute]: this.addMinute.bind(this),
[TimeUnit.Second]: this.addSecond.bind(this),
},
subtract: {
[TimeUnit.Year]: this.subtractYear.bind(this),
[TimeUnit.Month]: this.subtractMonth.bind(this),
[TimeUnit.Day]: this.subtractDay.bind(this),
[TimeUnit.Hour]: this.subtractHour.bind(this),
[TimeUnit.Minute]: this.subtractMinute.bind(this),
[TimeUnit.Second]: this.subtractSecond.bind(this),
},
};
/**
* Constructs a new CronDate instance.
* @param {CronDate | Date | number | string} [timestamp] - The timestamp to initialize the CronDate with.
* @param {string} [tz] - The timezone to use for the CronDate.
*/
constructor(timestamp, tz) {
const dateOpts = { zone: tz };
// Initialize the internal DateTime object based on the type of timestamp provided.
if (!timestamp) {
this.#date = luxon_1.DateTime.local();
}
else if (timestamp instanceof CronDate) {
this.#date = timestamp.#date;
this.#dstStart = timestamp.#dstStart;
this.#dstEnd = timestamp.#dstEnd;
}
else if (timestamp instanceof Date) {
this.#date = luxon_1.DateTime.fromJSDate(timestamp, dateOpts);
}
else if (typeof timestamp === 'number') {
this.#date = luxon_1.DateTime.fromMillis(timestamp, dateOpts);
}
else {
this.#date = luxon_1.DateTime.fromISO(timestamp, dateOpts);
this.#date.isValid || (this.#date = luxon_1.DateTime.fromRFC2822(timestamp, dateOpts));
this.#date.isValid || (this.#date = luxon_1.DateTime.fromSQL(timestamp, dateOpts));
this.#date.isValid || (this.#date = luxon_1.DateTime.fromFormat(timestamp, 'EEE, d MMM yyyy HH:mm:ss', dateOpts));
}
// Check for valid DateTime and throw an error if not valid.
if (!this.#date.isValid) {
throw new Error(`CronDate: unhandled timestamp: ${timestamp}`);
}
// Set the timezone if it is provided and different from the current zone.
if (tz && tz !== this.#date.zoneName) {
this.#date = this.#date.setZone(tz);
}
}
/**
* Determines if the given year is a leap year.
* @param {number} year - The year to check
* @returns {boolean} - True if the year is a leap year, false otherwise
* @private
*/
static #isLeapYear(year) {
return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
}
/**
* Returns daylight savings start time.
* @returns {number | null}
*/
get dstStart() {
return this.#dstStart;
}
/**
* Sets daylight savings start time.
* @param {number | null} value
*/
set dstStart(value) {
this.#dstStart = value;
}
/**
* Returns daylight savings end time.
* @returns {number | null}
*/
get dstEnd() {
return this.#dstEnd;
}
/**
* Sets daylight savings end time.
* @param {number | null} value
*/
set dstEnd(value) {
this.#dstEnd = value;
}
/**
* Adds one year to the current CronDate.
*/
addYear() {
this.#date = this.#date.plus({ years: 1 });
}
/**
* Adds one month to the current CronDate.
*/
addMonth() {
this.#date = this.#date.plus({ months: 1 }).startOf('month');
}
/**
* Adds one day to the current CronDate.
*/
addDay() {
this.#date = this.#date.plus({ days: 1 }).startOf('day');
}
/**
* Adds one hour to the current CronDate.
*/
addHour() {
this.#date = this.#date.plus({ hours: 1 }).startOf('hour');
}
/**
* Adds one minute to the current CronDate.
*/
addMinute() {
this.#date = this.#date.plus({ minutes: 1 }).startOf('minute');
}
/**
* Adds one second to the current CronDate.
*/
addSecond() {
this.#date = this.#date.plus({ seconds: 1 });
}
/**
* Subtracts one year from the current CronDate.
*/
subtractYear() {
this.#date = this.#date.minus({ years: 1 });
}
/**
* Subtracts one month from the current CronDate.
* If the month is 1, it will subtract one year instead.
*/
subtractMonth() {
this.#date = this.#date.minus({ months: 1 }).endOf('month').startOf('second');
}
/**
* Subtracts one day from the current CronDate.
* If the day is 1, it will subtract one month instead.
*/
subtractDay() {
this.#date = this.#date.minus({ days: 1 }).endOf('day').startOf('second');
}
/**
* Subtracts one hour from the current CronDate.
* If the hour is 0, it will subtract one day instead.
*/
subtractHour() {
this.#date = this.#date.minus({ hours: 1 }).endOf('hour').startOf('second');
}
/**
* Subtracts one minute from the current CronDate.
* If the minute is 0, it will subtract one hour instead.
*/
subtractMinute() {
this.#date = this.#date.minus({ minutes: 1 }).endOf('minute').startOf('second');
}
/**
* Subtracts one second from the current CronDate.
* If the second is 0, it will subtract one minute instead.
*/
subtractSecond() {
this.#date = this.#date.minus({ seconds: 1 });
}
/**
* Adds a unit of time to the current CronDate.
* @param {TimeUnit} unit
*/
addUnit(unit) {
this.#verbMap.add[unit]();
}
/**
* Subtracts a unit of time from the current CronDate.
* @param {TimeUnit} unit
*/
subtractUnit(unit) {
this.#verbMap.subtract[unit]();
}
/**
* Handles a math operation.
* @param {DateMathOp} verb - {'add' | 'subtract'}
* @param {TimeUnit} unit - {'year' | 'month' | 'day' | 'hour' | 'minute' | 'second'}
*/
invokeDateOperation(verb, unit) {
if (verb === DateMathOp.Add) {
this.addUnit(unit);
return;
}
if (verb === DateMathOp.Subtract) {
this.subtractUnit(unit);
return;
}
/* istanbul ignore next - this would only happen if an end user call the handleMathOp with an invalid verb */
throw new Error(`Invalid verb: ${verb}`);
}
/**
* Returns the day.
* @returns {number}
*/
getDate() {
return this.#date.day;
}
/**
* Returns the year.
* @returns {number}
*/
getFullYear() {
return this.#date.year;
}
/**
* Returns the day of the week.
* @returns {number}
*/
getDay() {
const weekday = this.#date.weekday;
return weekday === 7 ? 0 : weekday;
}
/**
* Returns the month.
* @returns {number}
*/
getMonth() {
return this.#date.month - 1;
}
/**
* Returns the hour.
* @returns {number}
*/
getHours() {
return this.#date.hour;
}
/**
* Returns the minutes.
* @returns {number}
*/
getMinutes() {
return this.#date.minute;
}
/**
* Returns the seconds.
* @returns {number}
*/
getSeconds() {
return this.#date.second;
}
/**
* Returns the milliseconds.
* @returns {number}
*/
getMilliseconds() {
return this.#date.millisecond;
}
/**
* Returns the time.
* @returns {number}
*/
getTime() {
return this.#date.valueOf();
}
/**
* Returns the UTC day.
* @returns {number}
*/
getUTCDate() {
return this.#getUTC().day;
}
/**
* Returns the UTC year.
* @returns {number}
*/
getUTCFullYear() {
return this.#getUTC().year;
}
/**
* Returns the UTC day of the week.
* @returns {number}
*/
getUTCDay() {
const weekday = this.#getUTC().weekday;
return weekday === 7 ? 0 : weekday;
}
/**
* Returns the UTC month.
* @returns {number}
*/
getUTCMonth() {
return this.#getUTC().month - 1;
}
/**
* Returns the UTC hour.
* @returns {number}
*/
getUTCHours() {
return this.#getUTC().hour;
}
/**
* Returns the UTC minutes.
* @returns {number}
*/
getUTCMinutes() {
return this.#getUTC().minute;
}
/**
* Returns the UTC seconds.
* @returns {number}
*/
getUTCSeconds() {
return this.#getUTC().second;
}
/**
* Returns the UTC milliseconds.
* @returns {string | null}
*/
toISOString() {
return this.#date.toUTC().toISO();
}
/**
* Returns the date as a JSON string.
* @returns {string | null}
*/
toJSON() {
return this.#date.toJSON();
}
/**
* Sets the day.
* @param d
*/
setDate(d) {
this.#date = this.#date.set({ day: d });
}
/**
* Sets the year.
* @param y
*/
setFullYear(y) {
this.#date = this.#date.set({ year: y });
}
/**
* Sets the day of the week.
* @param d
*/
setDay(d) {
this.#date = this.#date.set({ weekday: d });
}
/**
* Sets the month.
* @param m
*/
setMonth(m) {
this.#date = this.#date.set({ month: m + 1 });
}
/**
* Sets the hour.
* @param h
*/
setHours(h) {
this.#date = this.#date.set({ hour: h });
}
/**
* Sets the minutes.
* @param m
*/
setMinutes(m) {
this.#date = this.#date.set({ minute: m });
}
/**
* Sets the seconds.
* @param s
*/
setSeconds(s) {
this.#date = this.#date.set({ second: s });
}
/**
* Sets the milliseconds.
* @param s
*/
setMilliseconds(s) {
this.#date = this.#date.set({ millisecond: s });
}
/**
* Returns the date as a string.
* @returns {string}
*/
toString() {
return this.toDate().toString();
}
/**
* Returns the date as a Date object.
* @returns {Date}
*/
toDate() {
return this.#date.toJSDate();
}
/**
* Returns true if the day is the last day of the month.
* @returns {boolean}
*/
isLastDayOfMonth() {
const { day, month } = this.#date;
// Special handling for February in leap years
if (month === 2) {
const isLeap = CronDate.#isLeapYear(this.#date.year);
return day === exports.DAYS_IN_MONTH[month - 1] - (isLeap ? 0 : 1);
}
// For other months, check against the static map
return day === exports.DAYS_IN_MONTH[month - 1];
}
/**
* Returns true if the day is the last weekday of the month.
* @returns {boolean}
*/
isLastWeekdayOfMonth() {
const { day, month } = this.#date;
// Get the last day of the current month
let lastDay;
if (month === 2) {
// Special handling for February
lastDay = exports.DAYS_IN_MONTH[month - 1] - (CronDate.#isLeapYear(this.#date.year) ? 0 : 1);
}
else {
lastDay = exports.DAYS_IN_MONTH[month - 1];
}
// Check if the current day is within 7 days of the end of the month
return day > lastDay - 7;
}
/**
* Primarily for internal use.
* @param {DateMathOp} op - The operation to perform.
* @param {TimeUnit} unit - The unit of time to use.
* @param {number} [hoursLength] - The length of the hours. Required when unit is not month or day.
*/
applyDateOperation(op, unit, hoursLength) {
if (unit === TimeUnit.Month || unit === TimeUnit.Day) {
this.invokeDateOperation(op, unit);
return;
}
const previousHour = this.getHours();
this.invokeDateOperation(op, unit);
const currentHour = this.getHours();
const diff = currentHour - previousHour;
if (diff === 2) {
if (hoursLength !== 24) {
this.dstStart = currentHour;
}
}
else if (diff === 0 && this.getMinutes() === 0 && this.getSeconds() === 0) {
if (hoursLength !== 24) {
this.dstEnd = currentHour;
}
}
}
/**
* Returns the UTC date.
* @private
* @returns {DateTime}
*/
#getUTC() {
return this.#date.toUTC();
}
}
exports.CronDate = CronDate;
exports.default = CronDate;

407
node_modules/cron-parser/dist/CronExpression.js generated vendored Normal file
View File

@@ -0,0 +1,407 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CronExpression = exports.LOOPS_LIMIT_EXCEEDED_ERROR_MESSAGE = exports.TIME_SPAN_OUT_OF_BOUNDS_ERROR_MESSAGE = void 0;
const CronDate_1 = require("./CronDate");
/**
* Error message for when the current date is outside the specified time span.
*/
exports.TIME_SPAN_OUT_OF_BOUNDS_ERROR_MESSAGE = 'Out of the time span range';
/**
* Error message for when the loop limit is exceeded during iteration.
*/
exports.LOOPS_LIMIT_EXCEEDED_ERROR_MESSAGE = 'Invalid expression, loop limit exceeded';
/**
* Cron iteration loop safety limit
*/
const LOOP_LIMIT = 10000;
/**
* Class representing a Cron expression.
*/
class CronExpression {
#options;
#tz;
#currentDate;
#startDate;
#endDate;
#fields;
/**
* Creates a new CronExpression instance.
*
* @param {CronFieldCollection} fields - Cron fields.
* @param {CronExpressionOptions} options - Parser options.
*/
constructor(fields, options) {
this.#options = options;
this.#tz = options.tz;
this.#startDate = options.startDate ? new CronDate_1.CronDate(options.startDate, this.#tz) : null;
this.#endDate = options.endDate ? new CronDate_1.CronDate(options.endDate, this.#tz) : null;
let currentDateValue = options.currentDate ?? options.startDate;
if (currentDateValue) {
const tempCurrentDate = new CronDate_1.CronDate(currentDateValue, this.#tz);
if (this.#startDate && tempCurrentDate.getTime() < this.#startDate.getTime()) {
currentDateValue = this.#startDate;
}
else if (this.#endDate && tempCurrentDate.getTime() > this.#endDate.getTime()) {
currentDateValue = this.#endDate;
}
}
this.#currentDate = new CronDate_1.CronDate(currentDateValue, this.#tz);
this.#fields = fields;
}
/**
* Getter for the cron fields.
*
* @returns {CronFieldCollection} Cron fields.
*/
get fields() {
return this.#fields;
}
/**
* Converts cron fields back to a CronExpression instance.
*
* @public
* @param {Record<string, number[]>} fields - The input cron fields object.
* @param {CronExpressionOptions} [options] - Optional parsing options.
* @returns {CronExpression} - A new CronExpression instance.
*/
static fieldsToExpression(fields, options) {
return new CronExpression(fields, options || {});
}
/**
* Checks if the given value matches any element in the sequence.
*
* @param {number} value - The value to be matched.
* @param {number[]} sequence - The sequence to be checked against.
* @returns {boolean} - True if the value matches an element in the sequence; otherwise, false.
* @memberof CronExpression
* @private
*/
static #matchSchedule(value, sequence) {
return sequence.some((element) => element === value);
}
/**
* Determines if the current date matches the last specified weekday of the month.
*
* @param {Array<(number|string)>} expressions - An array of expressions containing weekdays and "L" for the last weekday.
* @param {CronDate} currentDate - The current date object.
* @returns {boolean} - True if the current date matches the last specified weekday of the month; otherwise, false.
* @memberof CronExpression
* @private
*/
static #isLastWeekdayOfMonthMatch(expressions, currentDate) {
const isLastWeekdayOfMonth = currentDate.isLastWeekdayOfMonth();
return expressions.some((expression) => {
// The first character represents the weekday
const weekday = parseInt(expression.toString().charAt(0), 10) % 7;
if (Number.isNaN(weekday)) {
throw new Error(`Invalid last weekday of the month expression: ${expression}`);
}
// Check if the current date matches the last specified weekday of the month
return currentDate.getDay() === weekday && isLastWeekdayOfMonth;
});
}
/**
* Find the next scheduled date based on the cron expression.
* @returns {CronDate} - The next scheduled date or an ES6 compatible iterator object.
* @memberof CronExpression
* @public
*/
next() {
return this.#findSchedule();
}
/**
* Find the previous scheduled date based on the cron expression.
* @returns {CronDate} - The previous scheduled date or an ES6 compatible iterator object.
* @memberof CronExpression
* @public
*/
prev() {
return this.#findSchedule(true);
}
/**
* Check if there is a next scheduled date based on the current date and cron expression.
* @returns {boolean} - Returns true if there is a next scheduled date, false otherwise.
* @memberof CronExpression
* @public
*/
hasNext() {
const current = this.#currentDate;
try {
this.#findSchedule();
return true;
}
catch {
return false;
}
finally {
this.#currentDate = current;
}
}
/**
* Check if there is a previous scheduled date based on the current date and cron expression.
* @returns {boolean} - Returns true if there is a previous scheduled date, false otherwise.
* @memberof CronExpression
* @public
*/
hasPrev() {
const current = this.#currentDate;
try {
this.#findSchedule(true);
return true;
}
catch {
return false;
}
finally {
this.#currentDate = current;
}
}
/**
* Iterate over a specified number of steps and optionally execute a callback function for each step.
* @param {number} steps - The number of steps to iterate. Positive value iterates forward, negative value iterates backward.
* @returns {CronDate[]} - An array of iterator fields or CronDate objects.
* @memberof CronExpression
* @public
*/
take(limit) {
const items = [];
if (limit >= 0) {
for (let i = 0; i < limit; i++) {
try {
items.push(this.next());
}
catch {
return items;
}
}
}
else {
for (let i = 0; i > limit; i--) {
try {
items.push(this.prev());
}
catch {
return items;
}
}
}
return items;
}
/**
* Reset the iterators current date to a new date or the initial date.
* @param {Date | CronDate} [newDate] - Optional new date to reset to. If not provided, it will reset to the initial date.
* @memberof CronExpression
* @public
*/
reset(newDate) {
this.#currentDate = new CronDate_1.CronDate(newDate || this.#options.currentDate);
}
/**
* Generate a string representation of the cron expression.
* @param {boolean} [includeSeconds=false] - Whether to include the seconds field in the string representation.
* @returns {string} - The string representation of the cron expression.
* @memberof CronExpression
* @public
*/
stringify(includeSeconds = false) {
return this.#fields.stringify(includeSeconds);
}
/**
* Check if the cron expression includes the given date
* @param {Date|CronDate} date
* @returns {boolean}
*/
includesDate(date) {
const { second, minute, hour, month } = this.#fields;
const dt = new CronDate_1.CronDate(date, this.#tz);
// Check basic time fields first
if (!second.values.includes(dt.getSeconds()) ||
!minute.values.includes(dt.getMinutes()) ||
!hour.values.includes(dt.getHours()) ||
!month.values.includes((dt.getMonth() + 1))) {
return false;
}
// Check day of month and day of week using the same logic as #findSchedule
if (!this.#matchDayOfMonth(dt)) {
return false;
}
// Check nth day of week if specified
if (this.#fields.dayOfWeek.nthDay > 0) {
const weekInMonth = Math.ceil(dt.getDate() / 7);
if (weekInMonth !== this.#fields.dayOfWeek.nthDay) {
return false;
}
}
return true;
}
/**
* Returns the string representation of the cron expression.
* @returns {CronDate} - The next schedule date.
*/
toString() {
/* istanbul ignore next - should be impossible under normal use to trigger the or branch */
return this.#options.expression || this.stringify(true);
}
/**
* Determines if the given date matches the cron expression's day of month and day of week fields.
*
* The function checks the following rules:
* Rule 1: If both "day of month" and "day of week" are restricted (not wildcard), then one or both must match the current day.
* Rule 2: If "day of month" is restricted and "day of week" is not restricted, then "day of month" must match the current day.
* Rule 3: If "day of month" is a wildcard, "day of week" is not a wildcard, and "day of week" matches the current day, then the match is accepted.
* If none of the rules match, the match is rejected.
*
* @param {CronDate} currentDate - The current date to be evaluated against the cron expression.
* @returns {boolean} Returns true if the current date matches the cron expression's day of month and day of week fields, otherwise false.
* @memberof CronExpression
* @private
*/
#matchDayOfMonth(currentDate) {
// Check if day of month and day of week fields are wildcards or restricted (not wildcard).
const isDayOfMonthWildcardMatch = this.#fields.dayOfMonth.isWildcard;
const isRestrictedDayOfMonth = !isDayOfMonthWildcardMatch;
const isDayOfWeekWildcardMatch = this.#fields.dayOfWeek.isWildcard;
const isRestrictedDayOfWeek = !isDayOfWeekWildcardMatch;
// Calculate if the current date matches the day of month and day of week fields.
const matchedDOM = CronExpression.#matchSchedule(currentDate.getDate(), this.#fields.dayOfMonth.values) ||
(this.#fields.dayOfMonth.hasLastChar && currentDate.isLastDayOfMonth());
const matchedDOW = CronExpression.#matchSchedule(currentDate.getDay(), this.#fields.dayOfWeek.values) ||
(this.#fields.dayOfWeek.hasLastChar &&
CronExpression.#isLastWeekdayOfMonthMatch(this.#fields.dayOfWeek.values, currentDate));
// Rule 1: Both "day of month" and "day of week" are restricted; one or both must match the current day.
if (isRestrictedDayOfMonth && isRestrictedDayOfWeek && (matchedDOM || matchedDOW)) {
return true;
}
// Rule 2: "day of month" restricted and "day of week" not restricted; "day of month" must match the current day.
if (matchedDOM && !isRestrictedDayOfWeek) {
return true;
}
// Rule 3: "day of month" is a wildcard, "day of week" is not a wildcard, and "day of week" matches the current day.
if (isDayOfMonthWildcardMatch && !isDayOfWeekWildcardMatch && matchedDOW) {
return true;
}
// If none of the rules match, the match is rejected.
return false;
}
/**
* Determines if the current hour matches the cron expression.
*
* @param {CronDate} currentDate - The current date object.
* @param {DateMathOp} dateMathVerb - The date math operation enumeration value.
* @param {boolean} reverse - A flag indicating whether the matching should be done in reverse order.
* @returns {boolean} - True if the current hour matches the cron expression; otherwise, false.
*/
#matchHour(currentDate, dateMathVerb, reverse) {
const currentHour = currentDate.getHours();
const isMatch = CronExpression.#matchSchedule(currentHour, this.#fields.hour.values);
const isDstStart = currentDate.dstStart === currentHour;
const isDstEnd = currentDate.dstEnd === currentHour;
if (!isMatch && !isDstStart) {
currentDate.dstStart = null;
currentDate.applyDateOperation(dateMathVerb, CronDate_1.TimeUnit.Hour, this.#fields.hour.values.length);
return false;
}
if (isDstStart && !CronExpression.#matchSchedule(currentHour - 1, this.#fields.hour.values)) {
currentDate.invokeDateOperation(dateMathVerb, CronDate_1.TimeUnit.Hour);
return false;
}
if (isDstEnd && !reverse) {
currentDate.dstEnd = null;
currentDate.applyDateOperation(CronDate_1.DateMathOp.Add, CronDate_1.TimeUnit.Hour, this.#fields.hour.values.length);
return false;
}
return true;
}
/**
* Validates the current date against the start and end dates of the cron expression.
* If the current date is outside the specified time span, an error is thrown.
*
* @param currentDate {CronDate} - The current date to validate.
* @throws {Error} If the current date is outside the specified time span.
* @private
*/
#validateTimeSpan(currentDate) {
if (!this.#startDate && !this.#endDate) {
return;
}
const currentTime = currentDate.getTime();
if (this.#startDate && currentTime < this.#startDate.getTime()) {
throw new Error(exports.TIME_SPAN_OUT_OF_BOUNDS_ERROR_MESSAGE);
}
if (this.#endDate && currentTime > this.#endDate.getTime()) {
throw new Error(exports.TIME_SPAN_OUT_OF_BOUNDS_ERROR_MESSAGE);
}
}
/**
* Finds the next or previous schedule based on the cron expression.
*
* @param {boolean} [reverse=false] - If true, finds the previous schedule; otherwise, finds the next schedule.
* @returns {CronDate} - The next or previous schedule date.
* @private
*/
#findSchedule(reverse = false) {
const dateMathVerb = reverse ? CronDate_1.DateMathOp.Subtract : CronDate_1.DateMathOp.Add;
const currentDate = new CronDate_1.CronDate(this.#currentDate);
const startTimestamp = currentDate.getTime();
let stepCount = 0;
while (++stepCount < LOOP_LIMIT) {
this.#validateTimeSpan(currentDate);
if (!this.#matchDayOfMonth(currentDate)) {
currentDate.applyDateOperation(dateMathVerb, CronDate_1.TimeUnit.Day, this.#fields.hour.values.length);
continue;
}
if (!(this.#fields.dayOfWeek.nthDay <= 0 || Math.ceil(currentDate.getDate() / 7) === this.#fields.dayOfWeek.nthDay)) {
currentDate.applyDateOperation(dateMathVerb, CronDate_1.TimeUnit.Day, this.#fields.hour.values.length);
continue;
}
if (!CronExpression.#matchSchedule(currentDate.getMonth() + 1, this.#fields.month.values)) {
currentDate.applyDateOperation(dateMathVerb, CronDate_1.TimeUnit.Month, this.#fields.hour.values.length);
continue;
}
if (!this.#matchHour(currentDate, dateMathVerb, reverse)) {
continue;
}
if (!CronExpression.#matchSchedule(currentDate.getMinutes(), this.#fields.minute.values)) {
currentDate.applyDateOperation(dateMathVerb, CronDate_1.TimeUnit.Minute, this.#fields.hour.values.length);
continue;
}
if (!CronExpression.#matchSchedule(currentDate.getSeconds(), this.#fields.second.values)) {
currentDate.applyDateOperation(dateMathVerb, CronDate_1.TimeUnit.Second, this.#fields.hour.values.length);
continue;
}
if (startTimestamp === currentDate.getTime()) {
if (dateMathVerb === 'Add' || currentDate.getMilliseconds() === 0) {
currentDate.applyDateOperation(dateMathVerb, CronDate_1.TimeUnit.Second, this.#fields.hour.values.length);
}
continue;
}
break;
}
/* istanbul ignore next - should be impossible under normal use to trigger the branch */
if (stepCount > LOOP_LIMIT) {
throw new Error(exports.LOOPS_LIMIT_EXCEEDED_ERROR_MESSAGE);
}
if (currentDate.getMilliseconds() !== 0) {
currentDate.setMilliseconds(0);
}
this.#currentDate = currentDate;
return currentDate;
}
/**
* Returns an iterator for iterating through future CronDate instances
*
* @name Symbol.iterator
* @memberof CronExpression
* @returns {Iterator<CronDate>} An iterator object for CronExpression that returns CronDate values.
*/
[Symbol.iterator]() {
return {
next: () => {
const schedule = this.#findSchedule();
return { value: schedule, done: !this.hasNext() };
},
};
}
}
exports.CronExpression = CronExpression;
exports.default = CronExpression;

382
node_modules/cron-parser/dist/CronExpressionParser.js generated vendored Normal file
View File

@@ -0,0 +1,382 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CronExpressionParser = exports.DayOfWeek = exports.Months = exports.CronUnit = exports.PredefinedExpressions = void 0;
const CronFieldCollection_1 = require("./CronFieldCollection");
const CronExpression_1 = require("./CronExpression");
const random_1 = require("./utils/random");
const fields_1 = require("./fields");
var PredefinedExpressions;
(function (PredefinedExpressions) {
PredefinedExpressions["@yearly"] = "0 0 0 1 1 *";
PredefinedExpressions["@annually"] = "0 0 0 1 1 *";
PredefinedExpressions["@monthly"] = "0 0 0 1 * *";
PredefinedExpressions["@weekly"] = "0 0 0 * * 0";
PredefinedExpressions["@daily"] = "0 0 0 * * *";
PredefinedExpressions["@hourly"] = "0 0 * * * *";
PredefinedExpressions["@minutely"] = "0 * * * * *";
PredefinedExpressions["@secondly"] = "* * * * * *";
PredefinedExpressions["@weekdays"] = "0 0 0 * * 1-5";
PredefinedExpressions["@weekends"] = "0 0 0 * * 0,6";
})(PredefinedExpressions || (exports.PredefinedExpressions = PredefinedExpressions = {}));
var CronUnit;
(function (CronUnit) {
CronUnit["Second"] = "Second";
CronUnit["Minute"] = "Minute";
CronUnit["Hour"] = "Hour";
CronUnit["DayOfMonth"] = "DayOfMonth";
CronUnit["Month"] = "Month";
CronUnit["DayOfWeek"] = "DayOfWeek";
})(CronUnit || (exports.CronUnit = CronUnit = {}));
// these need to be lowercase for the parser to work
var Months;
(function (Months) {
Months[Months["jan"] = 1] = "jan";
Months[Months["feb"] = 2] = "feb";
Months[Months["mar"] = 3] = "mar";
Months[Months["apr"] = 4] = "apr";
Months[Months["may"] = 5] = "may";
Months[Months["jun"] = 6] = "jun";
Months[Months["jul"] = 7] = "jul";
Months[Months["aug"] = 8] = "aug";
Months[Months["sep"] = 9] = "sep";
Months[Months["oct"] = 10] = "oct";
Months[Months["nov"] = 11] = "nov";
Months[Months["dec"] = 12] = "dec";
})(Months || (exports.Months = Months = {}));
// these need to be lowercase for the parser to work
var DayOfWeek;
(function (DayOfWeek) {
DayOfWeek[DayOfWeek["sun"] = 0] = "sun";
DayOfWeek[DayOfWeek["mon"] = 1] = "mon";
DayOfWeek[DayOfWeek["tue"] = 2] = "tue";
DayOfWeek[DayOfWeek["wed"] = 3] = "wed";
DayOfWeek[DayOfWeek["thu"] = 4] = "thu";
DayOfWeek[DayOfWeek["fri"] = 5] = "fri";
DayOfWeek[DayOfWeek["sat"] = 6] = "sat";
})(DayOfWeek || (exports.DayOfWeek = DayOfWeek = {}));
/**
* Static class that parses a cron expression and returns a CronExpression object.
* @static
* @class CronExpressionParser
*/
class CronExpressionParser {
/**
* Parses a cron expression and returns a CronExpression object.
* @param {string} expression - The cron expression to parse.
* @param {CronExpressionOptions} [options={}] - The options to use when parsing the expression.
* @param {boolean} [options.strict=false] - If true, will throw an error if the expression contains both dayOfMonth and dayOfWeek.
* @param {CronDate} [options.currentDate=new CronDate(undefined, 'UTC')] - The date to use when calculating the next/previous occurrence.
*
* @returns {CronExpression} A CronExpression object.
*/
static parse(expression, options = {}) {
const { strict = false, hashSeed } = options;
const rand = (0, random_1.seededRandom)(hashSeed);
expression = PredefinedExpressions[expression] || expression;
const rawFields = CronExpressionParser.#getRawFields(expression, strict);
if (!(rawFields.dayOfMonth === '*' || rawFields.dayOfWeek === '*' || !strict)) {
throw new Error('Cannot use both dayOfMonth and dayOfWeek together in strict mode!');
}
const second = CronExpressionParser.#parseField(CronUnit.Second, rawFields.second, fields_1.CronSecond.constraints, rand);
const minute = CronExpressionParser.#parseField(CronUnit.Minute, rawFields.minute, fields_1.CronMinute.constraints, rand);
const hour = CronExpressionParser.#parseField(CronUnit.Hour, rawFields.hour, fields_1.CronHour.constraints, rand);
const month = CronExpressionParser.#parseField(CronUnit.Month, rawFields.month, fields_1.CronMonth.constraints, rand);
const dayOfMonth = CronExpressionParser.#parseField(CronUnit.DayOfMonth, rawFields.dayOfMonth, fields_1.CronDayOfMonth.constraints, rand);
const { dayOfWeek: _dayOfWeek, nthDayOfWeek } = CronExpressionParser.#parseNthDay(rawFields.dayOfWeek);
const dayOfWeek = CronExpressionParser.#parseField(CronUnit.DayOfWeek, _dayOfWeek, fields_1.CronDayOfWeek.constraints, rand);
const fields = new CronFieldCollection_1.CronFieldCollection({
second: new fields_1.CronSecond(second, { rawValue: rawFields.second }),
minute: new fields_1.CronMinute(minute, { rawValue: rawFields.minute }),
hour: new fields_1.CronHour(hour, { rawValue: rawFields.hour }),
dayOfMonth: new fields_1.CronDayOfMonth(dayOfMonth, { rawValue: rawFields.dayOfMonth }),
month: new fields_1.CronMonth(month, { rawValue: rawFields.month }),
dayOfWeek: new fields_1.CronDayOfWeek(dayOfWeek, { rawValue: rawFields.dayOfWeek, nthDayOfWeek }),
});
return new CronExpression_1.CronExpression(fields, { ...options, expression });
}
/**
* Get the raw fields from a cron expression.
* @param {string} expression - The cron expression to parse.
* @param {boolean} strict - If true, will throw an error if the expression contains both dayOfMonth and dayOfWeek.
* @private
* @returns {RawCronFields} The raw fields.
*/
static #getRawFields(expression, strict) {
if (strict && !expression.length) {
throw new Error('Invalid cron expression');
}
expression = expression || '0 * * * * *';
const atoms = expression.trim().split(/\s+/);
if (strict && atoms.length < 6) {
throw new Error('Invalid cron expression, expected 6 fields');
}
if (atoms.length > 6) {
throw new Error('Invalid cron expression, too many fields');
}
const defaults = ['*', '*', '*', '*', '*', '0'];
if (atoms.length < defaults.length) {
atoms.unshift(...defaults.slice(atoms.length));
}
const [second, minute, hour, dayOfMonth, month, dayOfWeek] = atoms;
return { second, minute, hour, dayOfMonth, month, dayOfWeek };
}
/**
* Parse a field from a cron expression.
* @param {CronUnit} field - The field to parse.
* @param {string} value - The value of the field.
* @param {CronConstraints} constraints - The constraints for the field.
* @private
* @returns {(number | string)[]} The parsed field.
*/
static #parseField(field, value, constraints, rand) {
// Replace aliases for month and dayOfWeek
if (field === CronUnit.Month || field === CronUnit.DayOfWeek) {
value = value.replace(/[a-z]{3}/gi, (match) => {
match = match.toLowerCase();
const replacer = Months[match] || DayOfWeek[match];
if (replacer === undefined) {
throw new Error(`Validation error, cannot resolve alias "${match}"`);
}
return replacer.toString();
});
}
// Check for valid characters
if (!constraints.validChars.test(value)) {
throw new Error(`Invalid characters, got value: ${value}`);
}
value = this.#parseWildcard(value, constraints);
value = this.#parseHashed(value, constraints, rand);
return this.#parseSequence(field, value, constraints);
}
/**
* Parse a wildcard from a cron expression.
* @param {string} value - The value to parse.
* @param {CronConstraints} constraints - The constraints for the field.
* @private
*/
static #parseWildcard(value, constraints) {
return value.replace(/[*?]/g, constraints.min + '-' + constraints.max);
}
/**
* Parse a hashed value from a cron expression.
* @param {string} value - The value to parse.
* @param {CronConstraints} constraints - The constraints for the field.
* @param {PRNG} rand - The random number generator to use.
* @private
*/
static #parseHashed(value, constraints, rand) {
const randomValue = rand();
return value.replace(/H(?:\((\d+)-(\d+)\))?(?:\/(\d+))?/g, (_, min, max, step) => {
// H(range)/step
if (min && max && step) {
const minNum = parseInt(min, 10);
const maxNum = parseInt(max, 10);
const stepNum = parseInt(step, 10);
if (minNum > maxNum) {
throw new Error(`Invalid range: ${minNum}-${maxNum}, min > max`);
}
if (stepNum <= 0) {
throw new Error(`Invalid step: ${stepNum}, must be positive`);
}
const minStart = Math.max(minNum, constraints.min);
const offset = Math.floor(randomValue * stepNum);
const values = [];
for (let i = Math.floor(minStart / stepNum) * stepNum + offset; i <= maxNum; i += stepNum) {
if (i >= minStart) {
values.push(i);
}
}
return values.join(',');
}
// H(range)
else if (min && max) {
const minNum = parseInt(min, 10);
const maxNum = parseInt(max, 10);
if (minNum > maxNum) {
throw new Error(`Invalid range: ${minNum}-${maxNum}, min > max`);
}
return String(Math.floor(randomValue * (maxNum - minNum + 1)) + minNum);
}
// H/step
else if (step) {
const stepNum = parseInt(step, 10);
// Validate step
if (stepNum <= 0) {
throw new Error(`Invalid step: ${stepNum}, must be positive`);
}
const offset = Math.floor(randomValue * stepNum);
const values = [];
for (let i = Math.floor(constraints.min / stepNum) * stepNum + offset; i <= constraints.max; i += stepNum) {
if (i >= constraints.min) {
values.push(i);
}
}
return values.join(',');
}
// H
else {
return String(Math.floor(randomValue * (constraints.max - constraints.min + 1) + constraints.min));
}
});
}
/**
* Parse a sequence from a cron expression.
* @param {CronUnit} field - The field to parse.
* @param {string} val - The sequence to parse.
* @param {CronConstraints} constraints - The constraints for the field.
* @private
*/
static #parseSequence(field, val, constraints) {
const stack = [];
function handleResult(result, constraints) {
if (Array.isArray(result)) {
stack.push(...result);
}
else {
if (CronExpressionParser.#isValidConstraintChar(constraints, result)) {
stack.push(result);
}
else {
const v = parseInt(result.toString(), 10);
const isValid = v >= constraints.min && v <= constraints.max;
if (!isValid) {
throw new Error(`Constraint error, got value ${result} expected range ${constraints.min}-${constraints.max}`);
}
stack.push(field === CronUnit.DayOfWeek ? v % 7 : result);
}
}
}
const atoms = val.split(',');
atoms.forEach((atom) => {
if (!(atom.length > 0)) {
throw new Error('Invalid list value format');
}
handleResult(CronExpressionParser.#parseRepeat(field, atom, constraints), constraints);
});
return stack;
}
/**
* Parse repeat from a cron expression.
* @param {CronUnit} field - The field to parse.
* @param {string} val - The repeat to parse.
* @param {CronConstraints} constraints - The constraints for the field.
* @private
* @returns {(number | string)[]} The parsed repeat.
*/
static #parseRepeat(field, val, constraints) {
const atoms = val.split('/');
if (atoms.length > 2) {
throw new Error(`Invalid repeat: ${val}`);
}
if (atoms.length === 2) {
if (!isNaN(parseInt(atoms[0], 10))) {
atoms[0] = `${atoms[0]}-${constraints.max}`;
}
return CronExpressionParser.#parseRange(field, atoms[0], parseInt(atoms[1], 10), constraints);
}
return CronExpressionParser.#parseRange(field, val, 1, constraints);
}
/**
* Validate a cron range.
* @param {number} min - The minimum value of the range.
* @param {number} max - The maximum value of the range.
* @param {CronConstraints} constraints - The constraints for the field.
* @private
* @returns {void}
* @throws {Error} Throws an error if the range is invalid.
*/
static #validateRange(min, max, constraints) {
const isValid = !isNaN(min) && !isNaN(max) && min >= constraints.min && max <= constraints.max;
if (!isValid) {
throw new Error(`Constraint error, got range ${min}-${max} expected range ${constraints.min}-${constraints.max}`);
}
if (min > max) {
throw new Error(`Invalid range: ${min}-${max}, min(${min}) > max(${max})`);
}
}
/**
* Validate a cron repeat interval.
* @param {number} repeatInterval - The repeat interval to validate.
* @private
* @returns {void}
* @throws {Error} Throws an error if the repeat interval is invalid.
*/
static #validateRepeatInterval(repeatInterval) {
if (!(!isNaN(repeatInterval) && repeatInterval > 0)) {
throw new Error(`Constraint error, cannot repeat at every ${repeatInterval} time.`);
}
}
/**
* Create a range from a cron expression.
* @param {CronUnit} field - The field to parse.
* @param {number} min - The minimum value of the range.
* @param {number} max - The maximum value of the range.
* @param {number} repeatInterval - The repeat interval of the range.
* @private
* @returns {number[]} The created range.
*/
static #createRange(field, min, max, repeatInterval) {
const stack = [];
if (field === CronUnit.DayOfWeek && max % 7 === 0) {
stack.push(0);
}
for (let index = min; index <= max; index += repeatInterval) {
if (stack.indexOf(index) === -1) {
stack.push(index);
}
}
return stack;
}
/**
* Parse a range from a cron expression.
* @param {CronUnit} field - The field to parse.
* @param {string} val - The range to parse.
* @param {number} repeatInterval - The repeat interval of the range.
* @param {CronConstraints} constraints - The constraints for the field.
* @private
* @returns {number[] | string[] | number | string} The parsed range.
*/
static #parseRange(field, val, repeatInterval, constraints) {
const atoms = val.split('-');
if (atoms.length <= 1) {
return isNaN(+val) ? val : +val;
}
const [min, max] = atoms.map((num) => parseInt(num, 10));
this.#validateRange(min, max, constraints);
this.#validateRepeatInterval(repeatInterval);
// Create range
return this.#createRange(field, min, max, repeatInterval);
}
/**
* Parse a cron expression.
* @param {string} val - The cron expression to parse.
* @private
* @returns {string} The parsed cron expression.
*/
static #parseNthDay(val) {
const atoms = val.split('#');
if (atoms.length <= 1) {
return { dayOfWeek: atoms[0] };
}
const nthValue = +atoms[atoms.length - 1];
const matches = val.match(/([,-/])/);
if (matches !== null) {
throw new Error(`Constraint error, invalid dayOfWeek \`#\` and \`${matches?.[0]}\` special characters are incompatible`);
}
if (!(atoms.length <= 2 && !isNaN(nthValue) && nthValue >= 1 && nthValue <= 5)) {
throw new Error('Constraint error, invalid dayOfWeek occurrence number (#)');
}
return { dayOfWeek: atoms[0], nthDayOfWeek: nthValue };
}
/**
* Checks if a character is valid for a field.
* @param {CronConstraints} constraints - The constraints for the field.
* @param {string | number} value - The value to check.
* @private
* @returns {boolean} Whether the character is valid for the field.
*/
static #isValidConstraintChar(constraints, value) {
return constraints.chars.some((char) => value.toString().includes(char));
}
}
exports.CronExpressionParser = CronExpressionParser;

371
node_modules/cron-parser/dist/CronFieldCollection.js generated vendored Normal file
View File

@@ -0,0 +1,371 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CronFieldCollection = void 0;
const fields_1 = require("./fields");
/**
* Represents a complete set of cron fields.
* @class CronFieldCollection
*/
class CronFieldCollection {
#second;
#minute;
#hour;
#dayOfMonth;
#month;
#dayOfWeek;
/**
* Creates a new CronFieldCollection instance by partially overriding fields from an existing one.
* @param {CronFieldCollection} base - The base CronFieldCollection to copy fields from
* @param {CronFieldOverride} fields - The fields to override, can be CronField instances or raw values
* @returns {CronFieldCollection} A new CronFieldCollection instance
* @example
* const base = new CronFieldCollection({
* second: new CronSecond([0]),
* minute: new CronMinute([0]),
* hour: new CronHour([12]),
* dayOfMonth: new CronDayOfMonth([1]),
* month: new CronMonth([1]),
* dayOfWeek: new CronDayOfWeek([1])
* });
*
* // Using CronField instances
* const modified1 = CronFieldCollection.from(base, {
* hour: new CronHour([15]),
* minute: new CronMinute([30])
* });
*
* // Using raw values
* const modified2 = CronFieldCollection.from(base, {
* hour: [15], // Will create new CronHour
* minute: [30] // Will create new CronMinute
* });
*/
static from(base, fields) {
return new CronFieldCollection({
second: this.resolveField(fields_1.CronSecond, base.second, fields.second),
minute: this.resolveField(fields_1.CronMinute, base.minute, fields.minute),
hour: this.resolveField(fields_1.CronHour, base.hour, fields.hour),
dayOfMonth: this.resolveField(fields_1.CronDayOfMonth, base.dayOfMonth, fields.dayOfMonth),
month: this.resolveField(fields_1.CronMonth, base.month, fields.month),
dayOfWeek: this.resolveField(fields_1.CronDayOfWeek, base.dayOfWeek, fields.dayOfWeek),
});
}
/**
* Resolves a field value, either using the provided CronField instance or creating a new one from raw values.
* @param constructor - The constructor for creating new field instances
* @param baseField - The base field to use if no override is provided
* @param fieldValue - The override value, either a CronField instance or raw values
* @returns The resolved CronField instance
* @private
*/
static resolveField(constructor, baseField, fieldValue) {
if (!fieldValue) {
return baseField;
}
if (fieldValue instanceof fields_1.CronField) {
return fieldValue;
}
return new constructor(fieldValue);
}
/**
* CronFieldCollection constructor. Initializes the cron fields with the provided values.
* @param {CronFields} param0 - The cron fields values
* @throws {Error} if validation fails
* @example
* const cronFields = new CronFieldCollection({
* second: new CronSecond([0]),
* minute: new CronMinute([0, 30]),
* hour: new CronHour([9]),
* dayOfMonth: new CronDayOfMonth([15]),
* month: new CronMonth([1]),
* dayOfWeek: new CronDayOfTheWeek([1, 2, 3, 4, 5]),
* })
*
* console.log(cronFields.second.values); // [0]
* console.log(cronFields.minute.values); // [0, 30]
* console.log(cronFields.hour.values); // [9]
* console.log(cronFields.dayOfMonth.values); // [15]
* console.log(cronFields.month.values); // [1]
* console.log(cronFields.dayOfWeek.values); // [1, 2, 3, 4, 5]
*/
constructor({ second, minute, hour, dayOfMonth, month, dayOfWeek }) {
if (!second) {
throw new Error('Validation error, Field second is missing');
}
if (!minute) {
throw new Error('Validation error, Field minute is missing');
}
if (!hour) {
throw new Error('Validation error, Field hour is missing');
}
if (!dayOfMonth) {
throw new Error('Validation error, Field dayOfMonth is missing');
}
if (!month) {
throw new Error('Validation error, Field month is missing');
}
if (!dayOfWeek) {
throw new Error('Validation error, Field dayOfWeek is missing');
}
if (month.values.length === 1 && !dayOfMonth.hasLastChar) {
if (!(parseInt(dayOfMonth.values[0], 10) <= fields_1.CronMonth.daysInMonth[month.values[0] - 1])) {
throw new Error('Invalid explicit day of month definition');
}
}
this.#second = second;
this.#minute = minute;
this.#hour = hour;
this.#month = month;
this.#dayOfWeek = dayOfWeek;
this.#dayOfMonth = dayOfMonth;
}
/**
* Returns the second field.
* @returns {CronSecond}
*/
get second() {
return this.#second;
}
/**
* Returns the minute field.
* @returns {CronMinute}
*/
get minute() {
return this.#minute;
}
/**
* Returns the hour field.
* @returns {CronHour}
*/
get hour() {
return this.#hour;
}
/**
* Returns the day of the month field.
* @returns {CronDayOfMonth}
*/
get dayOfMonth() {
return this.#dayOfMonth;
}
/**
* Returns the month field.
* @returns {CronMonth}
*/
get month() {
return this.#month;
}
/**
* Returns the day of the week field.
* @returns {CronDayOfWeek}
*/
get dayOfWeek() {
return this.#dayOfWeek;
}
/**
* Returns a string representation of the cron fields.
* @param {(number | CronChars)[]} input - The cron fields values
* @static
* @returns {FieldRange[]} - The compacted cron fields
*/
static compactField(input) {
if (input.length === 0) {
return [];
}
// Initialize the output array and current IFieldRange
const output = [];
let current = undefined;
input.forEach((item, i, arr) => {
// If the current FieldRange is undefined, create a new one with the current item as the start.
if (current === undefined) {
current = { start: item, count: 1 };
return;
}
// Cache the previous and next items in the array.
const prevItem = arr[i - 1] || current.start;
const nextItem = arr[i + 1];
// If the current item is 'L' or 'W', push the current FieldRange to the output and
// create a new FieldRange with the current item as the start.
// 'L' and 'W' characters are special cases that need to be handled separately.
if (item === 'L' || item === 'W') {
output.push(current);
output.push({ start: item, count: 1 });
current = undefined;
return;
}
// If the current step is undefined and there is a next item, update the current IFieldRange.
// This block checks if the current step needs to be updated and does so if needed.
if (current.step === undefined && nextItem !== undefined) {
const step = item - prevItem;
const nextStep = nextItem - item;
// If the current step is less or equal to the next step, update the current FieldRange to include the current item.
if (step <= nextStep) {
current = { ...current, count: 2, end: item, step };
return;
}
current.step = 1;
}
// If the difference between the current item and the current end is equal to the current step,
// update the current IFieldRange's count and end.
// This block checks if the current item is part of the current range and updates the range accordingly.
if (item - (current.end ?? 0) === current.step) {
current.count++;
current.end = item;
}
else {
// If the count is 1, push a new FieldRange with the current start.
// This handles the case where the current range has only one element.
if (current.count === 1) {
// If the count is 2, push two separate IFieldRanges, one for each element.
output.push({ start: current.start, count: 1 });
}
else if (current.count === 2) {
output.push({ start: current.start, count: 1 });
// current.end can never be undefined here but typescript doesn't know that
// this is why we ?? it and then ignore the prevItem in the coverage
output.push({
start: current.end ?? /* istanbul ignore next - see above */ prevItem,
count: 1,
});
}
else {
// Otherwise, push the current FieldRange to the output.
output.push(current);
}
// Reset the current FieldRange with the current item as the start.
current = { start: item, count: 1 };
}
});
// Push the final IFieldRange, if any, to the output array.
if (current) {
output.push(current);
}
return output;
}
/**
* Handles a single range.
* @param {CronField} field - The cron field to stringify
* @param {FieldRange} range {start: number, end: number, step: number, count: number} The range to handle.
* @param {number} max The maximum value for the field.
* @returns {string | null} The stringified range or null if it cannot be stringified.
* @private
*/
static #handleSingleRange(field, range, max) {
const step = range.step;
if (!step) {
return null;
}
if (step === 1 && range.start === field.min && range.end && range.end >= max) {
return field.hasQuestionMarkChar ? '?' : '*';
}
if (step !== 1 && range.start === field.min && range.end && range.end >= max - step + 1) {
return `*/${step}`;
}
return null;
}
/**
* Handles multiple ranges.
* @param {FieldRange} range {start: number, end: number, step: number, count: number} The range to handle.
* @param {number} max The maximum value for the field.
* @returns {string} The stringified range.
* @private
*/
static #handleMultipleRanges(range, max) {
const step = range.step;
if (step === 1) {
return `${range.start}-${range.end}`;
}
const multiplier = range.start === 0 ? range.count - 1 : range.count;
/* istanbul ignore if */
if (!step) {
throw new Error('Unexpected range step');
}
/* istanbul ignore if */
if (!range.end) {
throw new Error('Unexpected range end');
}
if (step * multiplier > range.end) {
const mapFn = (_, index) => {
/* istanbul ignore if */
if (typeof range.start !== 'number') {
throw new Error('Unexpected range start');
}
return index % step === 0 ? range.start + index : null;
};
/* istanbul ignore if */
if (typeof range.start !== 'number') {
throw new Error('Unexpected range start');
}
const seed = { length: range.end - range.start + 1 };
return Array.from(seed, mapFn)
.filter((value) => value !== null)
.join(',');
}
return range.end === max - step + 1 ? `${range.start}/${step}` : `${range.start}-${range.end}/${step}`;
}
/**
* Returns a string representation of the cron fields.
* @param {CronField} field - The cron field to stringify
* @static
* @returns {string} - The stringified cron field
*/
stringifyField(field) {
let max = field.max;
let values = field.values;
if (field instanceof fields_1.CronDayOfWeek) {
max = 6;
const dayOfWeek = this.#dayOfWeek.values;
values = dayOfWeek[dayOfWeek.length - 1] === 7 ? dayOfWeek.slice(0, -1) : dayOfWeek;
}
if (field instanceof fields_1.CronDayOfMonth) {
max = this.#month.values.length === 1 ? fields_1.CronMonth.daysInMonth[this.#month.values[0] - 1] : field.max;
}
const ranges = CronFieldCollection.compactField(values);
if (ranges.length === 1) {
const singleRangeResult = CronFieldCollection.#handleSingleRange(field, ranges[0], max);
if (singleRangeResult) {
return singleRangeResult;
}
}
return ranges
.map((range) => {
const value = range.count === 1 ? range.start.toString() : CronFieldCollection.#handleMultipleRanges(range, max);
if (field instanceof fields_1.CronDayOfWeek && field.nthDay > 0) {
return `${value}#${field.nthDay}`;
}
return value;
})
.join(',');
}
/**
* Returns a string representation of the cron field values.
* @param {boolean} includeSeconds - Whether to include seconds in the output
* @returns {string} The formatted cron string
*/
stringify(includeSeconds = false) {
const arr = [];
if (includeSeconds) {
arr.push(this.stringifyField(this.#second)); // second
}
arr.push(this.stringifyField(this.#minute), // minute
this.stringifyField(this.#hour), // hour
this.stringifyField(this.#dayOfMonth), // dayOfMonth
this.stringifyField(this.#month), // month
this.stringifyField(this.#dayOfWeek));
return arr.join(' ');
}
/**
* Returns a serialized representation of the cron fields values.
* @returns {SerializedCronFields} An object containing the cron field values
*/
serialize() {
return {
second: this.#second.serialize(),
minute: this.#minute.serialize(),
hour: this.#hour.serialize(),
dayOfMonth: this.#dayOfMonth.serialize(),
month: this.#month.serialize(),
dayOfWeek: this.#dayOfWeek.serialize(),
};
}
}
exports.CronFieldCollection = CronFieldCollection;

109
node_modules/cron-parser/dist/CronFileParser.js generated vendored Normal file
View File

@@ -0,0 +1,109 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.CronFileParser = void 0;
const CronExpressionParser_1 = require("./CronExpressionParser");
/**
* Parser for crontab files that handles both synchronous and asynchronous operations.
*/
class CronFileParser {
/**
* Parse a crontab file asynchronously
* @param filePath Path to crontab file
* @returns Promise resolving to parse results
* @throws If file cannot be read
*/
static async parseFile(filePath) {
const { readFile } = await Promise.resolve().then(() => __importStar(require('fs/promises')));
const data = await readFile(filePath, 'utf8');
return CronFileParser.#parseContent(data);
}
/**
* Parse a crontab file synchronously
* @param filePath Path to crontab file
* @returns Parse results
* @throws If file cannot be read
*/
static parseFileSync(filePath) {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { readFileSync } = require('fs');
const data = readFileSync(filePath, 'utf8');
return CronFileParser.#parseContent(data);
}
/**
* Internal method to parse crontab file content
* @private
*/
static #parseContent(data) {
const blocks = data.split('\n');
const result = {
variables: {},
expressions: [],
errors: {},
};
for (const block of blocks) {
const entry = block.trim();
if (entry.length === 0 || entry.startsWith('#')) {
continue;
}
const variableMatch = entry.match(/^(.*)=(.*)$/);
if (variableMatch) {
const [, key, value] = variableMatch;
result.variables[key] = value.replace(/["']/g, ''); // Remove quotes
continue;
}
try {
const parsedEntry = CronFileParser.#parseEntry(entry);
result.expressions.push(parsedEntry.interval);
}
catch (err) {
result.errors[entry] = err;
}
}
return result;
}
/**
* Parse a single crontab entry
* @private
*/
static #parseEntry(entry) {
const atoms = entry.split(' ');
return {
interval: CronExpressionParser_1.CronExpressionParser.parse(atoms.slice(0, 5).join(' ')),
command: atoms.slice(5, atoms.length),
};
}
}
exports.CronFileParser = CronFileParser;

44
node_modules/cron-parser/dist/fields/CronDayOfMonth.js generated vendored Normal file
View File

@@ -0,0 +1,44 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CronDayOfMonth = void 0;
const CronField_1 = require("./CronField");
const MIN_DAY = 1;
const MAX_DAY = 31;
const DAY_CHARS = Object.freeze(['L']);
/**
* Represents the "day of the month" field within a cron expression.
* @class CronDayOfMonth
* @extends CronField
*/
class CronDayOfMonth extends CronField_1.CronField {
static get min() {
return MIN_DAY;
}
static get max() {
return MAX_DAY;
}
static get chars() {
return DAY_CHARS;
}
static get validChars() {
return /^[?,*\dLH/-]+$|^.*H\(\d+-\d+\)\/\d+.*$|^.*H\(\d+-\d+\).*$|^.*H\/\d+.*$/;
}
/**
* CronDayOfMonth constructor. Initializes the "day of the month" field with the provided values.
* @param {DayOfMonthRange[]} values - Values for the "day of the month" field
* @param {CronFieldOptions} [options] - Options provided by the parser
* @throws {Error} if validation fails
*/
constructor(values, options) {
super(values, options);
this.validate();
}
/**
* Returns an array of allowed values for the "day of the month" field.
* @returns {DayOfMonthRange[]}
*/
get values() {
return super.values;
}
}
exports.CronDayOfMonth = CronDayOfMonth;

51
node_modules/cron-parser/dist/fields/CronDayOfWeek.js generated vendored Normal file
View File

@@ -0,0 +1,51 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CronDayOfWeek = void 0;
const CronField_1 = require("./CronField");
const MIN_DAY = 0;
const MAX_DAY = 7;
const DAY_CHARS = Object.freeze(['L']);
/**
* Represents the "day of the week" field within a cron expression.
* @class CronDayOfTheWeek
* @extends CronField
*/
class CronDayOfWeek extends CronField_1.CronField {
static get min() {
return MIN_DAY;
}
static get max() {
return MAX_DAY;
}
static get chars() {
return DAY_CHARS;
}
static get validChars() {
return /^[?,*\dLH#/-]+$|^.*H\(\d+-\d+\)\/\d+.*$|^.*H\(\d+-\d+\).*$|^.*H\/\d+.*$/;
}
/**
* CronDayOfTheWeek constructor. Initializes the "day of the week" field with the provided values.
* @param {DayOfWeekRange[]} values - Values for the "day of the week" field
* @param {CronFieldOptions} [options] - Options provided by the parser
*/
constructor(values, options) {
super(values, options);
this.validate();
}
/**
* Returns an array of allowed values for the "day of the week" field.
* @returns {DayOfWeekRange[]}
*/
get values() {
return super.values;
}
/**
* Returns the nth day of the week if specified in the cron expression.
* This is used for the '#' character in the cron expression.
* @returns {number} The nth day of the week (1-5) or 0 if not specified.
*/
get nthDay() {
return this.options.nthDayOfWeek ?? 0;
}
}
exports.CronDayOfWeek = CronDayOfWeek;

183
node_modules/cron-parser/dist/fields/CronField.js generated vendored Normal file
View File

@@ -0,0 +1,183 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CronField = void 0;
/**
* Represents a field within a cron expression.
* This is a base class and should not be instantiated directly.
* @class CronField
*/
class CronField {
#hasLastChar = false;
#hasQuestionMarkChar = false;
#wildcard = false;
#values = [];
options = { rawValue: '' };
/**
* Returns the minimum value allowed for this field.
*/
/* istanbul ignore next */ static get min() {
/* istanbul ignore next */
throw new Error('min must be overridden');
}
/**
* Returns the maximum value allowed for this field.
*/
/* istanbul ignore next */ static get max() {
/* istanbul ignore next */
throw new Error('max must be overridden');
}
/**
* Returns the allowed characters for this field.
*/
/* istanbul ignore next */ static get chars() {
/* istanbul ignore next - this is overridden */
return Object.freeze([]);
}
/**
* Returns the regular expression used to validate this field.
*/
static get validChars() {
return /^[?,*\dH/-]+$|^.*H\(\d+-\d+\)\/\d+.*$|^.*H\(\d+-\d+\).*$|^.*H\/\d+.*$/;
}
/**
* Returns the constraints for this field.
*/
static get constraints() {
return { min: this.min, max: this.max, chars: this.chars, validChars: this.validChars };
}
/**
* CronField constructor. Initializes the field with the provided values.
* @param {number[] | string[]} values - Values for this field
* @param {CronFieldOptions} [options] - Options provided by the parser
* @throws {TypeError} if the constructor is called directly
* @throws {Error} if validation fails
*/
constructor(values, options = { rawValue: '' }) {
if (!Array.isArray(values)) {
throw new Error(`${this.constructor.name} Validation error, values is not an array`);
}
if (!(values.length > 0)) {
throw new Error(`${this.constructor.name} Validation error, values contains no values`);
}
/* istanbul ignore next */
this.options = {
...options,
rawValue: options.rawValue ?? '',
};
this.#values = values.sort(CronField.sorter);
this.#wildcard = this.options.wildcard !== undefined ? this.options.wildcard : this.#isWildcardValue();
this.#hasLastChar = this.options.rawValue.includes('L') || values.includes('L');
this.#hasQuestionMarkChar = this.options.rawValue.includes('?') || values.includes('?');
}
/**
* Returns the minimum value allowed for this field.
* @returns {number}
*/
get min() {
// return the static value from the child class
return this.constructor.min;
}
/**
* Returns the maximum value allowed for this field.
* @returns {number}
*/
get max() {
// return the static value from the child class
return this.constructor.max;
}
/**
* Returns an array of allowed special characters for this field.
* @returns {string[]}
*/
get chars() {
// return the frozen static value from the child class
return this.constructor.chars;
}
/**
* Indicates whether this field has a "last" character.
* @returns {boolean}
*/
get hasLastChar() {
return this.#hasLastChar;
}
/**
* Indicates whether this field has a "question mark" character.
* @returns {boolean}
*/
get hasQuestionMarkChar() {
return this.#hasQuestionMarkChar;
}
/**
* Indicates whether this field is a wildcard.
* @returns {boolean}
*/
get isWildcard() {
return this.#wildcard;
}
/**
* Returns an array of allowed values for this field.
* @returns {CronFieldType}
*/
get values() {
return this.#values;
}
/**
* Helper function to sort values in ascending order.
* @param {number | string} a - First value to compare
* @param {number | string} b - Second value to compare
* @returns {number} - A negative, zero, or positive value, depending on the sort order
*/
static sorter(a, b) {
const aIsNumber = typeof a === 'number';
const bIsNumber = typeof b === 'number';
if (aIsNumber && bIsNumber)
return a - b;
if (!aIsNumber && !bIsNumber)
return a.localeCompare(b);
return aIsNumber ? /* istanbul ignore next - A will always be a number until L-2 is supported */ -1 : 1;
}
/**
* Serializes the field to an object.
* @returns {SerializedCronField}
*/
serialize() {
return {
wildcard: this.#wildcard,
values: this.#values,
};
}
/**
* Validates the field values against the allowed range and special characters.
* @throws {Error} if validation fails
*/
validate() {
let badValue;
const charsString = this.chars.length > 0 ? ` or chars ${this.chars.join('')}` : '';
const charTest = (value) => (char) => new RegExp(`^\\d{0,2}${char}$`).test(value);
const rangeTest = (value) => {
badValue = value;
return typeof value === 'number' ? value >= this.min && value <= this.max : this.chars.some(charTest(value));
};
const isValidRange = this.#values.every(rangeTest);
if (!isValidRange) {
throw new Error(`${this.constructor.name} Validation error, got value ${badValue} expected range ${this.min}-${this.max}${charsString}`);
}
// check for duplicate value in this.#values array
const duplicate = this.#values.find((value, index) => this.#values.indexOf(value) !== index);
if (duplicate) {
throw new Error(`${this.constructor.name} Validation error, duplicate values found: ${duplicate}`);
}
}
/**
* Determines if the field is a wildcard based on the values.
* When options.rawValue is not empty, it checks if the raw value is a wildcard, otherwise it checks if all values in the range are included.
* @returns {boolean}
*/
#isWildcardValue() {
if (this.options.rawValue.length > 0) {
return ['*', '?'].includes(this.options.rawValue);
}
return Array.from({ length: this.max - this.min + 1 }, (_, i) => i + this.min).every((value) => this.#values.includes(value));
}
}
exports.CronField = CronField;

40
node_modules/cron-parser/dist/fields/CronHour.js generated vendored Normal file
View File

@@ -0,0 +1,40 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CronHour = void 0;
const CronField_1 = require("./CronField");
const MIN_HOUR = 0;
const MAX_HOUR = 23;
const HOUR_CHARS = Object.freeze([]);
/**
* Represents the "hour" field within a cron expression.
* @class CronHour
* @extends CronField
*/
class CronHour extends CronField_1.CronField {
static get min() {
return MIN_HOUR;
}
static get max() {
return MAX_HOUR;
}
static get chars() {
return HOUR_CHARS;
}
/**
* CronHour constructor. Initializes the "hour" field with the provided values.
* @param {HourRange[]} values - Values for the "hour" field
* @param {CronFieldOptions} [options] - Options provided by the parser
*/
constructor(values, options) {
super(values, options);
this.validate();
}
/**
* Returns an array of allowed values for the "hour" field.
* @returns {HourRange[]}
*/
get values() {
return super.values;
}
}
exports.CronHour = CronHour;

40
node_modules/cron-parser/dist/fields/CronMinute.js generated vendored Normal file
View File

@@ -0,0 +1,40 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CronMinute = void 0;
const CronField_1 = require("./CronField");
const MIN_MINUTE = 0;
const MAX_MINUTE = 59;
const MINUTE_CHARS = Object.freeze([]);
/**
* Represents the "second" field within a cron expression.
* @class CronSecond
* @extends CronField
*/
class CronMinute extends CronField_1.CronField {
static get min() {
return MIN_MINUTE;
}
static get max() {
return MAX_MINUTE;
}
static get chars() {
return MINUTE_CHARS;
}
/**
* CronSecond constructor. Initializes the "second" field with the provided values.
* @param {SixtyRange[]} values - Values for the "second" field
* @param {CronFieldOptions} [options] - Options provided by the parser
*/
constructor(values, options) {
super(values, options);
this.validate();
}
/**
* Returns an array of allowed values for the "second" field.
* @returns {SixtyRange[]}
*/
get values() {
return super.values;
}
}
exports.CronMinute = CronMinute;

44
node_modules/cron-parser/dist/fields/CronMonth.js generated vendored Normal file
View File

@@ -0,0 +1,44 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CronMonth = void 0;
const CronDate_1 = require("../CronDate");
const CronField_1 = require("./CronField");
const MIN_MONTH = 1;
const MAX_MONTH = 12;
const MONTH_CHARS = Object.freeze([]);
/**
* Represents the "day of the month" field within a cron expression.
* @class CronDayOfMonth
* @extends CronField
*/
class CronMonth extends CronField_1.CronField {
static get min() {
return MIN_MONTH;
}
static get max() {
return MAX_MONTH;
}
static get chars() {
return MONTH_CHARS;
}
static get daysInMonth() {
return CronDate_1.DAYS_IN_MONTH;
}
/**
* CronDayOfMonth constructor. Initializes the "day of the month" field with the provided values.
* @param {MonthRange[]} values - Values for the "day of the month" field
* @param {CronFieldOptions} [options] - Options provided by the parser
*/
constructor(values, options) {
super(values, options);
this.validate();
}
/**
* Returns an array of allowed values for the "day of the month" field.
* @returns {MonthRange[]}
*/
get values() {
return super.values;
}
}
exports.CronMonth = CronMonth;

40
node_modules/cron-parser/dist/fields/CronSecond.js generated vendored Normal file
View File

@@ -0,0 +1,40 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.CronSecond = void 0;
const CronField_1 = require("./CronField");
const MIN_SECOND = 0;
const MAX_SECOND = 59;
const SECOND_CHARS = Object.freeze([]);
/**
* Represents the "second" field within a cron expression.
* @class CronSecond
* @extends CronField
*/
class CronSecond extends CronField_1.CronField {
static get min() {
return MIN_SECOND;
}
static get max() {
return MAX_SECOND;
}
static get chars() {
return SECOND_CHARS;
}
/**
* CronSecond constructor. Initializes the "second" field with the provided values.
* @param {SixtyRange[]} values - Values for the "second" field
* @param {CronFieldOptions} [options] - Options provided by the parser
*/
constructor(values, options) {
super(values, options);
this.validate();
}
/**
* Returns an array of allowed values for the "second" field.
* @returns {SixtyRange[]}
*/
get values() {
return super.values;
}
}
exports.CronSecond = CronSecond;

24
node_modules/cron-parser/dist/fields/index.js generated vendored Normal file
View File

@@ -0,0 +1,24 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
__exportStar(require("./types"), exports);
__exportStar(require("./CronDayOfMonth"), exports);
__exportStar(require("./CronDayOfWeek"), exports);
__exportStar(require("./CronField"), exports);
__exportStar(require("./CronHour"), exports);
__exportStar(require("./CronMinute"), exports);
__exportStar(require("./CronMonth"), exports);
__exportStar(require("./CronSecond"), exports);

2
node_modules/cron-parser/dist/fields/types.js generated vendored Normal file
View File

@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

31
node_modules/cron-parser/dist/index.js generated vendored Normal file
View File

@@ -0,0 +1,31 @@
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __exportStar = (this && this.__exportStar) || function(m, exports) {
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.CronFileParser = exports.CronExpressionParser = exports.CronExpression = exports.CronFieldCollection = exports.CronDate = void 0;
/* istanbul ignore file */
const CronExpressionParser_1 = require("./CronExpressionParser");
var CronDate_1 = require("./CronDate");
Object.defineProperty(exports, "CronDate", { enumerable: true, get: function () { return CronDate_1.CronDate; } });
var CronFieldCollection_1 = require("./CronFieldCollection");
Object.defineProperty(exports, "CronFieldCollection", { enumerable: true, get: function () { return CronFieldCollection_1.CronFieldCollection; } });
var CronExpression_1 = require("./CronExpression");
Object.defineProperty(exports, "CronExpression", { enumerable: true, get: function () { return CronExpression_1.CronExpression; } });
var CronExpressionParser_2 = require("./CronExpressionParser");
Object.defineProperty(exports, "CronExpressionParser", { enumerable: true, get: function () { return CronExpressionParser_2.CronExpressionParser; } });
var CronFileParser_1 = require("./CronFileParser");
Object.defineProperty(exports, "CronFileParser", { enumerable: true, get: function () { return CronFileParser_1.CronFileParser; } });
__exportStar(require("./fields"), exports);
exports.default = CronExpressionParser_1.CronExpressionParser;

273
node_modules/cron-parser/dist/types/CronDate.d.ts generated vendored Normal file
View File

@@ -0,0 +1,273 @@
export declare enum TimeUnit {
Second = "Second",
Minute = "Minute",
Hour = "Hour",
Day = "Day",
Month = "Month",
Year = "Year"
}
export declare enum DateMathOp {
Add = "Add",
Subtract = "Subtract"
}
export declare const DAYS_IN_MONTH: readonly number[];
/**
* CronDate class that wraps the Luxon DateTime object to provide
* a consistent API for working with dates and times in the context of cron.
*/
export declare class CronDate {
#private;
/**
* Constructs a new CronDate instance.
* @param {CronDate | Date | number | string} [timestamp] - The timestamp to initialize the CronDate with.
* @param {string} [tz] - The timezone to use for the CronDate.
*/
constructor(timestamp?: CronDate | Date | number | string, tz?: string);
/**
* Returns daylight savings start time.
* @returns {number | null}
*/
get dstStart(): number | null;
/**
* Sets daylight savings start time.
* @param {number | null} value
*/
set dstStart(value: number | null);
/**
* Returns daylight savings end time.
* @returns {number | null}
*/
get dstEnd(): number | null;
/**
* Sets daylight savings end time.
* @param {number | null} value
*/
set dstEnd(value: number | null);
/**
* Adds one year to the current CronDate.
*/
addYear(): void;
/**
* Adds one month to the current CronDate.
*/
addMonth(): void;
/**
* Adds one day to the current CronDate.
*/
addDay(): void;
/**
* Adds one hour to the current CronDate.
*/
addHour(): void;
/**
* Adds one minute to the current CronDate.
*/
addMinute(): void;
/**
* Adds one second to the current CronDate.
*/
addSecond(): void;
/**
* Subtracts one year from the current CronDate.
*/
subtractYear(): void;
/**
* Subtracts one month from the current CronDate.
* If the month is 1, it will subtract one year instead.
*/
subtractMonth(): void;
/**
* Subtracts one day from the current CronDate.
* If the day is 1, it will subtract one month instead.
*/
subtractDay(): void;
/**
* Subtracts one hour from the current CronDate.
* If the hour is 0, it will subtract one day instead.
*/
subtractHour(): void;
/**
* Subtracts one minute from the current CronDate.
* If the minute is 0, it will subtract one hour instead.
*/
subtractMinute(): void;
/**
* Subtracts one second from the current CronDate.
* If the second is 0, it will subtract one minute instead.
*/
subtractSecond(): void;
/**
* Adds a unit of time to the current CronDate.
* @param {TimeUnit} unit
*/
addUnit(unit: TimeUnit): void;
/**
* Subtracts a unit of time from the current CronDate.
* @param {TimeUnit} unit
*/
subtractUnit(unit: TimeUnit): void;
/**
* Handles a math operation.
* @param {DateMathOp} verb - {'add' | 'subtract'}
* @param {TimeUnit} unit - {'year' | 'month' | 'day' | 'hour' | 'minute' | 'second'}
*/
invokeDateOperation(verb: DateMathOp, unit: TimeUnit): void;
/**
* Returns the day.
* @returns {number}
*/
getDate(): number;
/**
* Returns the year.
* @returns {number}
*/
getFullYear(): number;
/**
* Returns the day of the week.
* @returns {number}
*/
getDay(): number;
/**
* Returns the month.
* @returns {number}
*/
getMonth(): number;
/**
* Returns the hour.
* @returns {number}
*/
getHours(): number;
/**
* Returns the minutes.
* @returns {number}
*/
getMinutes(): number;
/**
* Returns the seconds.
* @returns {number}
*/
getSeconds(): number;
/**
* Returns the milliseconds.
* @returns {number}
*/
getMilliseconds(): number;
/**
* Returns the time.
* @returns {number}
*/
getTime(): number;
/**
* Returns the UTC day.
* @returns {number}
*/
getUTCDate(): number;
/**
* Returns the UTC year.
* @returns {number}
*/
getUTCFullYear(): number;
/**
* Returns the UTC day of the week.
* @returns {number}
*/
getUTCDay(): number;
/**
* Returns the UTC month.
* @returns {number}
*/
getUTCMonth(): number;
/**
* Returns the UTC hour.
* @returns {number}
*/
getUTCHours(): number;
/**
* Returns the UTC minutes.
* @returns {number}
*/
getUTCMinutes(): number;
/**
* Returns the UTC seconds.
* @returns {number}
*/
getUTCSeconds(): number;
/**
* Returns the UTC milliseconds.
* @returns {string | null}
*/
toISOString(): string | null;
/**
* Returns the date as a JSON string.
* @returns {string | null}
*/
toJSON(): string | null;
/**
* Sets the day.
* @param d
*/
setDate(d: number): void;
/**
* Sets the year.
* @param y
*/
setFullYear(y: number): void;
/**
* Sets the day of the week.
* @param d
*/
setDay(d: number): void;
/**
* Sets the month.
* @param m
*/
setMonth(m: number): void;
/**
* Sets the hour.
* @param h
*/
setHours(h: number): void;
/**
* Sets the minutes.
* @param m
*/
setMinutes(m: number): void;
/**
* Sets the seconds.
* @param s
*/
setSeconds(s: number): void;
/**
* Sets the milliseconds.
* @param s
*/
setMilliseconds(s: number): void;
/**
* Returns the date as a string.
* @returns {string}
*/
toString(): string;
/**
* Returns the date as a Date object.
* @returns {Date}
*/
toDate(): Date;
/**
* Returns true if the day is the last day of the month.
* @returns {boolean}
*/
isLastDayOfMonth(): boolean;
/**
* Returns true if the day is the last weekday of the month.
* @returns {boolean}
*/
isLastWeekdayOfMonth(): boolean;
/**
* Primarily for internal use.
* @param {DateMathOp} op - The operation to perform.
* @param {TimeUnit} unit - The unit of time to use.
* @param {number} [hoursLength] - The length of the hours. Required when unit is not month or day.
*/
applyDateOperation(op: DateMathOp, unit: TimeUnit, hoursLength?: number): void;
}
export default CronDate;

118
node_modules/cron-parser/dist/types/CronExpression.d.ts generated vendored Normal file
View File

@@ -0,0 +1,118 @@
import { CronDate } from './CronDate';
import { CronFieldCollection } from './CronFieldCollection';
export type CronExpressionOptions = {
currentDate?: Date | string | number | CronDate;
endDate?: Date | string | number | CronDate;
startDate?: Date | string | number | CronDate;
tz?: string;
expression?: string;
hashSeed?: string;
strict?: boolean;
};
/**
* Error message for when the current date is outside the specified time span.
*/
export declare const TIME_SPAN_OUT_OF_BOUNDS_ERROR_MESSAGE = "Out of the time span range";
/**
* Error message for when the loop limit is exceeded during iteration.
*/
export declare const LOOPS_LIMIT_EXCEEDED_ERROR_MESSAGE = "Invalid expression, loop limit exceeded";
/**
* Class representing a Cron expression.
*/
export declare class CronExpression {
#private;
/**
* Creates a new CronExpression instance.
*
* @param {CronFieldCollection} fields - Cron fields.
* @param {CronExpressionOptions} options - Parser options.
*/
constructor(fields: CronFieldCollection, options: CronExpressionOptions);
/**
* Getter for the cron fields.
*
* @returns {CronFieldCollection} Cron fields.
*/
get fields(): CronFieldCollection;
/**
* Converts cron fields back to a CronExpression instance.
*
* @public
* @param {Record<string, number[]>} fields - The input cron fields object.
* @param {CronExpressionOptions} [options] - Optional parsing options.
* @returns {CronExpression} - A new CronExpression instance.
*/
static fieldsToExpression(fields: CronFieldCollection, options?: CronExpressionOptions): CronExpression;
/**
* Find the next scheduled date based on the cron expression.
* @returns {CronDate} - The next scheduled date or an ES6 compatible iterator object.
* @memberof CronExpression
* @public
*/
next(): CronDate;
/**
* Find the previous scheduled date based on the cron expression.
* @returns {CronDate} - The previous scheduled date or an ES6 compatible iterator object.
* @memberof CronExpression
* @public
*/
prev(): CronDate;
/**
* Check if there is a next scheduled date based on the current date and cron expression.
* @returns {boolean} - Returns true if there is a next scheduled date, false otherwise.
* @memberof CronExpression
* @public
*/
hasNext(): boolean;
/**
* Check if there is a previous scheduled date based on the current date and cron expression.
* @returns {boolean} - Returns true if there is a previous scheduled date, false otherwise.
* @memberof CronExpression
* @public
*/
hasPrev(): boolean;
/**
* Iterate over a specified number of steps and optionally execute a callback function for each step.
* @param {number} steps - The number of steps to iterate. Positive value iterates forward, negative value iterates backward.
* @returns {CronDate[]} - An array of iterator fields or CronDate objects.
* @memberof CronExpression
* @public
*/
take(limit: number): CronDate[];
/**
* Reset the iterators current date to a new date or the initial date.
* @param {Date | CronDate} [newDate] - Optional new date to reset to. If not provided, it will reset to the initial date.
* @memberof CronExpression
* @public
*/
reset(newDate?: Date | CronDate): void;
/**
* Generate a string representation of the cron expression.
* @param {boolean} [includeSeconds=false] - Whether to include the seconds field in the string representation.
* @returns {string} - The string representation of the cron expression.
* @memberof CronExpression
* @public
*/
stringify(includeSeconds?: boolean): string;
/**
* Check if the cron expression includes the given date
* @param {Date|CronDate} date
* @returns {boolean}
*/
includesDate(date: Date | CronDate): boolean;
/**
* Returns the string representation of the cron expression.
* @returns {CronDate} - The next schedule date.
*/
toString(): string;
/**
* Returns an iterator for iterating through future CronDate instances
*
* @name Symbol.iterator
* @memberof CronExpression
* @returns {Iterator<CronDate>} An iterator object for CronExpression that returns CronDate values.
*/
[Symbol.iterator](): Iterator<CronDate>;
}
export default CronExpression;

View File

@@ -0,0 +1,70 @@
import { CronExpression, CronExpressionOptions } from './CronExpression';
export declare enum PredefinedExpressions {
'@yearly' = "0 0 0 1 1 *",
'@annually' = "0 0 0 1 1 *",
'@monthly' = "0 0 0 1 * *",
'@weekly' = "0 0 0 * * 0",
'@daily' = "0 0 0 * * *",
'@hourly' = "0 0 * * * *",
'@minutely' = "0 * * * * *",
'@secondly' = "* * * * * *",
'@weekdays' = "0 0 0 * * 1-5",
'@weekends' = "0 0 0 * * 0,6"
}
export declare enum CronUnit {
Second = "Second",
Minute = "Minute",
Hour = "Hour",
DayOfMonth = "DayOfMonth",
Month = "Month",
DayOfWeek = "DayOfWeek"
}
export declare enum Months {
jan = 1,
feb = 2,
mar = 3,
apr = 4,
may = 5,
jun = 6,
jul = 7,
aug = 8,
sep = 9,
oct = 10,
nov = 11,
dec = 12
}
export declare enum DayOfWeek {
sun = 0,
mon = 1,
tue = 2,
wed = 3,
thu = 4,
fri = 5,
sat = 6
}
export type RawCronFields = {
second: string;
minute: string;
hour: string;
dayOfMonth: string;
month: string;
dayOfWeek: string;
};
/**
* Static class that parses a cron expression and returns a CronExpression object.
* @static
* @class CronExpressionParser
*/
export declare class CronExpressionParser {
#private;
/**
* Parses a cron expression and returns a CronExpression object.
* @param {string} expression - The cron expression to parse.
* @param {CronExpressionOptions} [options={}] - The options to use when parsing the expression.
* @param {boolean} [options.strict=false] - If true, will throw an error if the expression contains both dayOfMonth and dayOfWeek.
* @param {CronDate} [options.currentDate=new CronDate(undefined, 'UTC')] - The date to use when calculating the next/previous occurrence.
*
* @returns {CronExpression} A CronExpression object.
*/
static parse(expression: string, options?: CronExpressionOptions): CronExpression;
}

View File

@@ -0,0 +1,153 @@
import { CronSecond, CronMinute, CronHour, CronDayOfMonth, CronMonth, CronDayOfWeek, CronField, SerializedCronField, CronChars } from './fields';
import { SixtyRange, HourRange, DayOfMonthRange, MonthRange, DayOfWeekRange } from './fields/types';
export type FieldRange = {
start: number | CronChars;
count: number;
end?: number;
step?: number;
};
export type CronFields = {
second: CronSecond;
minute: CronMinute;
hour: CronHour;
dayOfMonth: CronDayOfMonth;
month: CronMonth;
dayOfWeek: CronDayOfWeek;
};
export type CronFieldOverride = {
second?: CronSecond | SixtyRange[];
minute?: CronMinute | SixtyRange[];
hour?: CronHour | HourRange[];
dayOfMonth?: CronDayOfMonth | DayOfMonthRange[];
month?: CronMonth | MonthRange[];
dayOfWeek?: CronDayOfWeek | DayOfWeekRange[];
};
export type SerializedCronFields = {
second: SerializedCronField;
minute: SerializedCronField;
hour: SerializedCronField;
dayOfMonth: SerializedCronField;
month: SerializedCronField;
dayOfWeek: SerializedCronField;
};
/**
* Represents a complete set of cron fields.
* @class CronFieldCollection
*/
export declare class CronFieldCollection {
#private;
/**
* Creates a new CronFieldCollection instance by partially overriding fields from an existing one.
* @param {CronFieldCollection} base - The base CronFieldCollection to copy fields from
* @param {CronFieldOverride} fields - The fields to override, can be CronField instances or raw values
* @returns {CronFieldCollection} A new CronFieldCollection instance
* @example
* const base = new CronFieldCollection({
* second: new CronSecond([0]),
* minute: new CronMinute([0]),
* hour: new CronHour([12]),
* dayOfMonth: new CronDayOfMonth([1]),
* month: new CronMonth([1]),
* dayOfWeek: new CronDayOfWeek([1])
* });
*
* // Using CronField instances
* const modified1 = CronFieldCollection.from(base, {
* hour: new CronHour([15]),
* minute: new CronMinute([30])
* });
*
* // Using raw values
* const modified2 = CronFieldCollection.from(base, {
* hour: [15], // Will create new CronHour
* minute: [30] // Will create new CronMinute
* });
*/
static from(base: CronFieldCollection, fields: CronFieldOverride): CronFieldCollection;
/**
* Resolves a field value, either using the provided CronField instance or creating a new one from raw values.
* @param constructor - The constructor for creating new field instances
* @param baseField - The base field to use if no override is provided
* @param fieldValue - The override value, either a CronField instance or raw values
* @returns The resolved CronField instance
* @private
*/
private static resolveField;
/**
* CronFieldCollection constructor. Initializes the cron fields with the provided values.
* @param {CronFields} param0 - The cron fields values
* @throws {Error} if validation fails
* @example
* const cronFields = new CronFieldCollection({
* second: new CronSecond([0]),
* minute: new CronMinute([0, 30]),
* hour: new CronHour([9]),
* dayOfMonth: new CronDayOfMonth([15]),
* month: new CronMonth([1]),
* dayOfWeek: new CronDayOfTheWeek([1, 2, 3, 4, 5]),
* })
*
* console.log(cronFields.second.values); // [0]
* console.log(cronFields.minute.values); // [0, 30]
* console.log(cronFields.hour.values); // [9]
* console.log(cronFields.dayOfMonth.values); // [15]
* console.log(cronFields.month.values); // [1]
* console.log(cronFields.dayOfWeek.values); // [1, 2, 3, 4, 5]
*/
constructor({ second, minute, hour, dayOfMonth, month, dayOfWeek }: CronFields);
/**
* Returns the second field.
* @returns {CronSecond}
*/
get second(): CronSecond;
/**
* Returns the minute field.
* @returns {CronMinute}
*/
get minute(): CronMinute;
/**
* Returns the hour field.
* @returns {CronHour}
*/
get hour(): CronHour;
/**
* Returns the day of the month field.
* @returns {CronDayOfMonth}
*/
get dayOfMonth(): CronDayOfMonth;
/**
* Returns the month field.
* @returns {CronMonth}
*/
get month(): CronMonth;
/**
* Returns the day of the week field.
* @returns {CronDayOfWeek}
*/
get dayOfWeek(): CronDayOfWeek;
/**
* Returns a string representation of the cron fields.
* @param {(number | CronChars)[]} input - The cron fields values
* @static
* @returns {FieldRange[]} - The compacted cron fields
*/
static compactField(input: (number | CronChars)[]): FieldRange[];
/**
* Returns a string representation of the cron fields.
* @param {CronField} field - The cron field to stringify
* @static
* @returns {string} - The stringified cron field
*/
stringifyField(field: CronField): string;
/**
* Returns a string representation of the cron field values.
* @param {boolean} includeSeconds - Whether to include seconds in the output
* @returns {string} The formatted cron string
*/
stringify(includeSeconds?: boolean): string;
/**
* Returns a serialized representation of the cron fields values.
* @returns {SerializedCronFields} An object containing the cron field values
*/
serialize(): SerializedCronFields;
}

View File

@@ -0,0 +1,30 @@
import { CronExpression } from './CronExpression';
export type CronFileParserResult = {
variables: {
[key: string]: string;
};
expressions: CronExpression[];
errors: {
[key: string]: unknown;
};
};
/**
* Parser for crontab files that handles both synchronous and asynchronous operations.
*/
export declare class CronFileParser {
#private;
/**
* Parse a crontab file asynchronously
* @param filePath Path to crontab file
* @returns Promise resolving to parse results
* @throws If file cannot be read
*/
static parseFile(filePath: string): Promise<CronFileParserResult>;
/**
* Parse a crontab file synchronously
* @param filePath Path to crontab file
* @returns Parse results
* @throws If file cannot be read
*/
static parseFileSync(filePath: string): CronFileParserResult;
}

View File

@@ -0,0 +1,25 @@
import { CronField, CronFieldOptions } from './CronField';
import { CronChars, CronMax, CronMin, DayOfMonthRange } from './types';
/**
* Represents the "day of the month" field within a cron expression.
* @class CronDayOfMonth
* @extends CronField
*/
export declare class CronDayOfMonth extends CronField {
static get min(): CronMin;
static get max(): CronMax;
static get chars(): CronChars[];
static get validChars(): RegExp;
/**
* CronDayOfMonth constructor. Initializes the "day of the month" field with the provided values.
* @param {DayOfMonthRange[]} values - Values for the "day of the month" field
* @param {CronFieldOptions} [options] - Options provided by the parser
* @throws {Error} if validation fails
*/
constructor(values: DayOfMonthRange[], options?: CronFieldOptions);
/**
* Returns an array of allowed values for the "day of the month" field.
* @returns {DayOfMonthRange[]}
*/
get values(): DayOfMonthRange[];
}

View File

@@ -0,0 +1,30 @@
import { CronField, CronFieldOptions } from './CronField';
import { CronChars, CronMax, CronMin, DayOfWeekRange } from './types';
/**
* Represents the "day of the week" field within a cron expression.
* @class CronDayOfTheWeek
* @extends CronField
*/
export declare class CronDayOfWeek extends CronField {
static get min(): CronMin;
static get max(): CronMax;
static get chars(): readonly CronChars[];
static get validChars(): RegExp;
/**
* CronDayOfTheWeek constructor. Initializes the "day of the week" field with the provided values.
* @param {DayOfWeekRange[]} values - Values for the "day of the week" field
* @param {CronFieldOptions} [options] - Options provided by the parser
*/
constructor(values: DayOfWeekRange[], options?: CronFieldOptions);
/**
* Returns an array of allowed values for the "day of the week" field.
* @returns {DayOfWeekRange[]}
*/
get values(): DayOfWeekRange[];
/**
* Returns the nth day of the week if specified in the cron expression.
* This is used for the '#' character in the cron expression.
* @returns {number} The nth day of the week (1-5) or 0 if not specified.
*/
get nthDay(): number;
}

View File

@@ -0,0 +1,114 @@
import { CronChars, CronConstraints, CronFieldType, CronMax, CronMin } from './types';
/**
* Represents the serialized form of a cron field.
* @typedef {Object} SerializedCronField
* @property {boolean} wildcard - Indicates if the field is a wildcard.
* @property {(number|string)[]} values - The values of the field.
*/
export type SerializedCronField = {
wildcard: boolean;
values: (number | string)[];
};
/**
* Represents the options for a cron field.
* @typedef {Object} CronFieldOptions
* @property {string} rawValue - The raw value of the field.
* @property {boolean} [wildcard] - Indicates if the field is a wildcard.
* @property {number} [nthDayOfWeek] - The nth day of the week.
*/
export type CronFieldOptions = {
rawValue?: string;
wildcard?: boolean;
nthDayOfWeek?: number;
};
/**
* Represents a field within a cron expression.
* This is a base class and should not be instantiated directly.
* @class CronField
*/
export declare abstract class CronField {
#private;
protected readonly options: CronFieldOptions & {
rawValue: string;
};
/**
* Returns the minimum value allowed for this field.
*/
static get min(): CronMin;
/**
* Returns the maximum value allowed for this field.
*/
static get max(): CronMax;
/**
* Returns the allowed characters for this field.
*/
static get chars(): readonly CronChars[];
/**
* Returns the regular expression used to validate this field.
*/
static get validChars(): RegExp;
/**
* Returns the constraints for this field.
*/
static get constraints(): CronConstraints;
/**
* CronField constructor. Initializes the field with the provided values.
* @param {number[] | string[]} values - Values for this field
* @param {CronFieldOptions} [options] - Options provided by the parser
* @throws {TypeError} if the constructor is called directly
* @throws {Error} if validation fails
*/
protected constructor(values: (number | string)[], options?: CronFieldOptions);
/**
* Returns the minimum value allowed for this field.
* @returns {number}
*/
get min(): number;
/**
* Returns the maximum value allowed for this field.
* @returns {number}
*/
get max(): number;
/**
* Returns an array of allowed special characters for this field.
* @returns {string[]}
*/
get chars(): readonly string[];
/**
* Indicates whether this field has a "last" character.
* @returns {boolean}
*/
get hasLastChar(): boolean;
/**
* Indicates whether this field has a "question mark" character.
* @returns {boolean}
*/
get hasQuestionMarkChar(): boolean;
/**
* Indicates whether this field is a wildcard.
* @returns {boolean}
*/
get isWildcard(): boolean;
/**
* Returns an array of allowed values for this field.
* @returns {CronFieldType}
*/
get values(): CronFieldType;
/**
* Helper function to sort values in ascending order.
* @param {number | string} a - First value to compare
* @param {number | string} b - Second value to compare
* @returns {number} - A negative, zero, or positive value, depending on the sort order
*/
static sorter(a: number | string, b: number | string): number;
/**
* Serializes the field to an object.
* @returns {SerializedCronField}
*/
serialize(): SerializedCronField;
/**
* Validates the field values against the allowed range and special characters.
* @throws {Error} if validation fails
*/
validate(): void;
}

View File

@@ -0,0 +1,23 @@
import { CronField, CronFieldOptions } from './CronField';
import { CronChars, CronMax, CronMin, HourRange } from './types';
/**
* Represents the "hour" field within a cron expression.
* @class CronHour
* @extends CronField
*/
export declare class CronHour extends CronField {
static get min(): CronMin;
static get max(): CronMax;
static get chars(): readonly CronChars[];
/**
* CronHour constructor. Initializes the "hour" field with the provided values.
* @param {HourRange[]} values - Values for the "hour" field
* @param {CronFieldOptions} [options] - Options provided by the parser
*/
constructor(values: HourRange[], options?: CronFieldOptions);
/**
* Returns an array of allowed values for the "hour" field.
* @returns {HourRange[]}
*/
get values(): HourRange[];
}

View File

@@ -0,0 +1,23 @@
import { CronField, CronFieldOptions } from './CronField';
import { CronChars, CronMax, CronMin, SixtyRange } from './types';
/**
* Represents the "second" field within a cron expression.
* @class CronSecond
* @extends CronField
*/
export declare class CronMinute extends CronField {
static get min(): CronMin;
static get max(): CronMax;
static get chars(): readonly CronChars[];
/**
* CronSecond constructor. Initializes the "second" field with the provided values.
* @param {SixtyRange[]} values - Values for the "second" field
* @param {CronFieldOptions} [options] - Options provided by the parser
*/
constructor(values: SixtyRange[], options?: CronFieldOptions);
/**
* Returns an array of allowed values for the "second" field.
* @returns {SixtyRange[]}
*/
get values(): SixtyRange[];
}

View File

@@ -0,0 +1,24 @@
import { CronField, CronFieldOptions } from './CronField';
import { CronChars, CronMax, CronMin, MonthRange } from './types';
/**
* Represents the "day of the month" field within a cron expression.
* @class CronDayOfMonth
* @extends CronField
*/
export declare class CronMonth extends CronField {
static get min(): CronMin;
static get max(): CronMax;
static get chars(): readonly CronChars[];
static get daysInMonth(): readonly number[];
/**
* CronDayOfMonth constructor. Initializes the "day of the month" field with the provided values.
* @param {MonthRange[]} values - Values for the "day of the month" field
* @param {CronFieldOptions} [options] - Options provided by the parser
*/
constructor(values: MonthRange[], options?: CronFieldOptions);
/**
* Returns an array of allowed values for the "day of the month" field.
* @returns {MonthRange[]}
*/
get values(): MonthRange[];
}

View File

@@ -0,0 +1,23 @@
import { CronChars, CronMax, CronMin, SixtyRange } from './types';
import { CronField, CronFieldOptions } from './CronField';
/**
* Represents the "second" field within a cron expression.
* @class CronSecond
* @extends CronField
*/
export declare class CronSecond extends CronField {
static get min(): CronMin;
static get max(): CronMax;
static get chars(): readonly CronChars[];
/**
* CronSecond constructor. Initializes the "second" field with the provided values.
* @param {SixtyRange[]} values - Values for the "second" field
* @param {CronFieldOptions} [options] - Options provided by the parser
*/
constructor(values: SixtyRange[], options?: CronFieldOptions);
/**
* Returns an array of allowed values for the "second" field.
* @returns {SixtyRange[]}
*/
get values(): SixtyRange[];
}

View File

@@ -0,0 +1,8 @@
export * from './types';
export * from './CronDayOfMonth';
export * from './CronDayOfWeek';
export * from './CronField';
export * from './CronHour';
export * from './CronMinute';
export * from './CronMonth';
export * from './CronSecond';

18
node_modules/cron-parser/dist/types/fields/types.d.ts generated vendored Normal file
View File

@@ -0,0 +1,18 @@
export type RangeFrom<LENGTH extends number, ACC extends unknown[] = []> = ACC['length'] extends LENGTH ? ACC : RangeFrom<LENGTH, [...ACC, 1]>;
export type IntRange<FROM extends number[], TO extends number, ACC extends number = never> = FROM['length'] extends TO ? ACC | TO : IntRange<[...FROM, 1], TO, ACC | FROM['length']>;
export type SixtyRange = IntRange<RangeFrom<0>, 59>;
export type HourRange = IntRange<RangeFrom<0>, 23>;
export type DayOfMonthRange = IntRange<RangeFrom<1>, 31> | 'L';
export type MonthRange = IntRange<RangeFrom<1>, 12>;
export type DayOfWeekRange = IntRange<RangeFrom<0>, 7> | 'L';
export type CronFieldType = SixtyRange[] | HourRange[] | DayOfMonthRange[] | MonthRange[] | DayOfWeekRange[];
export type CronChars = 'L' | 'W';
export type CronMin = 0 | 1;
export type CronMax = 7 | 12 | 23 | 31 | 59;
export type ParseRangeResponse = number[] | string[] | number | string;
export type CronConstraints = {
min: CronMin;
max: CronMax;
chars: readonly CronChars[];
validChars: RegExp;
};

8
node_modules/cron-parser/dist/types/index.d.ts generated vendored Normal file
View File

@@ -0,0 +1,8 @@
import { CronExpressionParser } from './CronExpressionParser';
export { CronDate } from './CronDate';
export { CronFieldCollection } from './CronFieldCollection';
export { CronExpression, CronExpressionOptions } from './CronExpression';
export { CronExpressionParser } from './CronExpressionParser';
export { CronFileParser, CronFileParserResult } from './CronFileParser';
export * from './fields';
export default CronExpressionParser;

10
node_modules/cron-parser/dist/types/utils/random.d.ts generated vendored Normal file
View File

@@ -0,0 +1,10 @@
/**
* A type representing a Pseudorandom Number Generator, similar to Math.random()
*/
export type PRNG = () => number;
/**
* Generates a PRNG using a given seed. When not provided, the seed is randomly generated
* @param {string} str A string to derive the seed from
* @returns {PRNG} A random number generator correctly seeded
*/
export declare function seededRandom(str?: string): PRNG;

38
node_modules/cron-parser/dist/utils/random.js generated vendored Normal file
View File

@@ -0,0 +1,38 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.seededRandom = seededRandom;
/**
* Computes a numeric hash from a given string
* @param {string} str A value to hash
* @returns {number} A numeric hash computed from the given value
*/
function xfnv1a(str) {
let h = 2166136261 >>> 0;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = Math.imul(h, 16777619);
}
return () => h >>> 0;
}
/**
* Initialize a new PRNG using a given seed
* @param {number} seed The seed used to initialize the PRNG
* @returns {PRNG} A random number generator
*/
function mulberry32(seed) {
return () => {
let t = (seed += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/**
* Generates a PRNG using a given seed. When not provided, the seed is randomly generated
* @param {string} str A string to derive the seed from
* @returns {PRNG} A random number generator correctly seeded
*/
function seededRandom(str) {
const seed = str ? xfnv1a(str)() : Math.floor(Math.random() * 10_000_000_000);
return mulberry32(seed);
}

117
node_modules/cron-parser/package.json generated vendored Normal file
View File

@@ -0,0 +1,117 @@
{
"name": "cron-parser",
"version": "5.4.0",
"description": "Node.js library for parsing crontab instructions",
"main": "dist/index.js",
"types": "dist/types/index.d.ts",
"type": "commonjs",
"scripts": {
"clean": "rimraf dist",
"bench": "cross-env node -r ts-node/register benchmarks/index.ts",
"bench:pattern": "cross-env node -r ts-node/register benchmarks/pattern.ts",
"bench:clean": "rimraf benchmarks/versions && rimraf benchmarks/results",
"build": "npm run clean && tsc -p tsconfig.json",
"prepublishOnly": "npm run build",
"prepare": "husky && npm run build",
"precommit": "lint-staged",
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"lint:debug": "cross-env DEBUG=eslint:cli-engine eslint .",
"format": "prettier --write \"**/*.{ts,js,json,md}\"",
"format:check": "prettier --check \"**/*.{ts,js,json,md}\"",
"test:unit": "cross-env TZ=UTC jest",
"test:coverage": "cross-env TZ=UTC jest --coverage",
"generate-badges": "jest-coverage-badges",
"test:types": "npm run build && tsd",
"test": "cross-env TZ=UTC npm run lint && npm run test:types && npm run test:coverage && npm run generate-badges",
"docs": "rimraf docs && typedoc --out docs --readme none --name 'CronParser' src"
},
"files": [
"dist",
"LICENSE",
"README.md"
],
"dependencies": {
"luxon": "^3.7.1"
},
"devDependencies": {
"@tsd/typescript": "^5.8.2",
"@types/jest": "^29.5.14",
"@types/luxon": "^3.6.2",
"@types/node": "^22.14.0",
"@typescript-eslint/eslint-plugin": "^8.29.0",
"@typescript-eslint/parser": "^8.29.0",
"chalk": "^5.4.1",
"cli-table3": "^0.6.5",
"cross-env": "^7.0.3",
"eslint": "^9.23.0",
"eslint-config-prettier": "^10.1.1",
"eslint-plugin-prettier": "^5.2.6",
"husky": "^9.1.7",
"jest": "^29.7.0",
"jest-coverage-badges": "^1.0.0",
"lint-staged": "^15.5.0",
"prettier": "^3.5.3",
"rimraf": "^6.0.1",
"sinon": "^20.0.0",
"ts-jest": "^29.3.1",
"ts-node": "^10.9.2",
"tsd": "^0.31.2",
"typedoc": "^0.28.1",
"typescript": "^5.8.2"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"*.{ts,js,json}": [
"prettier --write"
]
},
"engines": {
"node": ">=18"
},
"browser": {
"fs": false,
"fs/promises": false
},
"tsd": {
"directory": "tests"
},
"repository": {
"type": "git",
"url": "https://github.com/harrisiirak/cron-parser.git"
},
"keywords": [
"cron",
"crontab",
"parser"
],
"author": "Harri Siirak",
"contributors": [
"Nicholas Clawson",
"Daniel Prentis <daniel@salsitasoft.com>",
"Renault John Lecoultre",
"Richard Astbury <richard.astbury@gmail.com>",
"Meaglin Wasabi <Meaglin.wasabi@gmail.com>",
"Mike Kusold <hello@mikekusold.com>",
"Alex Kit <alex.kit@atmajs.com>",
"Santiago Gimeno <santiago.gimeno@gmail.com>",
"Daniel <darc.tec@gmail.com>",
"Christian Steininger <christian.steininger.cs@gmail.com>",
"Mykola Piskovyi <m.piskovyi@gmail.com>",
"Brian Vaughn <brian.david.vaughn@gmail.com>",
"Nicholas Clawson <nickclaw@gmail.com>",
"Yasuhiroki <yasuhiroki.duck@gmail.com>",
"Nicholas Clawson <nickclaw@gmail.com>",
"Brendan Warkentin <faazshift@gmail.com>",
"Charlie Fish <fishcharlie.code@gmail.com>",
"Ian Graves <ian+diskimage@iangrav.es>",
"Andy Thompson <me@andytson.com>",
"Regev Brody <regevbr@gmail.com>",
"Michael Hobbs <michael.lee.hobbs@gmail.com>"
],
"license": "MIT"
}

7
node_modules/luxon/LICENSE.md generated vendored Normal file
View File

@@ -0,0 +1,7 @@
Copyright 2019 JS Foundation and other contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

55
node_modules/luxon/README.md generated vendored Normal file
View File

@@ -0,0 +1,55 @@
# Luxon
[![MIT License][license-image]][license] [![Build Status][github-action-image]][github-action-url] [![NPM version][npm-version-image]][npm-url] [![Coverage Status][test-coverage-image]][test-coverage-url] [![PRs welcome][contributing-image]][contributing-url]
Luxon is a library for working with dates and times in JavaScript.
```js
DateTime.now().setZone("America/New_York").minus({ weeks: 1 }).endOf("day").toISO();
```
## Upgrading to 3.0
[Guide](https://moment.github.io/luxon/#upgrading)
## Features
* DateTime, Duration, and Interval types.
* Immutable, chainable, unambiguous API.
* Parsing and formatting for common and custom formats.
* Native time zone and Intl support (no locale or tz files).
## Download/install
[Download/install instructions](https://moment.github.io/luxon/#/install)
## Documentation
* [General documentation](https://moment.github.io/luxon/#/?id=luxon)
* [API docs](https://moment.github.io/luxon/api-docs/index.html)
* [Quick tour](https://moment.github.io/luxon/#/tour)
* [For Moment users](https://moment.github.io/luxon/#/moment)
* [Why does Luxon exist?](https://moment.github.io/luxon/#/why)
* [A quick demo](https://moment.github.io/luxon/demo/global.html)
## Development
See [contributing](CONTRIBUTING.md).
![Phasers to stun][phasers-image]
[license-image]: https://img.shields.io/badge/license-MIT-blue.svg
[license]: LICENSE.md
[github-action-image]: https://github.com/moment/luxon/actions/workflows/test.yml/badge.svg
[github-action-url]: https://github.com/moment/luxon/actions/workflows/test.yml
[npm-url]: https://npmjs.org/package/luxon
[npm-version-image]: https://badge.fury.io/js/luxon.svg
[test-coverage-url]: https://codecov.io/gh/moment/luxon
[test-coverage-image]: https://codecov.io/gh/moment/luxon/branch/master/graph/badge.svg
[contributing-url]: https://github.com/moment/luxon/blob/master/CONTRIBUTING.md
[contributing-image]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg
[phasers-image]: https://img.shields.io/badge/phasers-stun-brightgreen.svg

7792
node_modules/luxon/build/node/luxon.js generated vendored Normal file

File diff suppressed because it is too large Load Diff

1
node_modules/luxon/build/node/luxon.js.map generated vendored Normal file

File diff suppressed because one or more lines are too long

87
node_modules/luxon/package.json generated vendored Normal file
View File

@@ -0,0 +1,87 @@
{
"name": "luxon",
"version": "3.7.2",
"description": "Immutable date wrapper",
"author": "Isaac Cambron",
"keywords": [
"date",
"immutable"
],
"repository": "https://github.com/moment/luxon",
"exports": {
".": {
"import": "./build/es6/luxon.mjs",
"require": "./build/node/luxon.js"
},
"./package.json": "./package.json"
},
"scripts": {
"build": "babel-node tasks/buildAll.js",
"build-node": "babel-node tasks/buildNode.js",
"build-global": "babel-node tasks/buildGlobal.js",
"jest": "jest",
"test": "jest --coverage",
"api-docs": "mkdir -p build && documentation build src/luxon.js -f html -o build/api-docs && sed -i.bak 's/<\\/body>/<script src=\"\\..\\/global\\/luxon.js\"><\\/script><script>console.log(\"You can try Luxon right here using the `luxon` global, like `luxon.DateTime.now()`\");<\\/script><\\/body>/g' build/api-docs/index.html && rm build/api-docs/index.html.bak",
"copy-site": "mkdir -p build && rsync -a docs/ build/docs && rsync -a site/ build",
"site": "npm run api-docs && npm run copy-site",
"format": "prettier --write 'src/**/*.js' 'test/**/*.js' 'benchmarks/*.js'",
"format-check": "prettier --check 'src/**/*.js' 'test/**/*.js' 'benchmarks/*.js'",
"benchmark": "node benchmarks/index.js",
"codecov": "codecov",
"prepack": "babel-node tasks/buildAll.js",
"prepare": "husky install",
"show-site": "http-server build"
},
"lint-staged": {
"*.{js,json}": [
"prettier --write"
]
},
"devDependencies": {
"@babel/core": "^7.18.6",
"@babel/node": "^7.18.6",
"@babel/plugin-external-helpers": "^7.18.6",
"@babel/preset-env": "^7.18.6",
"@rollup/plugin-babel": "^5.3.0",
"@rollup/plugin-commonjs": "^19.0.0",
"@rollup/plugin-node-resolve": "^13.0.0",
"babel-jest": "^28.1.2",
"benchmark": "latest",
"codecov": "latest",
"documentation": "latest",
"fs-extra": "^6.0.1",
"http-server": "^14.1.1",
"husky": "^7.0.0",
"jest": "^29.4.3",
"lint-staged": "^13.2.1",
"prettier": "latest",
"rollup": "^2.52.7",
"rollup-plugin-terser": "^7.0.2",
"uglify-js": "^3.13.10"
},
"main": "build/node/luxon.js",
"module": "src/luxon.js",
"browser": "build/cjs-browser/luxon.js",
"jsdelivr": "build/global/luxon.min.js",
"unpkg": "build/global/luxon.min.js",
"engines": {
"node": ">=12"
},
"files": [
"build/node/luxon.js",
"build/node/luxon.js.map",
"build/cjs-browser/luxon.js",
"build/cjs-browser/luxon.js.map",
"build/amd/luxon.js",
"build/amd/luxon.js.map",
"build/global/luxon.js",
"build/global/luxon.js.map",
"build/global/luxon.min.js",
"build/global/luxon.min.js.map",
"build/es6/luxon.mjs",
"build/es6/luxon.mjs.map",
"src"
],
"license": "MIT",
"sideEffects": false
}

2603
node_modules/luxon/src/datetime.js generated vendored Normal file

File diff suppressed because it is too large Load Diff

1009
node_modules/luxon/src/duration.js generated vendored Normal file

File diff suppressed because it is too large Load Diff

61
node_modules/luxon/src/errors.js generated vendored Normal file
View File

@@ -0,0 +1,61 @@
// these aren't really private, but nor are they really useful to document
/**
* @private
*/
class LuxonError extends Error {}
/**
* @private
*/
export class InvalidDateTimeError extends LuxonError {
constructor(reason) {
super(`Invalid DateTime: ${reason.toMessage()}`);
}
}
/**
* @private
*/
export class InvalidIntervalError extends LuxonError {
constructor(reason) {
super(`Invalid Interval: ${reason.toMessage()}`);
}
}
/**
* @private
*/
export class InvalidDurationError extends LuxonError {
constructor(reason) {
super(`Invalid Duration: ${reason.toMessage()}`);
}
}
/**
* @private
*/
export class ConflictingSpecificationError extends LuxonError {}
/**
* @private
*/
export class InvalidUnitError extends LuxonError {
constructor(unit) {
super(`Invalid unit ${unit}`);
}
}
/**
* @private
*/
export class InvalidArgumentError extends LuxonError {}
/**
* @private
*/
export class ZoneIsAbstractError extends LuxonError {
constructor() {
super("Zone is an abstract class");
}
}

205
node_modules/luxon/src/info.js generated vendored Normal file
View File

@@ -0,0 +1,205 @@
import DateTime from "./datetime.js";
import Settings from "./settings.js";
import Locale from "./impl/locale.js";
import IANAZone from "./zones/IANAZone.js";
import { normalizeZone } from "./impl/zoneUtil.js";
import { hasLocaleWeekInfo, hasRelative } from "./impl/util.js";
/**
* The Info class contains static methods for retrieving general time and date related data. For example, it has methods for finding out if a time zone has a DST, for listing the months in any supported locale, and for discovering which of Luxon features are available in the current environment.
*/
export default class Info {
/**
* Return whether the specified zone contains a DST.
* @param {string|Zone} [zone='local'] - Zone to check. Defaults to the environment's local zone.
* @return {boolean}
*/
static hasDST(zone = Settings.defaultZone) {
const proto = DateTime.now().setZone(zone).set({ month: 12 });
return !zone.isUniversal && proto.offset !== proto.set({ month: 6 }).offset;
}
/**
* Return whether the specified zone is a valid IANA specifier.
* @param {string} zone - Zone to check
* @return {boolean}
*/
static isValidIANAZone(zone) {
return IANAZone.isValidZone(zone);
}
/**
* Converts the input into a {@link Zone} instance.
*
* * If `input` is already a Zone instance, it is returned unchanged.
* * If `input` is a string containing a valid time zone name, a Zone instance
* with that name is returned.
* * If `input` is a string that doesn't refer to a known time zone, a Zone
* instance with {@link Zone#isValid} == false is returned.
* * If `input is a number, a Zone instance with the specified fixed offset
* in minutes is returned.
* * If `input` is `null` or `undefined`, the default zone is returned.
* @param {string|Zone|number} [input] - the value to be converted
* @return {Zone}
*/
static normalizeZone(input) {
return normalizeZone(input, Settings.defaultZone);
}
/**
* Get the weekday on which the week starts according to the given locale.
* @param {Object} opts - options
* @param {string} [opts.locale] - the locale code
* @param {string} [opts.locObj=null] - an existing locale object to use
* @returns {number} the start of the week, 1 for Monday through 7 for Sunday
*/
static getStartOfWeek({ locale = null, locObj = null } = {}) {
return (locObj || Locale.create(locale)).getStartOfWeek();
}
/**
* Get the minimum number of days necessary in a week before it is considered part of the next year according
* to the given locale.
* @param {Object} opts - options
* @param {string} [opts.locale] - the locale code
* @param {string} [opts.locObj=null] - an existing locale object to use
* @returns {number}
*/
static getMinimumDaysInFirstWeek({ locale = null, locObj = null } = {}) {
return (locObj || Locale.create(locale)).getMinDaysInFirstWeek();
}
/**
* Get the weekdays, which are considered the weekend according to the given locale
* @param {Object} opts - options
* @param {string} [opts.locale] - the locale code
* @param {string} [opts.locObj=null] - an existing locale object to use
* @returns {number[]} an array of weekdays, 1 for Monday through 7 for Sunday
*/
static getWeekendWeekdays({ locale = null, locObj = null } = {}) {
// copy the array, because we cache it internally
return (locObj || Locale.create(locale)).getWeekendDays().slice();
}
/**
* Return an array of standalone month names.
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat
* @param {string} [length='long'] - the length of the month representation, such as "numeric", "2-digit", "narrow", "short", "long"
* @param {Object} opts - options
* @param {string} [opts.locale] - the locale code
* @param {string} [opts.numberingSystem=null] - the numbering system
* @param {string} [opts.locObj=null] - an existing locale object to use
* @param {string} [opts.outputCalendar='gregory'] - the calendar
* @example Info.months()[0] //=> 'January'
* @example Info.months('short')[0] //=> 'Jan'
* @example Info.months('numeric')[0] //=> '1'
* @example Info.months('short', { locale: 'fr-CA' } )[0] //=> 'janv.'
* @example Info.months('numeric', { locale: 'ar' })[0] //=> '١'
* @example Info.months('long', { outputCalendar: 'islamic' })[0] //=> 'Rabiʻ I'
* @return {Array}
*/
static months(
length = "long",
{ locale = null, numberingSystem = null, locObj = null, outputCalendar = "gregory" } = {}
) {
return (locObj || Locale.create(locale, numberingSystem, outputCalendar)).months(length);
}
/**
* Return an array of format month names.
* Format months differ from standalone months in that they're meant to appear next to the day of the month. In some languages, that
* changes the string.
* See {@link Info#months}
* @param {string} [length='long'] - the length of the month representation, such as "numeric", "2-digit", "narrow", "short", "long"
* @param {Object} opts - options
* @param {string} [opts.locale] - the locale code
* @param {string} [opts.numberingSystem=null] - the numbering system
* @param {string} [opts.locObj=null] - an existing locale object to use
* @param {string} [opts.outputCalendar='gregory'] - the calendar
* @return {Array}
*/
static monthsFormat(
length = "long",
{ locale = null, numberingSystem = null, locObj = null, outputCalendar = "gregory" } = {}
) {
return (locObj || Locale.create(locale, numberingSystem, outputCalendar)).months(length, true);
}
/**
* Return an array of standalone week names.
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat
* @param {string} [length='long'] - the length of the weekday representation, such as "narrow", "short", "long".
* @param {Object} opts - options
* @param {string} [opts.locale] - the locale code
* @param {string} [opts.numberingSystem=null] - the numbering system
* @param {string} [opts.locObj=null] - an existing locale object to use
* @example Info.weekdays()[0] //=> 'Monday'
* @example Info.weekdays('short')[0] //=> 'Mon'
* @example Info.weekdays('short', { locale: 'fr-CA' })[0] //=> 'lun.'
* @example Info.weekdays('short', { locale: 'ar' })[0] //=> 'الاثنين'
* @return {Array}
*/
static weekdays(length = "long", { locale = null, numberingSystem = null, locObj = null } = {}) {
return (locObj || Locale.create(locale, numberingSystem, null)).weekdays(length);
}
/**
* Return an array of format week names.
* Format weekdays differ from standalone weekdays in that they're meant to appear next to more date information. In some languages, that
* changes the string.
* See {@link Info#weekdays}
* @param {string} [length='long'] - the length of the month representation, such as "narrow", "short", "long".
* @param {Object} opts - options
* @param {string} [opts.locale=null] - the locale code
* @param {string} [opts.numberingSystem=null] - the numbering system
* @param {string} [opts.locObj=null] - an existing locale object to use
* @return {Array}
*/
static weekdaysFormat(
length = "long",
{ locale = null, numberingSystem = null, locObj = null } = {}
) {
return (locObj || Locale.create(locale, numberingSystem, null)).weekdays(length, true);
}
/**
* Return an array of meridiems.
* @param {Object} opts - options
* @param {string} [opts.locale] - the locale code
* @example Info.meridiems() //=> [ 'AM', 'PM' ]
* @example Info.meridiems({ locale: 'my' }) //=> [ 'နံနက်', 'ညနေ' ]
* @return {Array}
*/
static meridiems({ locale = null } = {}) {
return Locale.create(locale).meridiems();
}
/**
* Return an array of eras, such as ['BC', 'AD']. The locale can be specified, but the calendar system is always Gregorian.
* @param {string} [length='short'] - the length of the era representation, such as "short" or "long".
* @param {Object} opts - options
* @param {string} [opts.locale] - the locale code
* @example Info.eras() //=> [ 'BC', 'AD' ]
* @example Info.eras('long') //=> [ 'Before Christ', 'Anno Domini' ]
* @example Info.eras('long', { locale: 'fr' }) //=> [ 'avant Jésus-Christ', 'après Jésus-Christ' ]
* @return {Array}
*/
static eras(length = "short", { locale = null } = {}) {
return Locale.create(locale, null, "gregory").eras(length);
}
/**
* Return the set of available features in this environment.
* Some features of Luxon are not available in all environments. For example, on older browsers, relative time formatting support is not available. Use this function to figure out if that's the case.
* Keys:
* * `relative`: whether this environment supports relative time formatting
* * `localeWeek`: whether this environment supports different weekdays for the start of the week based on the locale
* @example Info.features() //=> { relative: false, localeWeek: true }
* @return {Object}
*/
static features() {
return { relative: hasRelative(), localeWeek: hasLocaleWeekInfo() };
}
}

669
node_modules/luxon/src/interval.js generated vendored Normal file
View File

@@ -0,0 +1,669 @@
import DateTime, { friendlyDateTime } from "./datetime.js";
import Duration from "./duration.js";
import Settings from "./settings.js";
import { InvalidArgumentError, InvalidIntervalError } from "./errors.js";
import Invalid from "./impl/invalid.js";
import Formatter from "./impl/formatter.js";
import * as Formats from "./impl/formats.js";
const INVALID = "Invalid Interval";
// checks if the start is equal to or before the end
function validateStartEnd(start, end) {
if (!start || !start.isValid) {
return Interval.invalid("missing or invalid start");
} else if (!end || !end.isValid) {
return Interval.invalid("missing or invalid end");
} else if (end < start) {
return Interval.invalid(
"end before start",
`The end of an interval must be after its start, but you had start=${start.toISO()} and end=${end.toISO()}`
);
} else {
return null;
}
}
/**
* An Interval object represents a half-open interval of time, where each endpoint is a {@link DateTime}. Conceptually, it's a container for those two endpoints, accompanied by methods for creating, parsing, interrogating, comparing, transforming, and formatting them.
*
* Here is a brief overview of the most commonly used methods and getters in Interval:
*
* * **Creation** To create an Interval, use {@link Interval.fromDateTimes}, {@link Interval.after}, {@link Interval.before}, or {@link Interval.fromISO}.
* * **Accessors** Use {@link Interval#start} and {@link Interval#end} to get the start and end.
* * **Interrogation** To analyze the Interval, use {@link Interval#count}, {@link Interval#length}, {@link Interval#hasSame}, {@link Interval#contains}, {@link Interval#isAfter}, or {@link Interval#isBefore}.
* * **Transformation** To create other Intervals out of this one, use {@link Interval#set}, {@link Interval#splitAt}, {@link Interval#splitBy}, {@link Interval#divideEqually}, {@link Interval.merge}, {@link Interval.xor}, {@link Interval#union}, {@link Interval#intersection}, or {@link Interval#difference}.
* * **Comparison** To compare this Interval to another one, use {@link Interval#equals}, {@link Interval#overlaps}, {@link Interval#abutsStart}, {@link Interval#abutsEnd}, {@link Interval#engulfs}
* * **Output** To convert the Interval into other representations, see {@link Interval#toString}, {@link Interval#toLocaleString}, {@link Interval#toISO}, {@link Interval#toISODate}, {@link Interval#toISOTime}, {@link Interval#toFormat}, and {@link Interval#toDuration}.
*/
export default class Interval {
/**
* @private
*/
constructor(config) {
/**
* @access private
*/
this.s = config.start;
/**
* @access private
*/
this.e = config.end;
/**
* @access private
*/
this.invalid = config.invalid || null;
/**
* @access private
*/
this.isLuxonInterval = true;
}
/**
* Create an invalid Interval.
* @param {string} reason - simple string of why this Interval is invalid. Should not contain parameters or anything else data-dependent
* @param {string} [explanation=null] - longer explanation, may include parameters and other useful debugging information
* @return {Interval}
*/
static invalid(reason, explanation = null) {
if (!reason) {
throw new InvalidArgumentError("need to specify a reason the Interval is invalid");
}
const invalid = reason instanceof Invalid ? reason : new Invalid(reason, explanation);
if (Settings.throwOnInvalid) {
throw new InvalidIntervalError(invalid);
} else {
return new Interval({ invalid });
}
}
/**
* Create an Interval from a start DateTime and an end DateTime. Inclusive of the start but not the end.
* @param {DateTime|Date|Object} start
* @param {DateTime|Date|Object} end
* @return {Interval}
*/
static fromDateTimes(start, end) {
const builtStart = friendlyDateTime(start),
builtEnd = friendlyDateTime(end);
const validateError = validateStartEnd(builtStart, builtEnd);
if (validateError == null) {
return new Interval({
start: builtStart,
end: builtEnd,
});
} else {
return validateError;
}
}
/**
* Create an Interval from a start DateTime and a Duration to extend to.
* @param {DateTime|Date|Object} start
* @param {Duration|Object|number} duration - the length of the Interval.
* @return {Interval}
*/
static after(start, duration) {
const dur = Duration.fromDurationLike(duration),
dt = friendlyDateTime(start);
return Interval.fromDateTimes(dt, dt.plus(dur));
}
/**
* Create an Interval from an end DateTime and a Duration to extend backwards to.
* @param {DateTime|Date|Object} end
* @param {Duration|Object|number} duration - the length of the Interval.
* @return {Interval}
*/
static before(end, duration) {
const dur = Duration.fromDurationLike(duration),
dt = friendlyDateTime(end);
return Interval.fromDateTimes(dt.minus(dur), dt);
}
/**
* Create an Interval from an ISO 8601 string.
* Accepts `<start>/<end>`, `<start>/<duration>`, and `<duration>/<end>` formats.
* @param {string} text - the ISO string to parse
* @param {Object} [opts] - options to pass {@link DateTime#fromISO} and optionally {@link Duration#fromISO}
* @see https://en.wikipedia.org/wiki/ISO_8601#Time_intervals
* @return {Interval}
*/
static fromISO(text, opts) {
const [s, e] = (text || "").split("/", 2);
if (s && e) {
let start, startIsValid;
try {
start = DateTime.fromISO(s, opts);
startIsValid = start.isValid;
} catch (e) {
startIsValid = false;
}
let end, endIsValid;
try {
end = DateTime.fromISO(e, opts);
endIsValid = end.isValid;
} catch (e) {
endIsValid = false;
}
if (startIsValid && endIsValid) {
return Interval.fromDateTimes(start, end);
}
if (startIsValid) {
const dur = Duration.fromISO(e, opts);
if (dur.isValid) {
return Interval.after(start, dur);
}
} else if (endIsValid) {
const dur = Duration.fromISO(s, opts);
if (dur.isValid) {
return Interval.before(end, dur);
}
}
}
return Interval.invalid("unparsable", `the input "${text}" can't be parsed as ISO 8601`);
}
/**
* Check if an object is an Interval. Works across context boundaries
* @param {object} o
* @return {boolean}
*/
static isInterval(o) {
return (o && o.isLuxonInterval) || false;
}
/**
* Returns the start of the Interval
* @type {DateTime}
*/
get start() {
return this.isValid ? this.s : null;
}
/**
* Returns the end of the Interval. This is the first instant which is not part of the interval
* (Interval is half-open).
* @type {DateTime}
*/
get end() {
return this.isValid ? this.e : null;
}
/**
* Returns the last DateTime included in the interval (since end is not part of the interval)
* @type {DateTime}
*/
get lastDateTime() {
return this.isValid ? (this.e ? this.e.minus(1) : null) : null;
}
/**
* Returns whether this Interval's end is at least its start, meaning that the Interval isn't 'backwards'.
* @type {boolean}
*/
get isValid() {
return this.invalidReason === null;
}
/**
* Returns an error code if this Interval is invalid, or null if the Interval is valid
* @type {string}
*/
get invalidReason() {
return this.invalid ? this.invalid.reason : null;
}
/**
* Returns an explanation of why this Interval became invalid, or null if the Interval is valid
* @type {string}
*/
get invalidExplanation() {
return this.invalid ? this.invalid.explanation : null;
}
/**
* Returns the length of the Interval in the specified unit.
* @param {string} unit - the unit (such as 'hours' or 'days') to return the length in.
* @return {number}
*/
length(unit = "milliseconds") {
return this.isValid ? this.toDuration(...[unit]).get(unit) : NaN;
}
/**
* Returns the count of minutes, hours, days, months, or years included in the Interval, even in part.
* Unlike {@link Interval#length} this counts sections of the calendar, not periods of time, e.g. specifying 'day'
* asks 'what dates are included in this interval?', not 'how many days long is this interval?'
* @param {string} [unit='milliseconds'] - the unit of time to count.
* @param {Object} opts - options
* @param {boolean} [opts.useLocaleWeeks=false] - If true, use weeks based on the locale, i.e. use the locale-dependent start of the week; this operation will always use the locale of the start DateTime
* @return {number}
*/
count(unit = "milliseconds", opts) {
if (!this.isValid) return NaN;
const start = this.start.startOf(unit, opts);
let end;
if (opts?.useLocaleWeeks) {
end = this.end.reconfigure({ locale: start.locale });
} else {
end = this.end;
}
end = end.startOf(unit, opts);
return Math.floor(end.diff(start, unit).get(unit)) + (end.valueOf() !== this.end.valueOf());
}
/**
* Returns whether this Interval's start and end are both in the same unit of time
* @param {string} unit - the unit of time to check sameness on
* @return {boolean}
*/
hasSame(unit) {
return this.isValid ? this.isEmpty() || this.e.minus(1).hasSame(this.s, unit) : false;
}
/**
* Return whether this Interval has the same start and end DateTimes.
* @return {boolean}
*/
isEmpty() {
return this.s.valueOf() === this.e.valueOf();
}
/**
* Return whether this Interval's start is after the specified DateTime.
* @param {DateTime} dateTime
* @return {boolean}
*/
isAfter(dateTime) {
if (!this.isValid) return false;
return this.s > dateTime;
}
/**
* Return whether this Interval's end is before the specified DateTime.
* @param {DateTime} dateTime
* @return {boolean}
*/
isBefore(dateTime) {
if (!this.isValid) return false;
return this.e <= dateTime;
}
/**
* Return whether this Interval contains the specified DateTime.
* @param {DateTime} dateTime
* @return {boolean}
*/
contains(dateTime) {
if (!this.isValid) return false;
return this.s <= dateTime && this.e > dateTime;
}
/**
* "Sets" the start and/or end dates. Returns a newly-constructed Interval.
* @param {Object} values - the values to set
* @param {DateTime} values.start - the starting DateTime
* @param {DateTime} values.end - the ending DateTime
* @return {Interval}
*/
set({ start, end } = {}) {
if (!this.isValid) return this;
return Interval.fromDateTimes(start || this.s, end || this.e);
}
/**
* Split this Interval at each of the specified DateTimes
* @param {...DateTime} dateTimes - the unit of time to count.
* @return {Array}
*/
splitAt(...dateTimes) {
if (!this.isValid) return [];
const sorted = dateTimes
.map(friendlyDateTime)
.filter((d) => this.contains(d))
.sort((a, b) => a.toMillis() - b.toMillis()),
results = [];
let { s } = this,
i = 0;
while (s < this.e) {
const added = sorted[i] || this.e,
next = +added > +this.e ? this.e : added;
results.push(Interval.fromDateTimes(s, next));
s = next;
i += 1;
}
return results;
}
/**
* Split this Interval into smaller Intervals, each of the specified length.
* Left over time is grouped into a smaller interval
* @param {Duration|Object|number} duration - The length of each resulting interval.
* @return {Array}
*/
splitBy(duration) {
const dur = Duration.fromDurationLike(duration);
if (!this.isValid || !dur.isValid || dur.as("milliseconds") === 0) {
return [];
}
let { s } = this,
idx = 1,
next;
const results = [];
while (s < this.e) {
const added = this.start.plus(dur.mapUnits((x) => x * idx));
next = +added > +this.e ? this.e : added;
results.push(Interval.fromDateTimes(s, next));
s = next;
idx += 1;
}
return results;
}
/**
* Split this Interval into the specified number of smaller intervals.
* @param {number} numberOfParts - The number of Intervals to divide the Interval into.
* @return {Array}
*/
divideEqually(numberOfParts) {
if (!this.isValid) return [];
return this.splitBy(this.length() / numberOfParts).slice(0, numberOfParts);
}
/**
* Return whether this Interval overlaps with the specified Interval
* @param {Interval} other
* @return {boolean}
*/
overlaps(other) {
return this.e > other.s && this.s < other.e;
}
/**
* Return whether this Interval's end is adjacent to the specified Interval's start.
* @param {Interval} other
* @return {boolean}
*/
abutsStart(other) {
if (!this.isValid) return false;
return +this.e === +other.s;
}
/**
* Return whether this Interval's start is adjacent to the specified Interval's end.
* @param {Interval} other
* @return {boolean}
*/
abutsEnd(other) {
if (!this.isValid) return false;
return +other.e === +this.s;
}
/**
* Returns true if this Interval fully contains the specified Interval, specifically if the intersect (of this Interval and the other Interval) is equal to the other Interval; false otherwise.
* @param {Interval} other
* @return {boolean}
*/
engulfs(other) {
if (!this.isValid) return false;
return this.s <= other.s && this.e >= other.e;
}
/**
* Return whether this Interval has the same start and end as the specified Interval.
* @param {Interval} other
* @return {boolean}
*/
equals(other) {
if (!this.isValid || !other.isValid) {
return false;
}
return this.s.equals(other.s) && this.e.equals(other.e);
}
/**
* Return an Interval representing the intersection of this Interval and the specified Interval.
* Specifically, the resulting Interval has the maximum start time and the minimum end time of the two Intervals.
* Returns null if the intersection is empty, meaning, the intervals don't intersect.
* @param {Interval} other
* @return {Interval}
*/
intersection(other) {
if (!this.isValid) return this;
const s = this.s > other.s ? this.s : other.s,
e = this.e < other.e ? this.e : other.e;
if (s >= e) {
return null;
} else {
return Interval.fromDateTimes(s, e);
}
}
/**
* Return an Interval representing the union of this Interval and the specified Interval.
* Specifically, the resulting Interval has the minimum start time and the maximum end time of the two Intervals.
* @param {Interval} other
* @return {Interval}
*/
union(other) {
if (!this.isValid) return this;
const s = this.s < other.s ? this.s : other.s,
e = this.e > other.e ? this.e : other.e;
return Interval.fromDateTimes(s, e);
}
/**
* Merge an array of Intervals into an equivalent minimal set of Intervals.
* Combines overlapping and adjacent Intervals.
* The resulting array will contain the Intervals in ascending order, that is, starting with the earliest Interval
* and ending with the latest.
*
* @param {Array} intervals
* @return {Array}
*/
static merge(intervals) {
const [found, final] = intervals
.sort((a, b) => a.s - b.s)
.reduce(
([sofar, current], item) => {
if (!current) {
return [sofar, item];
} else if (current.overlaps(item) || current.abutsStart(item)) {
return [sofar, current.union(item)];
} else {
return [sofar.concat([current]), item];
}
},
[[], null]
);
if (final) {
found.push(final);
}
return found;
}
/**
* Return an array of Intervals representing the spans of time that only appear in one of the specified Intervals.
* @param {Array} intervals
* @return {Array}
*/
static xor(intervals) {
let start = null,
currentCount = 0;
const results = [],
ends = intervals.map((i) => [
{ time: i.s, type: "s" },
{ time: i.e, type: "e" },
]),
flattened = Array.prototype.concat(...ends),
arr = flattened.sort((a, b) => a.time - b.time);
for (const i of arr) {
currentCount += i.type === "s" ? 1 : -1;
if (currentCount === 1) {
start = i.time;
} else {
if (start && +start !== +i.time) {
results.push(Interval.fromDateTimes(start, i.time));
}
start = null;
}
}
return Interval.merge(results);
}
/**
* Return an Interval representing the span of time in this Interval that doesn't overlap with any of the specified Intervals.
* @param {...Interval} intervals
* @return {Array}
*/
difference(...intervals) {
return Interval.xor([this].concat(intervals))
.map((i) => this.intersection(i))
.filter((i) => i && !i.isEmpty());
}
/**
* Returns a string representation of this Interval appropriate for debugging.
* @return {string}
*/
toString() {
if (!this.isValid) return INVALID;
return `[${this.s.toISO()} ${this.e.toISO()})`;
}
/**
* Returns a string representation of this Interval appropriate for the REPL.
* @return {string}
*/
[Symbol.for("nodejs.util.inspect.custom")]() {
if (this.isValid) {
return `Interval { start: ${this.s.toISO()}, end: ${this.e.toISO()} }`;
} else {
return `Interval { Invalid, reason: ${this.invalidReason} }`;
}
}
/**
* Returns a localized string representing this Interval. Accepts the same options as the
* Intl.DateTimeFormat constructor and any presets defined by Luxon, such as
* {@link DateTime.DATE_FULL} or {@link DateTime.TIME_SIMPLE}. The exact behavior of this method
* is browser-specific, but in general it will return an appropriate representation of the
* Interval in the assigned locale. Defaults to the system's locale if no locale has been
* specified.
* @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/DateTimeFormat
* @param {Object} [formatOpts=DateTime.DATE_SHORT] - Either a DateTime preset or
* Intl.DateTimeFormat constructor options.
* @param {Object} opts - Options to override the configuration of the start DateTime.
* @example Interval.fromISO('2022-11-07T09:00Z/2022-11-08T09:00Z').toLocaleString(); //=> 11/7/2022 11/8/2022
* @example Interval.fromISO('2022-11-07T09:00Z/2022-11-08T09:00Z').toLocaleString(DateTime.DATE_FULL); //=> November 7 8, 2022
* @example Interval.fromISO('2022-11-07T09:00Z/2022-11-08T09:00Z').toLocaleString(DateTime.DATE_FULL, { locale: 'fr-FR' }); //=> 78 novembre 2022
* @example Interval.fromISO('2022-11-07T17:00Z/2022-11-07T19:00Z').toLocaleString(DateTime.TIME_SIMPLE); //=> 6:00 8:00 PM
* @example Interval.fromISO('2022-11-07T17:00Z/2022-11-07T19:00Z').toLocaleString({ weekday: 'short', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }); //=> Mon, Nov 07, 6:00 8:00 p
* @return {string}
*/
toLocaleString(formatOpts = Formats.DATE_SHORT, opts = {}) {
return this.isValid
? Formatter.create(this.s.loc.clone(opts), formatOpts).formatInterval(this)
: INVALID;
}
/**
* Returns an ISO 8601-compliant string representation of this Interval.
* @see https://en.wikipedia.org/wiki/ISO_8601#Time_intervals
* @param {Object} opts - The same options as {@link DateTime#toISO}
* @return {string}
*/
toISO(opts) {
if (!this.isValid) return INVALID;
return `${this.s.toISO(opts)}/${this.e.toISO(opts)}`;
}
/**
* Returns an ISO 8601-compliant string representation of date of this Interval.
* The time components are ignored.
* @see https://en.wikipedia.org/wiki/ISO_8601#Time_intervals
* @return {string}
*/
toISODate() {
if (!this.isValid) return INVALID;
return `${this.s.toISODate()}/${this.e.toISODate()}`;
}
/**
* Returns an ISO 8601-compliant string representation of time of this Interval.
* The date components are ignored.
* @see https://en.wikipedia.org/wiki/ISO_8601#Time_intervals
* @param {Object} opts - The same options as {@link DateTime#toISO}
* @return {string}
*/
toISOTime(opts) {
if (!this.isValid) return INVALID;
return `${this.s.toISOTime(opts)}/${this.e.toISOTime(opts)}`;
}
/**
* Returns a string representation of this Interval formatted according to the specified format
* string. **You may not want this.** See {@link Interval#toLocaleString} for a more flexible
* formatting tool.
* @param {string} dateFormat - The format string. This string formats the start and end time.
* See {@link DateTime#toFormat} for details.
* @param {Object} opts - Options.
* @param {string} [opts.separator = ' '] - A separator to place between the start and end
* representations.
* @return {string}
*/
toFormat(dateFormat, { separator = " " } = {}) {
if (!this.isValid) return INVALID;
return `${this.s.toFormat(dateFormat)}${separator}${this.e.toFormat(dateFormat)}`;
}
/**
* Return a Duration representing the time spanned by this interval.
* @param {string|string[]} [unit=['milliseconds']] - the unit or units (such as 'hours' or 'days') to include in the duration.
* @param {Object} opts - options that affect the creation of the Duration
* @param {string} [opts.conversionAccuracy='casual'] - the conversion system to use
* @example Interval.fromDateTimes(dt1, dt2).toDuration().toObject() //=> { milliseconds: 88489257 }
* @example Interval.fromDateTimes(dt1, dt2).toDuration('days').toObject() //=> { days: 1.0241812152777778 }
* @example Interval.fromDateTimes(dt1, dt2).toDuration(['hours', 'minutes']).toObject() //=> { hours: 24, minutes: 34.82095 }
* @example Interval.fromDateTimes(dt1, dt2).toDuration(['hours', 'minutes', 'seconds']).toObject() //=> { hours: 24, minutes: 34, seconds: 49.257 }
* @example Interval.fromDateTimes(dt1, dt2).toDuration('seconds').toObject() //=> { seconds: 88489.257 }
* @return {Duration}
*/
toDuration(unit, opts) {
if (!this.isValid) {
return Duration.invalid(this.invalidReason);
}
return this.e.diff(this.s, unit, opts);
}
/**
* Run mapFn on the interval start and end, returning a new Interval from the resulting DateTimes
* @param {function} mapFn
* @return {Interval}
* @example Interval.fromDateTimes(dt1, dt2).mapEndpoints(endpoint => endpoint.toUTC())
* @example Interval.fromDateTimes(dt1, dt2).mapEndpoints(endpoint => endpoint.plus({ hours: 2 }))
*/
mapEndpoints(mapFn) {
return Interval.fromDateTimes(mapFn(this.s), mapFn(this.e));
}
}

26
node_modules/luxon/src/luxon.js generated vendored Normal file
View File

@@ -0,0 +1,26 @@
import DateTime from "./datetime.js";
import Duration from "./duration.js";
import Interval from "./interval.js";
import Info from "./info.js";
import Zone from "./zone.js";
import FixedOffsetZone from "./zones/fixedOffsetZone.js";
import IANAZone from "./zones/IANAZone.js";
import InvalidZone from "./zones/invalidZone.js";
import SystemZone from "./zones/systemZone.js";
import Settings from "./settings.js";
const VERSION = "3.7.2";
export {
VERSION,
DateTime,
Duration,
Interval,
Info,
Zone,
FixedOffsetZone,
IANAZone,
InvalidZone,
SystemZone,
Settings,
};

4
node_modules/luxon/src/package.json generated vendored Normal file
View File

@@ -0,0 +1,4 @@
{
"type": "module",
"version": "3.7.2"
}

180
node_modules/luxon/src/settings.js generated vendored Normal file
View File

@@ -0,0 +1,180 @@
import SystemZone from "./zones/systemZone.js";
import IANAZone from "./zones/IANAZone.js";
import Locale from "./impl/locale.js";
import DateTime from "./datetime.js";
import { normalizeZone } from "./impl/zoneUtil.js";
import { validateWeekSettings } from "./impl/util.js";
import { resetDigitRegexCache } from "./impl/digits.js";
let now = () => Date.now(),
defaultZone = "system",
defaultLocale = null,
defaultNumberingSystem = null,
defaultOutputCalendar = null,
twoDigitCutoffYear = 60,
throwOnInvalid,
defaultWeekSettings = null;
/**
* Settings contains static getters and setters that control Luxon's overall behavior. Luxon is a simple library with few options, but the ones it does have live here.
*/
export default class Settings {
/**
* Get the callback for returning the current timestamp.
* @type {function}
*/
static get now() {
return now;
}
/**
* Set the callback for returning the current timestamp.
* The function should return a number, which will be interpreted as an Epoch millisecond count
* @type {function}
* @example Settings.now = () => Date.now() + 3000 // pretend it is 3 seconds in the future
* @example Settings.now = () => 0 // always pretend it's Jan 1, 1970 at midnight in UTC time
*/
static set now(n) {
now = n;
}
/**
* Set the default time zone to create DateTimes in. Does not affect existing instances.
* Use the value "system" to reset this value to the system's time zone.
* @type {string}
*/
static set defaultZone(zone) {
defaultZone = zone;
}
/**
* Get the default time zone object currently used to create DateTimes. Does not affect existing instances.
* The default value is the system's time zone (the one set on the machine that runs this code).
* @type {Zone}
*/
static get defaultZone() {
return normalizeZone(defaultZone, SystemZone.instance);
}
/**
* Get the default locale to create DateTimes with. Does not affect existing instances.
* @type {string}
*/
static get defaultLocale() {
return defaultLocale;
}
/**
* Set the default locale to create DateTimes with. Does not affect existing instances.
* @type {string}
*/
static set defaultLocale(locale) {
defaultLocale = locale;
}
/**
* Get the default numbering system to create DateTimes with. Does not affect existing instances.
* @type {string}
*/
static get defaultNumberingSystem() {
return defaultNumberingSystem;
}
/**
* Set the default numbering system to create DateTimes with. Does not affect existing instances.
* @type {string}
*/
static set defaultNumberingSystem(numberingSystem) {
defaultNumberingSystem = numberingSystem;
}
/**
* Get the default output calendar to create DateTimes with. Does not affect existing instances.
* @type {string}
*/
static get defaultOutputCalendar() {
return defaultOutputCalendar;
}
/**
* Set the default output calendar to create DateTimes with. Does not affect existing instances.
* @type {string}
*/
static set defaultOutputCalendar(outputCalendar) {
defaultOutputCalendar = outputCalendar;
}
/**
* @typedef {Object} WeekSettings
* @property {number} firstDay
* @property {number} minimalDays
* @property {number[]} weekend
*/
/**
* @return {WeekSettings|null}
*/
static get defaultWeekSettings() {
return defaultWeekSettings;
}
/**
* Allows overriding the default locale week settings, i.e. the start of the week, the weekend and
* how many days are required in the first week of a year.
* Does not affect existing instances.
*
* @param {WeekSettings|null} weekSettings
*/
static set defaultWeekSettings(weekSettings) {
defaultWeekSettings = validateWeekSettings(weekSettings);
}
/**
* Get the cutoff year for whether a 2-digit year string is interpreted in the current or previous century. Numbers higher than the cutoff will be considered to mean 19xx and numbers lower or equal to the cutoff will be considered 20xx.
* @type {number}
*/
static get twoDigitCutoffYear() {
return twoDigitCutoffYear;
}
/**
* Set the cutoff year for whether a 2-digit year string is interpreted in the current or previous century. Numbers higher than the cutoff will be considered to mean 19xx and numbers lower or equal to the cutoff will be considered 20xx.
* @type {number}
* @example Settings.twoDigitCutoffYear = 0 // all 'yy' are interpreted as 20th century
* @example Settings.twoDigitCutoffYear = 99 // all 'yy' are interpreted as 21st century
* @example Settings.twoDigitCutoffYear = 50 // '49' -> 2049; '50' -> 1950
* @example Settings.twoDigitCutoffYear = 1950 // interpreted as 50
* @example Settings.twoDigitCutoffYear = 2050 // ALSO interpreted as 50
*/
static set twoDigitCutoffYear(cutoffYear) {
twoDigitCutoffYear = cutoffYear % 100;
}
/**
* Get whether Luxon will throw when it encounters invalid DateTimes, Durations, or Intervals
* @type {boolean}
*/
static get throwOnInvalid() {
return throwOnInvalid;
}
/**
* Set whether Luxon will throw when it encounters invalid DateTimes, Durations, or Intervals
* @type {boolean}
*/
static set throwOnInvalid(t) {
throwOnInvalid = t;
}
/**
* Reset Luxon's global caches. Should only be necessary in testing scenarios.
* @return {void}
*/
static resetCaches() {
Locale.resetCache();
IANAZone.resetCache();
DateTime.resetCache();
resetDigitRegexCache();
}
}

97
node_modules/luxon/src/zone.js generated vendored Normal file
View File

@@ -0,0 +1,97 @@
import { ZoneIsAbstractError } from "./errors.js";
/**
* @interface
*/
export default class Zone {
/**
* The type of zone
* @abstract
* @type {string}
*/
get type() {
throw new ZoneIsAbstractError();
}
/**
* The name of this zone.
* @abstract
* @type {string}
*/
get name() {
throw new ZoneIsAbstractError();
}
/**
* The IANA name of this zone.
* Defaults to `name` if not overwritten by a subclass.
* @abstract
* @type {string}
*/
get ianaName() {
return this.name;
}
/**
* Returns whether the offset is known to be fixed for the whole year.
* @abstract
* @type {boolean}
*/
get isUniversal() {
throw new ZoneIsAbstractError();
}
/**
* Returns the offset's common name (such as EST) at the specified timestamp
* @abstract
* @param {number} ts - Epoch milliseconds for which to get the name
* @param {Object} opts - Options to affect the format
* @param {string} opts.format - What style of offset to return. Accepts 'long' or 'short'.
* @param {string} opts.locale - What locale to return the offset name in.
* @return {string}
*/
offsetName(ts, opts) {
throw new ZoneIsAbstractError();
}
/**
* Returns the offset's value as a string
* @abstract
* @param {number} ts - Epoch milliseconds for which to get the offset
* @param {string} format - What style of offset to return.
* Accepts 'narrow', 'short', or 'techie'. Returning '+6', '+06:00', or '+0600' respectively
* @return {string}
*/
formatOffset(ts, format) {
throw new ZoneIsAbstractError();
}
/**
* Return the offset in minutes for this zone at the specified timestamp.
* @abstract
* @param {number} ts - Epoch milliseconds for which to compute the offset
* @return {number}
*/
offset(ts) {
throw new ZoneIsAbstractError();
}
/**
* Return whether this Zone is equal to another zone
* @abstract
* @param {Zone} otherZone - the zone to compare
* @return {boolean}
*/
equals(otherZone) {
throw new ZoneIsAbstractError();
}
/**
* Return whether this Zone is valid.
* @abstract
* @type {boolean}
*/
get isValid() {
throw new ZoneIsAbstractError();
}
}

125
node_modules/vue3-cron-plus-picker/README.md generated vendored Normal file
View File

@@ -0,0 +1,125 @@
# vue3+elementplus 的cron表达式生成插件
## 目的
- vue3环境中使用cron表达式插件
## 依赖版本
- Vue3.0.0+
- element-plus
## 使用
### 1 安装
`npm i vue3-cron-plus-picker`
或者
`pnpm add vue3-cron-plus-picker`
### 2 引入
1. 全局引入
在src\main.js中引入下载的包并引入其样式
```js
import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import Vue3CronPlusPicker from 'vue3-cron-plus-picker' // 引入组件
import 'vue3-cron-plus-picker/style.css' //引入组件相关样式
createApp(App).use(ElementPlus).use(Vue3CronPlusPicker).mount('#app')
```
2. 局部引入
在使用的组件的vue文件中直接引入
```js
import 'vue3-cron-plus-picker/style.css'
import {Vue3CronPlusPicker} from 'vue3-cron-plus-picker'
```
### 3 使用
```js
<template>
<div>
<el-input class="elInput" v-model="cronValue" placeholder="请输入正确的cron表达式">
<template #append>
<el-button class="inputButton" @click="openDialog">配置cron</el-button>
</template>
</el-input>
<el-dialog v-model="showCron" >
<vue3-cron-plus-picker @hide="showCron=false" @fill="cronFill" :expression="expression"/>
</el-dialog>
</div>
</template>
<script setup>
import {ref} from 'vue'
const cronValue = ref('')
const showCron = ref()
const expression = ref('')
const openDialog = ()=>{
showCron.value = true
expression.value = cronValue.value
}
const cronFill = (contabValue)=>{
cronValue.value = contabValue
}
</script>
<style>
</style>
```
### 4 参数
<table>
<thead>
<tr>
<th>属性名</th>
<th>说明</th>
<th>类型</th>
<th>Default</th>
<th>可选值</th>
</tr>
</thead>
<tbody>
<tr>
<td>expression</td>
<td>cron表达式绑定的值</td>
<td><span class="inline-flex items-center"><code class="api-typing mr-1">string</code></span></td>
<td>-</td>
<td>-</td>
</tr>
<tr>
<td>hideComponent</td>
<td>可以隐藏的组件内容</td>
<td><span class="inline-flex items-center"><code class="api-typing mr-1">Array</code></span></td>
<td>-</td>
<td>second/min/hour/day/mouth/week/year/result</td>
</tr>
</tbody>
</table>
### 5 事件
<table>
<thead>
<tr>
<th>名称</th>
<th>说明</th>
<th>类型</th>
<th>方法对应的参数类型</th>
</tr>
</thead>
<tbody>
<tr>
<td>fill</td>
<td>填充选择的cron表达式</td>
<td><span class="inline-flex items-center"><code class="api-typing mr-1">Function</code></span></td>
<td>(contabValue: string) => void</td>
</tr>
<tr>
<td>hide</td>
<td>关闭组件</td>
<td><span class="inline-flex items-center"><code class="api-typing mr-1">Function</code></span></td>
<td>() => void</td>
</tr>
</tbody>
</table>

18
node_modules/vue3-cron-plus-picker/package.json generated vendored Normal file
View File

@@ -0,0 +1,18 @@
{
"name": "vue3-cron-plus-picker",
"version": "1.0.2",
"description": "Corn expression plugin based on vue3+elementPlus基于vue3+elementplus+vue3的corn表达式插件",
"main": "vue3-cron-plus-picker.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [
"vue3-cron-plus-picker",
"vue3-cron-plus",
"vue3-cron",
"vue3",
"cron"
],
"author": "",
"license": "ISC"
}

1
node_modules/vue3-cron-plus-picker/style.css generated vendored Normal file
View File

@@ -0,0 +1 @@
.pop_btn[data-v-8ae49d29]{text-align:center;margin-top:20px}.popup-main[data-v-8ae49d29]{position:relative;margin:10px auto;background:#fff;border-radius:5px;font-size:12px;overflow:hidden}.popup-title[data-v-8ae49d29]{overflow:hidden;line-height:34px;padding-top:6px;background:#f2f2f2}.popup-result[data-v-8ae49d29]{box-sizing:border-box;line-height:24px;margin:25px auto;padding:15px 10px 10px;border:1px solid #ccc;position:relative}.popup-result .title[data-v-8ae49d29]{position:absolute;top:-28px;left:50%;width:140px;font-size:14px;margin-left:-70px;text-align:center;line-height:30px;background:#fff}.popup-result table[data-v-8ae49d29]{text-align:center;width:100%;margin:0 auto}.popup-result table span[data-v-8ae49d29]{display:block;width:100%;font-family:arial;line-height:30px;height:30px;white-space:nowrap;overflow:hidden;border:1px solid #e8e8e8}.popup-result-scroll[data-v-8ae49d29]{font-size:12px;line-height:24px;height:10em;overflow-y:auto}

1
node_modules/vue3-cron-plus-picker/vite.svg generated vendored Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

42
package-lock.json generated
View File

@@ -10,9 +10,11 @@
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",
"cron-parser": "^5.4.0",
"element-plus": "^2.4.0",
"vue": "^3.4.0",
"vue-router": "^4.2.0"
"vue-router": "^4.2.0",
"vue3-cron-plus-picker": "^1.0.2"
},
"devDependencies": {
"@babel/core": "^7.23.0",
@@ -3862,6 +3864,17 @@
"node": ">=10"
}
},
"node_modules/cron-parser": {
"version": "5.4.0",
"resolved": "https://registry.npmmirror.com/cron-parser/-/cron-parser-5.4.0.tgz",
"integrity": "sha512-HxYB8vTvnQFx4dLsZpGRa0uHp6X3qIzS3ZJgJ9v6l/5TJMgeWQbLkR5yiJ5hOxGbc9+jCADDnydIe15ReLZnJA==",
"dependencies": {
"luxon": "^3.7.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -6673,6 +6686,14 @@
"yallist": "^3.0.2"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmmirror.com/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "0.30.19",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.19.tgz",
@@ -9697,6 +9718,20 @@
"node": ">= 0.6"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"optional": true,
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "7.12.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.12.0.tgz",
@@ -9959,6 +9994,11 @@
"integrity": "sha512-4gDntzrifFnCEvyoO8PqyJDmguXgVPxKiIxrBKjIowvL9l+N66196+72XVYR8BBf1Uv1Fgt3bGevJ+sEmxfZzw==",
"dev": true
},
"node_modules/vue3-cron-plus-picker": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/vue3-cron-plus-picker/-/vue3-cron-plus-picker-1.0.2.tgz",
"integrity": "sha512-SUVmAb2qSPMuAm5GIU0wQZyUawiiL3OKEy1HAZs94eZz+neKF+wEPNP4wICWMU78u4LzeCNni2Njnhf8bsqkcw=="
},
"node_modules/watchpack": {
"version": "2.4.4",
"resolved": "https://registry.npmmirror.com/watchpack/-/watchpack-2.4.4.tgz",

View File

@@ -18,9 +18,11 @@
"license": "MIT",
"dependencies": {
"axios": "^1.6.0",
"cron-parser": "^5.4.0",
"element-plus": "^2.4.0",
"vue": "^3.4.0",
"vue-router": "^4.2.0"
"vue-router": "^4.2.0",
"vue3-cron-plus-picker": "^1.0.2"
},
"devDependencies": {
"@babel/core": "^7.23.0",

View File

@@ -1,14 +1,31 @@
<template>
<MainLayout />
<div id="app">
<template v-if="isLoginPage">
<router-view />
</template>
<template v-else>
<MainLayout />
</template>
</div>
</template>
<script>
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import MainLayout from './layouts/MainLayout.vue';
export default {
name: 'App',
components: {
MainLayout
},
setup() {
const route = useRoute();
const isLoginPage = computed(() => route.path === '/login');
return {
isLoginPage
};
}
};
</script>

89
src/api/areaController.js Normal file
View File

@@ -0,0 +1,89 @@
import http from '../utils/http';
/**
* @typedef {object} Response
* @property {number} code - 业务状态码
* @property {object} [data] - 业务数据
* @property {string} [message] - 提示信息
*/
/**
* @typedef {object} AreaControllerResponse
* @property {number} id
* @property {string} name
* @property {string} network_id
* @property {string} location
* @property {string} status
* @property {object} properties
* @property {string} created_at
* @property {string} updated_at
*/
/**
* @typedef {object} CreateAreaControllerRequest
* @property {string} name
* @property {string} network_id
* @property {string} [location]
* @property {object} [properties]
*/
/**
* @typedef {object} UpdateAreaControllerRequest
* @property {string} name
* @property {string} network_id
* @property {string} [location]
* @property {object} [properties]
*/
/**
* 获取系统中所有区域主控的列表
* @returns {Promise<Array<AreaControllerResponse>>}
*/
export const getAreaControllers = () => {
return http.get('/api/v1/area-controllers');
};
/**
* 根据提供的信息创建一个新区域主控
* @param {CreateAreaControllerRequest} areaControllerData - 区域主控信息
* @returns {Promise<AreaControllerResponse>}
*/
export const createAreaController = (areaControllerData) => {
return http.post('/api/v1/area-controllers', areaControllerData);
};
/**
* 根据ID获取单个区域主控的详细信息
* @param {string} id - 区域主控ID
* @returns {Promise<AreaControllerResponse>}
*/
export const getAreaControllerById = (id) => {
return http.get(`/api/v1/area-controllers/${id}`);
};
/**
* 根据ID更新一个已存在的区域主控信息
* @param {string} id - 区域主控ID
* @param {UpdateAreaControllerRequest} areaControllerData - 要更新的区域主控信息
* @returns {Promise<AreaControllerResponse>}
*/
export const updateAreaController = (id, areaControllerData) => {
return http.put(`/api/v1/area-controllers/${id}`, areaControllerData);
};
/**
* 根据ID删除一个区域主控软删除
* @param {string} id - 区域主控ID
* @returns {Promise<Response>}
*/
export const deleteAreaController = (id) => {
return http.delete(`/api/v1/area-controllers/${id}`);
};
export const AreaControllerApi = {
list: getAreaControllers,
create: createAreaController,
getById: getAreaControllerById,
update: updateAreaController,
delete: deleteAreaController,
};

View File

@@ -1,53 +1,203 @@
import http from '../utils/http.js';
import http from '../utils/http';
// --- Typedefs ---
/**
* 设备管理API
* @typedef {object} Response
* @property {number} code - 业务状态码
* @property {object} [data] - 业务数据
* @property {string} [message] - 提示信息
*/
export class DeviceApi {
/**
* 获取设备列表
* @returns {Promise} 设备列表
*/
static list() {
return http.get('/api/v1/devices');
}
/**
* 创建新设备
* @param {Object} deviceData 设备数据
* @returns {Promise} 创建结果
*/
static create(deviceData) {
return http.post('/api/v1/devices', deviceData);
}
/**
* @typedef {object} DeviceResponse
* @property {number} id
* @property {string} name
* @property {string} location
* @property {number} area_controller_id
* @property {string} area_controller_name
* @property {number} device_template_id
* @property {string} device_template_name
* @property {object} properties
* @property {string} created_at
* @property {string} updated_at
*/
/**
* 获取设备详情
* @param {string|number} id 设备ID
* @returns {Promise} 设备详情
*/
static get(id) {
return http.get(`/api/v1/devices/${id}`);
}
/**
* @typedef {object} CreateDeviceRequest
* @property {string} name
* @property {string} [location]
* @property {number} area_controller_id
* @property {number} device_template_id
* @property {object} [properties]
*/
/**
* 更新设备信息
* @param {string|number} id 设备ID
* @param {Object} deviceData 设备数据
* @returns {Promise} 更新结果
*/
static update(id, deviceData) {
return http.put(`/api/v1/devices/${id}`, deviceData);
}
/**
* @typedef {object} UpdateDeviceRequest
* @property {string} name
* @property {string} [location]
* @property {number} area_controller_id
* @property {number} device_template_id
* @property {object} [properties]
*/
/**
* 删除设备
* @param {string|number} id 设备ID
* @returns {Promise} 删除结果
*/
static delete(id) {
return http.delete(`/api/v1/devices/${id}`);
}
}
/**
* @typedef {object} ManualControlDeviceRequest
* @property {string} [action] - Action 不传表示这是一个传感器, 会触发一次采集
*/
export default DeviceApi;
/**
* @typedef {object} AreaControllerResponse
* @property {number} id
* @property {string} name
* @property {string} network_id
* @property {string} location
* @property {string} status
* @property {object} properties
* @property {string} created_at
* @property {string} updated_at
*/
/**
* @typedef {object} CreateAreaControllerRequest
* @property {string} name
* @property {string} network_id
* @property {string} [location]
* @property {object} [properties]
*/
/**
* @typedef {object} UpdateAreaControllerRequest
* @property {string} name
* @property {string} network_id
* @property {string} [location]
* @property {object} [properties]
*/
// --- Device API Functions ---
/**
* 获取系统中所有设备的列表
* @returns {Promise<Array<DeviceResponse>>}
*/
export const getDevices = () => {
return http.get('/api/v1/devices');
};
/**
* 根据提供的信息创建一个新设备
* @param {CreateDeviceRequest} deviceData - 设备信息
* @returns {Promise<DeviceResponse>}
*/
export const createDevice = (deviceData) => {
return http.post('/api/v1/devices', deviceData);
};
/**
* 根据设备ID获取单个设备的详细信息
* @param {string} id - 设备ID
* @returns {Promise<DeviceResponse>}
*/
export const getDeviceById = (id) => {
return http.get(`/api/v1/devices/${id}`);
};
/**
* 根据设备ID更新一个已存在的设备信息
* @param {string} id - 设备ID
* @param {UpdateDeviceRequest} deviceData - 要更新的设备信息
* @returns {Promise<DeviceResponse>}
*/
export const updateDevice = (id, deviceData) => {
return http.put(`/api/v1/devices/${id}`, deviceData);
};
/**
* 根据设备ID删除一个设备软删除
* @param {string} id - 设备ID
* @returns {Promise<Response>}
*/
export const deleteDevice = (id) => {
return http.delete(`/api/v1/devices/${id}`);
};
/**
* 根据设备ID和指定的动作开启或关闭来手动控制设备
* @param {string} id - 设备ID
* @param {ManualControlDeviceRequest} manualControlData - 手动控制指令
* @returns {Promise<Response>}
*/
export const manualControlDevice = (id, manualControlData) => {
return http.post(`/api/v1/devices/manual-control/${id}`, manualControlData);
};
// --- AreaController API Functions ---
/**
* 获取系统中所有区域主控的列表
* @returns {Promise<Array<AreaControllerResponse>>}
*/
export const getAreaControllers = () => {
return http.get('/api/v1/area-controllers');
};
/**
* 创建一个新区域主控
* @param {CreateAreaControllerRequest} areaControllerData - 区域主控信息
* @returns {Promise<AreaControllerResponse>}
*/
export const createAreaController = (areaControllerData) => {
return http.post('/api/v1/area-controllers', areaControllerData);
};
/**
* 根据ID获取单个区域主控的详细信息
* @param {string} id - 区域主控ID
* @returns {Promise<AreaControllerResponse>}
*/
export const getAreaControllerById = (id) => {
return http.get(`/api/v1/area-controllers/${id}`);
};
/**
* 根据ID更新一个已存在的区域主控信息
* @param {string} id - 区域主控ID
* @param {UpdateAreaControllerRequest} areaControllerData - 要更新的区域主控信息
* @returns {Promise<AreaControllerResponse>}
*/
export const updateAreaController = (id, areaControllerData) => {
return http.put(`/api/v1/area-controllers/${id}`, areaControllerData);
};
/**
* 根据ID删除一个区域主控
* @param {string} id - 区域主控ID
* @returns {Promise<Response>}
*/
export const deleteAreaController = (id) => {
return http.delete(`/api/v1/area-controllers/${id}`);
};
// --- API Wrappers ---
// AreaControllerApi 封装
export const AreaControllerApi = {
list: getAreaControllers,
create: createAreaController,
getById: getAreaControllerById,
update: updateAreaController,
delete: deleteAreaController
};
// DeviceApi 封装
export const DeviceApi = {
list: getDevices,
create: createDevice,
getById: getDeviceById,
update: updateDevice,
delete: deleteDevice,
manualControl: manualControlDevice
};

110
src/api/deviceTemplate.js Normal file
View File

@@ -0,0 +1,110 @@
import http from '../utils/http';
/**
* @typedef {object} Response
* @property {number} code - 业务状态码
* @property {object} [data] - 业务数据
* @property {string} [message] - 提示信息
*/
/**
* @typedef {('执行器'|'传感器')} DeviceCategory
*/
/**
* @typedef {('信号强度'|'电池电量'|'温度'|'湿度'|'重量')} SensorType
*/
/**
* @typedef {object} ValueDescriptor
* @property {SensorType} type
* @property {number} [multiplier] - 乘数,用于原始数据转换
* @property {number} [offset] - 偏移量,用于原始数据转换
*/
/**
* @typedef {object} DeviceTemplateResponse
* @property {number} id
* @property {string} name
* @property {string} [description]
* @property {string} [manufacturer]
* @property {DeviceCategory} category
* @property {object} commands
* @property {Array<ValueDescriptor>} values
* @property {string} created_at
* @property {string} updated_at
*/
/**
* @typedef {object} CreateDeviceTemplateRequest
* @property {string} name
* @property {string} [description]
* @property {string} [manufacturer]
* @property {DeviceCategory} category
* @property {object} commands
* @property {Array<ValueDescriptor>} [values]
*/
/**
* @typedef {object} UpdateDeviceTemplateRequest
* @property {string} name
* @property {string} [description]
* @property {string} [manufacturer]
* @property {DeviceCategory} category
* @property {object} commands
* @property {Array<ValueDescriptor>} [values]
*/
/**
* 获取系统中所有设备模板的列表
* @returns {Promise<Array<DeviceTemplateResponse>>}
*/
const getDeviceTemplates = () => {
return http.get('/api/v1/device-templates');
};
/**
* 根据提供的信息创建一个新设备模板
* @param {CreateDeviceTemplateRequest} deviceTemplateData - 设备模板信息
* @returns {Promise<DeviceTemplateResponse>}
*/
const createDeviceTemplate = (deviceTemplateData) => {
return http.post('/api/v1/device-templates', deviceTemplateData);
};
/**
* 根据设备模板ID获取单个设备模板的详细信息
* @param {number} id - 设备模板ID
* @returns {Promise<DeviceTemplateResponse>}
*/
const getDeviceTemplateById = (id) => {
return http.get(`/api/v1/device-templates/${id}`);
};
/**
* 根据设备模板ID更新一个已存在的设备模板信息
* @param {number} id - 设备模板ID
* @param {UpdateDeviceTemplateRequest} deviceTemplateData - 要更新的设备模板信息
* @returns {Promise<DeviceTemplateResponse>}
*/
const updateDeviceTemplate = (id, deviceTemplateData) => {
return http.put(`/api/v1/device-templates/${id}`, deviceTemplateData);
};
/**
* 根据设备模板ID删除一个设备模板软删除
* @param {number} id - 设备模板ID
* @returns {Promise<Response>}
*/
const deleteDeviceTemplate = (id) => {
return http.delete(`/api/v1/device-templates/${id}`);
};
export const DeviceTemplateApi = {
getDeviceTemplates,
createDeviceTemplate,
getDeviceTemplateById,
updateDeviceTemplate,
deleteDeviceTemplate,
};

View File

@@ -1,15 +1,18 @@
import DeviceApi from './device.js';
import PlanApi from './plan.js';
import UserApi from './user.js';
import { AreaControllerApi, DeviceApi } from './device.js';
import { PlanApi } from './plan.js';
import { UserApi } from './user.js';
import { DeviceTemplateApi } from './deviceTemplate.js'; // 导入设备模板API
/**
* API客户端
*/
export class ApiClient {
constructor() {
this.areaControllers = AreaControllerApi;
this.devices = DeviceApi;
this.plans = PlanApi;
this.users = UserApi;
this.deviceTemplates = DeviceTemplateApi; // 添加设备模板API
}
}

900
src/api/monitor.js Normal file
View File

@@ -0,0 +1,900 @@
import http from '../utils/http';
// --- Typedefs ---
/**
* @typedef {object} PaginationDTO
* @property {number} page
* @property {number} page_size
* @property {number} total
*/
/**
* @typedef {object} DeviceCommandLogDTO
* @property {string} message_id
* @property {number} device_id
* @property {string} sent_at
* @property {string} acknowledged_at
* @property {boolean} received_success
*/
/**
* @typedef {object} ListDeviceCommandLogResponse
* @property {Array<DeviceCommandLogDTO>} list
* @property {PaginationDTO} pagination
*/
/**
* @typedef {object} DeviceCommandLogsParams
* @property {number} [page]
* @property {number} [page_size]
* @property {string} [order_by]
* @property {number} [device_id]
* @property {string} [start_time]
* @property {string} [end_time]
* @property {boolean} [received_success]
*/
/**
* @typedef {object} FeedFormulaDTO
* @property {number} id
* @property {string} name
*/
/**
* @typedef {object} PenDTO
* @property {number} id
* @property {string} name
*/
/**
* @typedef {object} FeedUsageRecordDTO
* @property {number} id
* @property {number} pen_id
* @property {PenDTO} pen
* @property {number} feed_formula_id
* @property {FeedFormulaDTO} feed_formula
* @property {number} amount
* @property {string} recorded_at
* @property {string} remarks
* @property {number} operator_id
*/
/**
* @typedef {object} ListFeedUsageRecordResponse
* @property {Array<FeedUsageRecordDTO>} list
* @property {PaginationDTO} pagination
*/
/**
* @typedef {object} FeedUsageRecordsParams
* @property {number} [page]
* @property {number} [page_size]
* @property {string} [order_by]
* @property {number} [pen_id]
* @property {number} [feed_formula_id]
* @property {string} [start_time]
* @property {string} [end_time]
* @property {number} [operator_id]
*/
/**
* @typedef {('预防'|'治疗'|'保健')} MedicationReasonType
*/
/**
* @typedef {object} MedicationDTO
* @property {number} id
* @property {string} name
*/
/**
* @typedef {object} MedicationLogDTO
* @property {number} id
* @property {number} pig_batch_id
* @property {number} medication_id
* @property {MedicationDTO} medication
* @property {number} dosage_used
* @property {number} target_count
* @property {MedicationReasonType} reason
* @property {string} description
* @property {string} happened_at
* @property {number} operator_id
*/
/**
* @typedef {object} ListMedicationLogResponse
* @property {Array<MedicationLogDTO>} list
* @property {PaginationDTO} pagination
*/
/**
* @typedef {object} MedicationLogsParams
* @property {number} [page]
* @property {number} [page_size]
* @property {string} [order_by]
* @property {number} [pig_batch_id]
* @property {number} [medication_id]
* @property {string} [reason]
* @property {string} [start_time]
* @property {string} [end_time]
* @property {number} [operator_id]
*/
/**
* @typedef {('邮件'|'企业微信'|'飞书'|'日志')} NotifierType
*/
/**
* @typedef {('发送成功'|'发送失败'|'已跳过')} NotificationStatus
*/
/**
* @typedef {object} NotificationDTO
* @property {number} id
* @property {number} user_id
* @property {NotifierType} notifier_type
* @property {string} to_address
* @property {string} title
* @property {string} message
* @property {number} level - 日志级别, 见 ZapcoreLevel 枚举
* @property {string} alarm_timestamp
* @property {NotificationStatus} status
* @property {string} error_message
* @property {string} created_at
* @property {string} updated_at
*/
/**
* @typedef {object} ListNotificationResponse
* @property {Array<NotificationDTO>} list
* @property {PaginationDTO} pagination
*/
/**
* @typedef {object} NotificationsParams
* @property {number} [page]
* @property {number} [page_size]
* @property {string} [order_by]
* @property {string} [start_time]
* @property {string} [end_time]
* @property {number} [level] - 日志级别, 见 ZapcoreLevel 枚举
* @property {NotifierType} [notifier_type]
* @property {NotificationStatus} [status]
* @property {number} [user_id]
*/
/**
* @typedef {('等待中'|'已完成'|'已超时')} PendingCollectionStatus
*/
/**
* @typedef {object} PendingCollectionDTO
* @property {string} correlation_id
* @property {number} device_id
* @property {Array<number>} command_metadata
* @property {PendingCollectionStatus} status
* @property {string} created_at
* @property {string} fulfilled_at
*/
/**
* @typedef {object} ListPendingCollectionResponse
* @property {Array<PendingCollectionDTO>} list
* @property {PaginationDTO} pagination
*/
/**
* @typedef {object} PendingCollectionsParams
* @property {number} [page]
* @property {number} [page_size]
* @property {string} [order_by]
* @property {number} [device_id]
* @property {string} [status]
* @property {string} [start_time]
* @property {string} [end_time]
*/
/**
* @typedef {('死亡'|'淘汰'|'销售'|'购买'|'转入'|'转出'|'盘点校正')} LogChangeType
*/
/**
* @typedef {object} PigBatchLogDTO
* @property {number} id
* @property {number} pig_batch_id
* @property {LogChangeType} change_type
* @property {number} before_count
* @property {number} after_count
* @property {number} change_count
* @property {string} reason
* @property {number} operator_id
* @property {string} happened_at
* @property {string} created_at
* @property {string} updated_at
*/
/**
* @typedef {object} ListPigBatchLogResponse
* @property {Array<PigBatchLogDTO>} list
* @property {PaginationDTO} pagination
*/
/**
* @typedef {object} PigBatchLogsParams
* @property {number} [page]
* @property {number} [page_size]
* @property {string} [order_by]
* @property {number} [pig_batch_id]
* @property {string} [change_type]
* @property {string} [start_time]
* @property {string} [end_time]
* @property {number} [operator_id]
*/
/**
* @typedef {object} PigPurchaseDTO
* @property {number} id
* @property {number} pig_batch_id
* @property {number} quantity
* @property {number} unit_price
* @property {number} total_price
* @property {string} supplier
* @property {string} purchase_date
* @property {string} remarks
* @property {number} operator_id
* @property {string} created_at
* @property {string} updated_at
*/
/**
* @typedef {object} ListPigPurchaseResponse
* @property {Array<PigPurchaseDTO>} list
* @property {PaginationDTO} pagination
*/
/**
* @typedef {object} PigPurchasesParams
* @property {number} [page]
* @property {number} [page_size]
* @property {string} [order_by]
* @property {number} [pig_batch_id]
* @property {string} [supplier]
* @property {string} [start_time]
* @property {string} [end_time]
* @property {number} [operator_id]
*/
/**
* @typedef {object} PigSaleDTO
* @property {number} id
* @property {number} pig_batch_id
* @property {number} quantity
* @property {number} unit_price
* @property {number} total_price
* @property {string} buyer
* @property {string} sale_date
* @property {string} remarks
* @property {number} operator_id
* @property {string} created_at
* @property {string} updated_at
*/
/**
* @typedef {object} ListPigSaleResponse
* @property {Array<PigSaleDTO>} list
* @property {PaginationDTO} pagination
*/
/**
* @typedef {object} PigSalesParams
* @property {number} [page]
* @property {number} [page_size]
* @property {string} [order_by]
* @property {number} [pig_batch_id]
* @property {string} [buyer]
* @property {string} [start_time]
* @property {string} [end_time]
* @property {number} [operator_id]
*/
/**
* @typedef {('患病'|'康复'|'死亡'|'淘汰'|'转入'|'转出'|'其他')} PigBatchSickPigReasonType
*/
/**
* @typedef {('原地治疗'|'病猪栏治疗')} PigBatchSickPigTreatmentLocation
*/
/**
* @typedef {object} PigSickLogDTO
* @property {number} id
* @property {number} pig_batch_id
* @property {number} pen_id
* @property {PigBatchSickPigReasonType} reason
* @property {PigBatchSickPigTreatmentLocation} treatment_location
* @property {number} before_count
* @property {number} after_count
* @property {number} change_count
* @property {string} remarks
* @property {number} operator_id
* @property {string} happened_at
* @property {string} created_at
* @property {string} updated_at
*/
/**
* @typedef {object} ListPigSickLogResponse
* @property {Array<PigSickLogDTO>} list
* @property {PaginationDTO} pagination
*/
/**
* @typedef {object} PigSickLogsParams
* @property {number} [page]
* @property {number} [page_size]
* @property {string} [order_by]
* @property {number} [pig_batch_id]
* @property {number} [pen_id]
* @property {string} [reason]
* @property {string} [treatment_location]
* @property {string} [start_time]
* @property {string} [end_time]
* @property {number} [operator_id]
*/
/**
* @typedef {('群内调栏'|'跨群调栏'|'销售'|'死亡'|'淘汰'|'新购入'|'产房转入')} PigTransferType
*/
/**
* @typedef {object} PigTransferLogDTO
* @property {number} id
* @property {number} pig_batch_id
* @property {number} pen_id
* @property {PigTransferType} type
* @property {number} quantity
* @property {string} remarks
* @property {string} correlation_id
* @property {number} operator_id
* @property {string} transfer_time
* @property {string} created_at
* @property {string} updated_at
*/
/**
* @typedef {object} ListPigTransferLogResponse
* @property {Array<PigTransferLogDTO>} list
* @property {PaginationDTO} pagination
*/
/**
* @typedef {object} PigTransferLogsParams
* @property {number} [page]
* @property {number} [page_size]
* @property {string} [order_by]
* @property {number} [pig_batch_id]
* @property {number} [pen_id]
* @property {string} [transfer_type]
* @property {string} [correlation_id]
* @property {string} [start_time]
* @property {string} [end_time]
* @property {number} [operator_id]
*/
/**
* @typedef {('已开始'|'已完成'|'失败'|'已取消'|'等待中')} ExecutionStatus
*/
/**
* @typedef {object} PlanExecutionLogDTO
* @property {number} id
* @property {number} plan_id
* @property {string} plan_name
* @property {ExecutionStatus} status
* @property {string} started_at
* @property {string} ended_at
* @property {string} error
* @property {string} created_at
* @property {string} updated_at
*/
/**
* @typedef {object} ListPlanExecutionLogResponse
* @property {Array<PlanExecutionLogDTO>} list
* @property {PaginationDTO} pagination
*/
/**
* @typedef {object} PlanExecutionLogsParams
* @property {number} [page]
* @property {number} [page_size]
* @property {string} [order_by]
* @property {number} [plan_id]
* @property {string} [status]
* @property {string} [start_time]
* @property {string} [end_time]
*/
/**
* @typedef {object} RawMaterialDTO
* @property {number} id
* @property {string} name
*/
/**
* @typedef {object} RawMaterialPurchaseDTO
* @property {number} id
* @property {number} raw_material_id
* @property {RawMaterialDTO} raw_material
* @property {number} amount
* @property {number} unit_price
* @property {number} total_price
* @property {string} supplier
* @property {string} purchase_date
* @property {string} created_at
*/
/**
* @typedef {object} ListRawMaterialPurchaseResponse
* @property {Array<RawMaterialPurchaseDTO>} list
* @property {PaginationDTO} pagination
*/
/**
* @typedef {object} RawMaterialPurchasesParams
* @property {number} [page]
* @property {number} [page_size]
* @property {string} [order_by]
* @property {number} [raw_material_id]
* @property {string} [supplier]
* @property {string} [start_time]
* @property {string} [end_time]
*/
/**
* @typedef {('采购入库'|'饲喂出库'|'变质出库'|'售卖出库'|'杂用领取'|'手动盘点')} StockLogSourceType
*/
/**
* @typedef {object} RawMaterialStockLogDTO
* @property {number} id
* @property {number} raw_material_id
* @property {number} change_amount
* @property {StockLogSourceType} source_type
* @property {number} source_id
* @property {string} remarks
* @property {string} happened_at
*/
/**
* @typedef {object} ListRawMaterialStockLogResponse
* @property {Array<RawMaterialStockLogDTO>} list
* @property {PaginationDTO} pagination
*/
/**
* @typedef {object} RawMaterialStockLogsParams
* @property {number} [page]
* @property {number} [page_size]
* @property {string} [order_by]
* @property {number} [raw_material_id]
* @property {string} [source_type]
* @property {number} [source_id]
* @property {string} [start_time]
* @property {string} [end_time]
*/
/**
* @typedef {('信号强度'|'电池电量'|'温度'|'湿度'|'重量')} SensorType
*/
/**
* @typedef {object} SensorDataDTO
* @property {number} regional_controller_id
* @property {number} device_id
* @property {SensorType} sensor_type
* @property {Array<number>} data
* @property {string} time
*/
/**
* @typedef {object} ListSensorDataResponse
* @property {Array<SensorDataDTO>} list
* @property {PaginationDTO} pagination
*/
/**
* @typedef {object} SensorDataParams
* @property {number} [page]
* @property {number} [page_size]
* @property {string} [order_by]
* @property {number} [device_id]
* @property {string} [sensor_type]
* @property {string} [start_time]
* @property {string} [end_time]
*/
/**
* @typedef {object} TaskDTO
* @property {number} id
* @property {string} name
* @property {string} description
*/
/**
* @typedef {object} TaskExecutionLogDTO
* @property {number} id
* @property {number} plan_execution_log_id
* @property {number} task_id
* @property {TaskDTO} task
* @property {ExecutionStatus} status
* @property {string} output
* @property {string} started_at
* @property {string} ended_at
* @property {string} created_at
* @property {string} updated_at
*/
/**
* @typedef {object} ListTaskExecutionLogResponse
* @property {Array<TaskExecutionLogDTO>} list
* @property {PaginationDTO} pagination
*/
/**
* @typedef {object} TaskExecutionLogsParams
* @property {number} [page]
* @property {number} [page_size]
* @property {string} [order_by]
* @property {number} [plan_execution_log_id]
* @property {number} [task_id]
* @property {string} [status]
* @property {string} [start_time]
* @property {string} [end_time]
*/
/**
* @typedef {('成功'|'失败')} AuditStatus
*/
/**
* @typedef {object} UserActionLogDTO
* @property {number} id
* @property {number} user_id
* @property {string} username
* @property {string} action_type
* @property {string} description
* @property {string} http_method
* @property {string} http_path
* @property {string} source_ip
* @property {Array<number>} target_resource
* @property {AuditStatus} status
* @property {string} result_details
* @property {string} time
*/
/**
* @typedef {object} ListUserActionLogResponse
* @property {Array<UserActionLogDTO>} list
* @property {PaginationDTO} pagination
*/
/**
* @typedef {object} UserActionLogsParams
* @property {number} [page]
* @property {number} [page_size]
* @property {string} [order_by]
* @property {number} [user_id]
* @property {string} [username]
* @property {string} [action_type]
* @property {string} [status]
* @property {string} [start_time]
* @property {string} [end_time]
*/
/**
* @typedef {object} WeighingBatchDTO
* @property {number} id
* @property {number} pig_batch_id
* @property {string} description
* @property {string} weighing_time
* @property {string} created_at
* @property {string} updated_at
*/
/**
* @typedef {object} ListWeighingBatchResponse
* @property {Array<WeighingBatchDTO>} list
* @property {PaginationDTO} pagination
*/
/**
* @typedef {object} WeighingBatchesParams
* @property {number} [page]
* @property {number} [page_size]
* @property {string} [order_by]
* @property {number} [pig_batch_id]
* @property {string} [start_time]
* @property {string} [end_time]
*/
/**
* @typedef {object} WeighingRecordDTO
* @property {number} id
* @property {number} weighing_batch_id
* @property {number} pen_id
* @property {number} weight
* @property {string} remark
* @property {number} operator_id
* @property {string} weighing_time
* @property {string} created_at
* @property {string} updated_at
*/
/**
* @typedef {object} ListWeighingRecordResponse
* @property {Array<WeighingRecordDTO>} list
* @property {PaginationDTO} pagination
*/
/**
* @typedef {object} WeighingRecordsParams
* @property {number} [page]
* @property {number} [page_size]
* @property {string} [order_by]
* @property {number} [weighing_batch_id]
* @property {number} [pen_id]
* @property {string} [start_time]
* @property {string} [end_time]
* @property {number} [operator_id]
*/
// --- Enums ---
/**
* 日志级别, 对应后端的 zapcore.Level
* @enum {number}
*/
export const ZapcoreLevel = {
Debug: -1,
Info: 0,
Warn: 1,
Error: 2,
DPanic: 3,
Panic: 4,
Fatal: 5,
_minLevel: -1,
_maxLevel: 5,
Invalid: 6,
_numLevels: 7,
};
// --- Functions ---
const processResponse = (responseData) => {
const data = responseData.data;
return {
list: data.list || [],
total: data.pagination ? data.pagination.total : 0,
};
};
/**
* 获取设备命令日志列表
* @param {DeviceCommandLogsParams} params - 查询参数
* @returns {Promise<{list: Array<DeviceCommandLogDTO>, total: number}>}
*/
export const getDeviceCommandLogs = async (params) => {
const newParams = { ...params, page_size: params.page_size };
const responseData = await http.get('/api/v1/monitor/device-command-logs', { params: newParams });
return processResponse(responseData);
};
/**
* 获取饲料使用记录列表
* @param {FeedUsageRecordsParams} params - 查询参数
* @returns {Promise<{list: Array<FeedUsageRecordDTO>, total: number}>}
*/
export const getFeedUsageRecords = async (params) => {
const newParams = { ...params, page_size: params.page_size };
const responseData = await http.get('/api/v1/monitor/feed-usage-records', { params: newParams });
return processResponse(responseData);
};
/**
* 获取用药记录列表
* @param {MedicationLogsParams} params - 查询参数
* @returns {Promise<{list: Array<MedicationLogDTO>, total: number}>}
*/
export const getMedicationLogs = async (params) => {
const newParams = { ...params, page_size: params.page_size };
const responseData = await http.get('/api/v1/monitor/medication-logs', { params: newParams });
return processResponse(responseData);
};
/**
* 批量查询通知
* @param {NotificationsParams} params - 查询参数
* @returns {Promise<{list: Array<NotificationDTO>, total: number}>}
*/
export const getNotifications = async (params) => {
const newParams = { ...params, page_size: params.page_size };
const responseData = await http.get('/api/v1/monitor/notifications', { params: newParams });
return processResponse(responseData);
};
/**
* 获取待采集请求列表
* @param {PendingCollectionsParams} params - 查询参数
* @returns {Promise<{list: Array<PendingCollectionDTO>, total: number}>}
*/
export const getPendingCollections = async (params) => {
const newParams = { ...params, page_size: params.page_size };
const responseData = await http.get('/api/v1/monitor/pending-collections', { params: newParams });
return processResponse(responseData);
};
/**
* 获取猪批次日志列表
* @param {PigBatchLogsParams} params - 查询参数
* @returns {Promise<{list: Array<PigBatchLogDTO>, total: number}>}
*/
export const getPigBatchLogs = async (params) => {
const newParams = { ...params, page_size: params.page_size };
const responseData = await http.get('/api/v1/monitor/pig-batch-logs', { params: newParams });
return processResponse(responseData);
};
/**
* 获取猪只采购记录列表
* @param {PigPurchasesParams} params - 查询参数
* @returns {Promise<{list: Array<PigPurchaseDTO>, total: number}>}
*/
export const getPigPurchases = async (params) => {
const newParams = { ...params, page_size: params.page_size };
const responseData = await http.get('/api/v1/monitor/pig-purchases', { params: newParams });
return processResponse(responseData);
};
/**
* 获取猪只售卖记录列表
* @param {PigSalesParams} params - 查询参数
* @returns {Promise<{list: Array<PigSaleDTO>, total: number}>}
*/
export const getPigSales = async (params) => {
const newParams = { ...params, page_size: params.page_size };
const responseData = await http.get('/api/v1/monitor/pig-sales', { params: newParams });
return processResponse(responseData);
};
/**
* 获取病猪日志列表
* @param {PigSickLogsParams} params - 查询参数
* @returns {Promise<{list: Array<PigSickLogDTO>, total: number}>}
*/
export const getPigSickLogs = async (params) => {
const newParams = { ...params, page_size: params.page_size };
const responseData = await http.get('/api/v1/monitor/pig-sick-logs', { params: newParams });
return processResponse(responseData);
};
/**
* 获取猪只迁移日志列表
* @param {PigTransferLogsParams} params - 查询参数
* @returns {Promise<{list: Array<PigTransferLogDTO>, total: number}>}
*/
export const getPigTransferLogs = async (params) => {
const newParams = { ...params, page_size: params.page_size };
const responseData = await http.get('/api/v1/monitor/pig-transfer-logs', { params: newParams });
return processResponse(responseData);
};
/**
* 获取计划执行日志列表
* @param {PlanExecutionLogsParams} params - 查询参数
* @returns {Promise<{list: Array<PlanExecutionLogDTO>, total: number}>}
*/
export const getPlanExecutionLogs = async (params) => {
const newParams = { ...params, page_size: params.page_size };
const responseData = await http.get('/api/v1/monitor/plan-execution-logs', { params: newParams });
return processResponse(responseData);
};
/**
* 获取原料采购记录列表
* @param {RawMaterialPurchasesParams} params - 查询参数
* @returns {Promise<{list: Array<RawMaterialPurchaseDTO>, total: number}>}
*/
export const getRawMaterialPurchases = async (params) => {
const newParams = { ...params, page_size: params.page_size };
const responseData = await http.get('/api/v1/monitor/raw-material-purchases', { params: newParams });
return processResponse(responseData);
};
/**
* 获取原料库存日志列表
* @param {RawMaterialStockLogsParams} params - 查询参数
* @returns {Promise<{list: Array<RawMaterialStockLogDTO>, total: number}>}
*/
export const getRawMaterialStockLogs = async (params) => {
const newParams = { ...params, page_size: params.page_size };
const responseData = await http.get('/api/v1/monitor/raw-material-stock-logs', { params: newParams });
return processResponse(responseData);
};
/**
* 获取传感器数据列表
* @param {SensorDataParams} params - 查询参数
* @returns {Promise<{list: Array<SensorDataDTO>, total: number}>}
*/
export const getSensorData = async (params) => {
const newParams = { ...params, page_size: params.page_size };
const responseData = await http.get('/api/v1/monitor/sensor-data', { params: newParams });
return processResponse(responseData);
};
/**
* 获取任务执行日志列表
* @param {TaskExecutionLogsParams} params - 查询参数
* @returns {Promise<{list: Array<TaskExecutionLogDTO>, total: number}>}
*/
export const getTaskExecutionLogs = async (params) => {
const newParams = { ...params, page_size: params.page_size };
const responseData = await http.get('/api/v1/monitor/task-execution-logs', { params: newParams });
return processResponse(responseData);
};
/**
* 获取用户操作日志列表
* @param {UserActionLogsParams} params - 查询参数
* @returns {Promise<{list: Array<UserActionLogDTO>, total: number}>}
*/
export const getUserActionLogs = async (params) => {
const newParams = { ...params, page_size: params.page_size };
const responseData = await http.get('/api/v1/monitor/user-action-logs', { params: newParams });
return processResponse(responseData);
};
/**
* 获取批次称重记录列表
* @param {WeighingBatchesParams} params - 查询参数
* @returns {Promise<{list: Array<WeighingBatchDTO>, total: number}>}
*/
export const getWeighingBatches = async (params) => {
const newParams = { ...params, page_size: params.page_size };
const responseData = await http.get('/api/v1/monitor/weighing-batches', { params: newParams });
return processResponse(responseData);
};
/**
* 获取单次称重记录列表
* @param {WeighingRecordsParams} params - 查询参数
* @returns {Promise<{list: Array<WeighingRecordDTO>, total: number}>}
*/
export const getWeighingRecords = async (params) => {
const newParams = { ...params, page_size: params.page_size };
const responseData = await http.get('/api/v1/monitor/weighing-records', { params: newParams });
return processResponse(responseData);
};
export const MonitorApi = {
getDeviceCommandLogs,
getFeedUsageRecords,
getMedicationLogs,
getNotifications,
getPendingCollections,
getPigBatchLogs,
getPigPurchases,
getPigSales,
getPigSickLogs,
getPigTransferLogs,
getPlanExecutionLogs,
getRawMaterialPurchases,
getRawMaterialStockLogs,
getSensorData,
getTaskExecutionLogs,
getUserActionLogs,
getWeighingBatches,
getWeighingRecords,
};

103
src/api/pen.js Normal file
View File

@@ -0,0 +1,103 @@
import http from '../utils/http';
/**
* @typedef {object} Response
* @property {number} code - 业务状态码
* @property {object} [data] - 业务数据
* @property {string} [message] - 提示信息
*/
/**
* @typedef {object} PenResponse
* @property {number} id
* @property {number} house_id
* @property {string} pen_number
* @property {number} capacity
* @property {number} current_pig_count
* @property {number} pig_batch_id
* @property {('空闲'|'使用中'|'病猪栏'|'康复栏'|'清洗消毒'|'维修中')} status
*/
/**
* @typedef {object} CreatePenRequest
* @property {number} house_id
* @property {string} pen_number
* @property {number} capacity
*/
/**
* @typedef {object} UpdatePenRequest
* @property {number} house_id
* @property {string} pen_number
* @property {number} capacity
* @property {('空闲'|'使用中'|'病猪栏'|'康复栏'|'清洗消毒'|'维修中')} status
*/
/**
* @typedef {object} UpdatePenStatusRequest
* @property {('空闲'|'使用中'|'病猪栏'|'康复栏'|'清洗消毒'|'维修中')} status
*/
/**
* 获取所有猪栏的列表
* @returns {Promise<Array<PenResponse>>}
*/
export const getPens = () => {
return http.get('/api/v1/pens');
};
/**
* 创建一个新的猪栏
* @param {CreatePenRequest} penData - 猪栏信息
* @returns {Promise<PenResponse>}
*/
export const createPen = (penData) => {
return http.post('/api/v1/pens', penData);
};
/**
* 根据ID获取单个猪栏信息
* @param {number} id - 猪栏ID
* @returns {Promise<PenResponse>}
*/
export const getPenById = (id) => {
return http.get(`/api/v1/pens/${id}`);
};
/**
* 更新一个已存在的猪栏信息
* @param {number} id - 猪栏ID
* @param {UpdatePenRequest} penData - 猪栏信息
* @returns {Promise<PenResponse>}
*/
export const updatePen = (id, penData) => {
return http.put(`/api/v1/pens/${id}`, penData);
};
/**
* 根据ID删除一个猪栏
* @param {number} id - 猪栏ID
* @returns {Promise<Response>}
*/
export const deletePen = (id) => {
return http.delete(`/api/v1/pens/${id}`);
};
/**
* 更新指定猪栏的当前状态
* @param {number} id - 猪栏ID
* @param {UpdatePenStatusRequest} statusData - 新的猪栏状态
* @returns {Promise<PenResponse>}
*/
export const updatePenStatus = (id, statusData) => {
return http.put(`/api/v1/pens/${id}/status`, statusData);
};
export const PenApi = {
getPens,
createPen,
getPenById,
updatePen,
deletePen,
updatePenStatus,
};

424
src/api/pigBatch.js Normal file
View File

@@ -0,0 +1,424 @@
import http from '../utils/http';
// --- Typedefs ---
/**
* @typedef {object} Response
* @property {number} code - 业务状态码
* @property {object} [data] - 业务数据
* @property {string} [message] - 提示信息
*/
/**
* @typedef {('自繁'|'外购')} PigBatchOriginType
*/
/**
* @typedef {('保育'|'生长'|'育肥'|'待售'|'已出售'|'已归档')} PigBatchStatus
*/
/**
* @typedef {object} PigBatchResponseDTO
* @property {number} id - 批次ID
* @property {string} batch_number - 批次编号
* @property {PigBatchOriginType} origin_type - 批次来源
* @property {string} [start_date] - 批次开始日期
* @property {string} [end_date] - 批次结束日期
* @property {number} initial_count - 初始数量
* @property {PigBatchStatus} status - 批次状态
* @property {boolean} is_active - 是否活跃
* @property {string} create_time - 创建时间
* @property {string} update_time - 更新时间
* @property {number} current_total_quantity - 当前总数
* @property {number} current_total_pigs_in_pens - 当前存栏总数
*/
/**
* @typedef {object} PigBatchesParams
* @property {boolean} [is_active] - 是否活跃 (true/false)
*/
/**
* @typedef {object} PigBatchCreateDTO
* @property {string} batch_number - 批次编号,必填
* @property {PigBatchOriginType} origin_type - 批次来源,必填
* @property {string} start_date - 批次开始日期,必填
* @property {number} initial_count - 初始数量必填最小为1
* @property {PigBatchStatus} status - 批次状态,必填
*/
/**
* @typedef {object} PigBatchUpdateDTO
* @property {string} [batch_number] - 批次编号,可选
* @property {PigBatchOriginType} [origin_type] - 批次来源,可选
* @property {string} [start_date] - 批次开始日期,可选
* @property {string} [end_date] - 批次结束日期,可选
* @property {number} [initial_count] - 初始数量,可选
* @property {PigBatchStatus} [status] - 批次状态,可选
*/
/**
* @typedef {object} AssignEmptyPensToBatchRequest
* @property {Array<number>} pen_ids - 待分配的猪栏ID列表
*/
/**
* @typedef {object} BuyPigsRequest
* @property {number} pen_id - 猪栏ID
* @property {number} quantity - 买入猪只数量
* @property {number} unit_price - 单价
* @property {number} total_price - 总价
* @property {string} trader_name - 交易方名称
* @property {string} trade_date - 交易日期
* @property {string} [remarks] - 备注
*/
/**
* @typedef {object} SellPigsRequest
* @property {number} pen_id - 猪栏ID
* @property {number} quantity - 卖出猪只数量
* @property {number} unit_price - 单价
* @property {number} total_price - 总价
* @property {string} trader_name - 交易方名称
* @property {string} trade_date - 交易日期
* @property {string} [remarks] - 备注
*/
/**
* @typedef {object} MovePigsIntoPenRequest
* @property {number} to_pen_id - 目标猪栏ID
* @property {number} quantity - 移入猪只数量
* @property {string} [remarks] - 备注
*/
/**
* @typedef {object} TransferPigsWithinBatchRequest
* @property {number} from_pen_id - 源猪栏ID
* @property {number} to_pen_id - 目标猪栏ID
* @property {number} quantity - 调栏猪只数量
* @property {string} [remarks] - 备注
*/
/**
* @typedef {object} TransferPigsAcrossBatchesRequest
* @property {number} dest_batch_id - 目标猪批次ID
* @property {number} from_pen_id - 源猪栏ID
* @property {number} to_pen_id - 目标猪栏ID
* @property {number} quantity - 调栏猪只数量
* @property {string} [remarks] - 备注
*/
/**
* @typedef {object} ReclassifyPenToNewBatchRequest
* @property {number} pen_id - 待划拨的猪栏ID
* @property {number} to_batch_id - 目标猪批次ID
* @property {string} [remarks] - 备注
*/
/**
* @typedef {('原地治疗'|'病猪栏治疗')} PigBatchSickPigTreatmentLocation
*/
/**
* @typedef {object} RecordSickPigsRequest
* @property {number} pen_id - 猪栏ID
* @property {number} quantity - 病猪数量
* @property {PigBatchSickPigTreatmentLocation} treatment_location - 治疗地点
* @property {string} happened_at - 发生时间
* @property {string} [remarks] - 备注
*/
/**
* @typedef {object} RecordSickPigRecoveryRequest
* @property {number} pen_id - 猪栏ID
* @property {number} quantity - 康复猪数量
* @property {PigBatchSickPigTreatmentLocation} treatment_location - 治疗地点
* @property {string} happened_at - 发生时间
* @property {string} [remarks] - 备注
*/
/**
* @typedef {object} RecordSickPigDeathRequest
* @property {number} pen_id - 猪栏ID
* @property {number} quantity - 死亡猪数量
* @property {PigBatchSickPigTreatmentLocation} treatment_location - 治疗地点
* @property {string} happened_at - 发生时间
* @property {string} [remarks] - 备注
*/
/**
* @typedef {object} RecordSickPigCullRequest
* @property {number} pen_id - 猪栏ID
* @property {number} quantity - 淘汰猪数量
* @property {PigBatchSickPigTreatmentLocation} treatment_location - 治疗地点
* @property {string} happened_at - 发生时间
* @property {string} [remarks] - 备注
*/
/**
* @typedef {object} RecordDeathRequest
* @property {number} pen_id - 猪栏ID
* @property {number} quantity - 死亡猪数量
* @property {string} happened_at - 发生时间
* @property {string} [remarks] - 备注
*/
/**
* @typedef {object} RecordCullRequest
* @property {number} pen_id - 猪栏ID
* @property {number} quantity - 淘汰猪数量
* @property {string} happened_at - 发生时间
* @property {string} [remarks] - 备注
*/
/**
* @typedef {object} PenResponse
* @property {number} id
* @property {number} house_id
* @property {string} pen_number
* @property {number} capacity
* @property {number} current_pig_count
* @property {number} pig_batch_id
* @property {('空闲'|'使用中'|'病猪栏'|'康复栏'|'清洗消毒'|'维修中')} status
*/
/**
* @typedef {object} PigHouseResponse
* @property {number} id
* @property {string} name
* @property {string} description
*/
// --- 猪批次基础操作 ---
/**
* 获取所有猪批次的列表
* @param {PigBatchesParams} params - 查询参数
* @returns {Promise<Array<PigBatchResponseDTO>>}
*/
export const getPigBatches = (params) => {
return http.get('/api/v1/pig-batches', params);
};
/**
* 创建一个新的猪批次
* @param {PigBatchCreateDTO} batchData - 猪批次信息
* @returns {Promise<PigBatchResponseDTO>}
*/
export const createPigBatch = (batchData) => {
return http.post('/api/v1/pig-batches', batchData);
};
/**
* 根据ID获取单个猪批次信息
* @param {number} id - 猪批次ID
* @returns {Promise<PigBatchResponseDTO>}
*/
export const getPigBatchById = (id) => {
return http.get(`/api/v1/pig-batches/${id}`);
};
/**
* 更新一个已存在的猪批次信息
* @param {number} id - 猪批次ID
* @param {PigBatchUpdateDTO} batchData - 猪批次信息
* @returns {Promise<PigBatchResponseDTO>}
*/
export const updatePigBatch = (id, batchData) => {
return http.put(`/api/v1/pig-batches/${id}`, batchData);
};
/**
* 根据ID删除一个猪批次
* @param {number} id - 猪批次ID
* @returns {Promise<Response>}
*/
export const deletePigBatch = (id) => {
return http.delete(`/api/v1/pig-batches/${id}`);
};
// --- 猪批次业务操作 ---
/**
* 为猪批次分配空栏
* @param {number} id - 猪批次ID
* @param {AssignEmptyPensToBatchRequest} pensData - 待分配的猪栏ID列表
* @returns {Promise<Response>}
*/
export const assignPensToBatch = (id, pensData) => {
return http.post(`/api/v1/pig-batches/assign-pens/${id}`, pensData);
};
/**
* 从猪批次移除空栏
* @param {number} penID - 待移除的猪栏ID
* @param {number} batchID - 猪批次ID
* @returns {Promise<Response>}
*/
export const removePenFromBatch = (penID, batchID) => {
return http.delete(`/api/v1/pig-batches/remove-pen/${penID}/${batchID}`);
};
/**
* 处理买猪的业务逻辑
* @param {number} id - 猪批次ID
* @param {BuyPigsRequest} buyData - 买猪请求信息
* @returns {Promise<Response>}
*/
export const buyPigsForBatch = (id, buyData) => {
return http.post(`/api/v1/pig-batches/buy-pigs/${id}`, buyData);
};
/**
* 处理卖猪的业务逻辑
* @param {number} id - 猪批次ID
* @param {SellPigsRequest} sellData - 卖猪请求信息
* @returns {Promise<Response>}
*/
export const sellPigsFromBatch = (id, sellData) => {
return http.post(`/api/v1/pig-batches/sell-pigs/${id}`, sellData);
};
/**
* 将猪只从“虚拟库存”移入指定猪栏
* @param {number} id - 猪批次ID
* @param {MovePigsIntoPenRequest} moveData - 移入猪只请求信息
* @returns {Promise<Response>}
*/
export const movePigsIntoPen = (id, moveData) => {
return http.post(`/api/v1/pig-batches/move-pigs-into-pen/${id}`, moveData);
};
/**
* 群内调栏
* @param {number} id - 猪批次ID
* @param {TransferPigsWithinBatchRequest} transferData - 群内调栏请求信息
* @returns {Promise<Response>}
*/
export const transferPigsWithinBatch = (id, transferData) => {
return http.post(`/api/v1/pig-batches/transfer-within-batch/${id}`, transferData);
};
/**
* 跨猪群调栏
* @param {number} sourceBatchID - 源猪批次ID
* @param {TransferPigsAcrossBatchesRequest} transferData - 跨群调栏请求信息
* @returns {Promise<Response>}
*/
export const transferPigsAcrossBatches = (sourceBatchID, transferData) => {
return http.post(`/api/v1/pig-batches/transfer-across-batches/${sourceBatchID}`, transferData);
};
/**
* 将猪栏划拨到新批次
* @param {number} fromBatchID - 源猪批次ID
* @param {ReclassifyPenToNewBatchRequest} reclassifyData - 划拨请求信息
* @returns {Promise<Response>}
*/
export const reclassifyPenToNewBatch = (fromBatchID, reclassifyData) => {
return http.post(`/api/v1/pig-batches/reclassify-pen/${fromBatchID}`, reclassifyData);
};
// --- 猪只数量变更记录 ---
/**
* 记录新增病猪事件
* @param {number} id - 猪批次ID
* @param {RecordSickPigsRequest} sickData - 记录病猪请求信息
* @returns {Promise<Response>}
*/
export const recordSickPigsInBatch = (id, sickData) => {
return http.post(`/api/v1/pig-batches/record-sick-pigs/${id}`, sickData);
};
/**
* 记录病猪康复事件
* @param {number} id - 猪批次ID
* @param {RecordSickPigRecoveryRequest} recoveryData - 记录病猪康复请求信息
* @returns {Promise<Response>}
*/
export const recordSickPigRecoveryInBatch = (id, recoveryData) => {
return http.post(`/api/v1/pig-batches/record-sick-pig-recovery/${id}`, recoveryData);
};
/**
* 记录病猪死亡事件
* @param {number} id - 猪批次ID
* @param {RecordSickPigDeathRequest} deathData - 记录病猪死亡请求信息
* @returns {Promise<Response>}
*/
export const recordSickPigDeathInBatch = (id, deathData) => {
return http.post(`/api/v1/pig-batches/record-sick-pig-death/${id}`, deathData);
};
/**
* 记录病猪淘汰事件
* @param {number} id - 猪批次ID
* @param {RecordSickPigCullRequest} cullData - 记录病猪淘汰请求信息
* @returns {Promise<Response>}
*/
export const recordSickPigCullInBatch = (id, cullData) => {
return http.post(`/api/v1/pig-batches/record-sick-pig-cull/${id}`, cullData);
};
/**
* 记录正常猪只死亡事件
* @param {number} id - 猪批次ID
* @param {RecordDeathRequest} deathData - 记录正常猪只死亡请求信息
* @returns {Promise<Response>}
*/
export const recordDeathInBatch = (id, deathData) => {
return http.post(`/api/v1/pig-batches/record-death/${id}`, deathData);
};
/**
* 记录正常猪只淘汰事件
* @param {number} id - 猪批次ID
* @param {RecordCullRequest} cullData - 记录正常猪只淘汰请求信息
* @returns {Promise<Response>}
*/
export const recordCullInBatch = (id, cullData) => {
return http.post(`/api/v1/pig-batches/record-cull/${id}`, cullData);
};
// --- 新增的猪栏和猪舍API ---
/**
* 获取所有猪栏的列表
* @returns {Promise<Array<PenResponse>>}
*/
export const getAllPens = () => {
return http.get('/api/v1/pens');
};
/**
* 获取所有猪舍的列表
* @returns {Promise<Array<PigHouseResponse>>}
*/
export const getAllPigHouses = () => {
return http.get('/api/v1/pig-houses');
};
export const PigBatchApi = {
getPigBatches,
createPigBatch,
getPigBatchById,
updatePigBatch,
deletePigBatch,
assignPensToBatch,
removePenFromBatch,
buyPigsForBatch,
sellPigsFromBatch,
movePigsIntoPen,
transferPigsWithinBatch,
transferPigsAcrossBatches,
reclassifyPenToNewBatch,
recordSickPigsInBatch,
recordSickPigRecoveryInBatch,
recordSickPigDeathInBatch,
recordSickPigCullInBatch,
recordDeathInBatch,
recordCullInBatch,
getAllPens,
getAllPigHouses,
};

80
src/api/pigHouse.js Normal file
View File

@@ -0,0 +1,80 @@
import http from '../utils/http';
/**
* @typedef {object} Response
* @property {number} code - 业务状态码
* @property {object} [data] - 业务数据
* @property {string} [message] - 提示信息
*/
/**
* @typedef {object} PigHouseResponse
* @property {number} id
* @property {string} name
* @property {string} description
*/
/**
* @typedef {object} CreatePigHouseRequest
* @property {string} name
* @property {string} [description]
*/
/**
* @typedef {object} UpdatePigHouseRequest
* @property {string} name
* @property {string} [description]
*/
/**
* 获取所有猪舍的列表
* @returns {Promise<Array<PigHouseResponse>>}
*/
export const getPigHouses = () => {
return http.get('/api/v1/pig-houses');
};
/**
* 创建一个新的猪舍
* @param {CreatePigHouseRequest} pigHouseData - 猪舍信息
* @returns {Promise<PigHouseResponse>}
*/
export const createPigHouse = (pigHouseData) => {
return http.post('/api/v1/pig-houses', pigHouseData);
};
/**
* 根据ID获取单个猪舍信息
* @param {number} id - 猪舍ID
* @returns {Promise<PigHouseResponse>}
*/
export const getPigHouseById = (id) => {
return http.get(`/api/v1/pig-houses/${id}`);
};
/**
* 更新一个已存在的猪舍信息
* @param {number} id - 猪舍ID
* @param {UpdatePigHouseRequest} pigHouseData - 猪舍信息
* @returns {Promise<PigHouseResponse>}
*/
export const updatePigHouse = (id, pigHouseData) => {
return http.put(`/api/v1/pig-houses/${id}`, pigHouseData);
};
/**
* 根据ID删除一个猪舍
* @param {number} id - 猪舍ID
* @returns {Promise<Response>}
*/
export const deletePigHouse = (id) => {
return http.delete(`/api/v1/pig-houses/${id}`);
};
export const PigHouseApi = {
getPigHouses,
createPigHouse,
getPigHouseById,
updatePigHouse,
deletePigHouse,
};

View File

@@ -1,71 +1,196 @@
import http from '../utils/http.js';
import http from '../utils/http';
/**
* 计划管理API
* @typedef {('计划分析'|'等待'|'下料'|'全量采集')} TaskType
*/
export class PlanApi {
/**
* 获取计划列表
* @returns {Promise} 计划列表
*/
static list() {
return http.get('/api/v1/plans');
}
/**
* 创建新计划
* @param {Object} planData 计划数据
* @returns {Promise} 创建结果
*/
static create(planData) {
return http.post('/api/v1/plans', planData);
}
/**
* @typedef {object} TaskRequest
* @property {string} [name]
* @property {string} [description]
* @property {TaskType} [type]
* @property {object} [parameters]
* @property {number} [execution_order]
*/
/**
* 获取计划详情
* @param {string|number} id 计划ID
* @returns {Promise} 计划详情
*/
static get(id) {
return http.get(`/api/v1/plans/${id}`);
}
/**
* @typedef {('自动'|'手动')} PlanExecutionType
*/
/**
* 更新计划信息
* @param {string|number} id 计划ID
* @param {Object} planData 计划数据
* @returns {Promise} 更新结果
*/
static update(id, planData) {
return http.put(`/api/v1/plans/${id}`, planData);
}
/**
* @typedef {object} CreatePlanRequest
* @property {string} name
* @property {string} [description]
* @property {PlanExecutionType} execution_type
* @property {string} [cron_expression]
* @property {number} [execute_num]
* @property {Array<TaskRequest>} [tasks]
* @property {Array<number>} [sub_plan_ids]
*/
/**
* 删除计划
* @param {string|number} id 计划ID
* @returns {Promise} 删除结果
*/
static delete(id) {
return http.delete(`/api/v1/plans/${id}`);
}
/**
* @typedef {object} UpdatePlanRequest
* @property {string} [name]
* @property {string} [description]
* @property {PlanExecutionType} execution_type
* @property {string} [cron_expression]
* @property {number} [execute_num]
* @property {Array<TaskRequest>} [tasks]
* @property {Array<number>} [sub_plan_ids]
*/
/**
* 启动计划
* @param {string|number} id 计划ID
* @returns {Promise} 启动结果
*/
static start(id) {
return http.post(`/api/v1/plans/${id}/start`);
}
/**
* @typedef {object} TaskResponse
* @property {number} id
* @property {number} plan_id
* @property {string} name
* @property {string} description
* @property {TaskType} type
* @property {object} parameters
* @property {number} execution_order
*/
/**
* 停止计划
* @param {string|number} id 计划ID
* @returns {Promise} 停止结果
*/
static stop(id) {
return http.post(`/api/v1/plans/${id}/stop`);
}
}
/**
* @typedef {object} SubPlanResponse
* @property {number} id
* @property {number} parent_plan_id
* @property {number} child_plan_id
* @property {number} execution_order
* @property {PlanResponse} child_plan
*/
export default PlanApi;
/**
* @typedef {('已禁用'|'已启用'|'执行完毕'|'执行失败')} PlanStatus
*/
/**
* @typedef {('子计划'|'任务')} PlanContentType
*/
/**
* @typedef {('自定义任务'|'系统任务')} PlanType
*/
/**
* @typedef {object} PlanResponse
* @property {number} id
* @property {string} name
* @property {string} description
* @property {PlanExecutionType} execution_type
* @property {string} cron_expression
* @property {number} execute_num
* @property {number} execute_count
* @property {PlanStatus} status
* @property {PlanContentType} content_type
* @property {PlanType} plan_type
* @property {Array<TaskResponse>} tasks
* @property {Array<SubPlanResponse>} sub_plans
*/
/**
* @typedef {object} ListPlansResponse
* @property {Array<PlanResponse>} plans
* @property {number} total
*/
/**
* @typedef {object} PlanExecutionLogDTO
* @property {string} created_at
* @property {string} ended_at
* @property {string} error
* @property {number} id
* @property {number} plan_id
* @property {string} plan_name
* @property {string} started_at
* @property {string} status
* @property {string} updated_at
*/
/**
* @typedef {object} Response
* @property {number} code - 业务状态码
* @property {object} [data] - 业务数据
* @property {string} [message] - 提示信息
*/
/**
* 获取所有计划的列表
* @param {object} params - 查询参数
* @param {number} [params.page] - 页码
* @param {number} [params.page_size] - 每页大小
* @param {('所有任务'|'自定义任务'|'系统任务')} [params.plan_type] - 计划类型
* @returns {Promise<ListPlansResponse>}
*/
const getPlans = (params) => {
const newParams = {
page: params.page,
page_size: params.page_size,
plan_type: params.plan_type,
};
return http.get('/api/v1/plans', { params: newParams });
};
/**
* 创建一个新的计划
* @param {CreatePlanRequest} planData - 计划信息
* @returns {Promise<PlanResponse>}
*/
const createPlan = (planData) => {
return http.post('/api/v1/plans', planData);
};
/**
* 根据计划ID获取单个计划的详细信息
* @param {number} id - 计划ID
* @returns {Promise<PlanResponse>}
*/
const getPlanById = (id) => {
return http.get(`/api/v1/plans/${id}`);
};
/**
* 根据计划ID更新计划的详细信息。系统计划不允许修改。
* @param {number} id - 计划ID
* @param {UpdatePlanRequest} planData - 更新后的计划信息
* @returns {Promise<PlanResponse>}
*/
const updatePlan = (id, planData) => {
return http.put(`/api/v1/plans/${id}`, planData);
};
/**
* 根据计划ID删除计划。软删除系统计划不允许删除。
* @param {number} id - 计划ID
* @returns {Promise<Response>}
*/
const deletePlan = (id) => {
return http.delete(`/api/v1/plans/${id}`);
};
/**
* 根据计划ID启动一个计划的执行。系统计划不允许手动启动。
* @param {number} id - 计划ID
* @returns {Promise<Response>}
*/
const startPlan = (id) => {
return http.post(`/api/v1/plans/${id}/start`);
};
/**
* 根据计划ID停止一个正在执行的计划。系统计划不能被停止。
* @param {number} id - 计划ID
* @returns {Promise<Response>}
*/
const stopPlan = (id) => {
return http.post(`/api/v1/plans/${id}/stop`);
};
export const PlanApi = {
getPlans,
createPlan,
getPlanById,
updatePlan,
deletePlan,
startPlan,
stopPlan,
};

View File

@@ -1,26 +1,143 @@
import http from '../utils/http.js';
import http from '../utils/http';
/**
* 用户管理API
* @typedef {object} CreateUserRequest
* @property {string} username
* @property {string} password
*/
export class UserApi {
/**
* 创建新用户
* @param {Object} userData 用户数据
* @returns {Promise} 创建结果
*/
static create(userData) {
return http.post('/api/v1/users', userData);
}
/**
* 用户登录
* @param {Object} credentials 登录凭证 {username, password}
* @returns {Promise} 登录结果
*/
static login(credentials) {
return http.post('/api/v1/users/login', credentials);
}
}
/**
* @typedef {object} CreateUserResponse
* @property {number} id
* @property {string} username
*/
export default UserApi;
/**
* @typedef {object} LoginRequest
* @property {string} identifier - Identifier 可以是用户名、邮箱、手机号、微信号或飞书账号
* @property {string} password
*/
/**
* @typedef {object} LoginResponse
* @property {number} id
* @property {string} username
* @property {string} token
*/
/**
* @typedef {('成功'|'失败')} AuditStatus
*/
/**
* @typedef {object} UserActionLogDTO
* @property {number} id
* @property {number} user_id
* @property {string} username
* @property {string} action_type
* @property {string} description
* @property {string} http_method
* @property {string} http_path
* @property {string} source_ip
* @property {Array<number>} target_resource
* @property {AuditStatus} status
* @property {string} result_details
* @property {string} time
*/
/**
* @typedef {object} PaginationDTO
* @property {number} page
* @property {number} page_size
* @property {number} total
*/
/**
* @typedef {object} ListUserActionLogResponse
* @property {Array<UserActionLogDTO>} list
* @property {PaginationDTO} pagination
*/
/**
* @typedef {object} UserHistoryParams
* @property {string} [action_type]
* @property {string} [end_time]
* @property {string} [order_by]
* @property {number} [page]
* @property {number} [page_size]
* @property {string} [start_time]
* @property {string} [status]
* @property {number} [user_id]
* @property {string} [username]
*/
/**
* @typedef {('邮件'|'企业微信'|'飞书'|'日志')} NotifierType
*/
/**
* @typedef {object} SendTestNotificationRequest
* @property {NotifierType} type - Type 指定要测试的通知渠道
*/
/**
* @typedef {object} Response
* @property {number} code - 业务状态码
* @property {object} [data] - 业务数据
* @property {string} [message] - 提示信息
*/
/**
* 创建一个新用户
* @param {CreateUserRequest} userData - 用户信息
* @returns {Promise<CreateUserResponse>}
*/
const createUser = (userData) => {
return http.post('/api/v1/users', userData);
};
/**
* 用户登录
* @param {LoginRequest} credentials - 登录凭证
* @returns {Promise<LoginResponse>}
*/
const login = (credentials) => {
return http.post('/api/v1/users/login', credentials);
};
/**
* 获取用户操作日志列表
* @param {UserHistoryParams} params - 查询参数
* @returns {Promise<ListUserActionLogResponse>}
*/
const getUserActionLogs = (params) => {
const newParams = {
action_type: params.action_type,
end_time: params.end_time,
order_by: params.order_by,
page: params.page,
page_size: params.page_size,
start_time: params.start_time,
status: params.status,
user_id: params.user_id,
username: params.username,
};
return http.get('/api/v1/monitor/user-action-logs', { params: newParams });
};
/**
* 发送测试通知
* @param {number} id - 用户ID
* @param {SendTestNotificationRequest} data - 请求体
* @returns {Promise<Response>}
*/
const sendTestNotification = (id, data) => {
return http.post(`/api/v1/users/${id}/notifications/test`, data);
};
export const UserApi = {
createUser,
login,
getUserActionLogs,
sendTestNotification,
};

View File

@@ -0,0 +1,80 @@
<template>
<el-dialog
title="分配猪只"
:model-value="visible"
@update:model-value="$emit('update:visible', $event)"
width="30%"
@close="resetForm"
>
<el-form ref="form" :model="form" :rules="rules" label-width="100px">
<el-form-item label="未分配数量">
<span>{{ unassignedPigCount }}</span>
</el-form-item>
<el-form-item label="分配数量" prop="quantity">
<el-input-number v-model="form.quantity" :min="1" :max="unassignedPigCount" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="$emit('update:visible', false)"> </el-button>
<el-button type="primary" @click="handleConfirm"> </el-button>
</span>
</template>
</el-dialog>
</template>
<script>
export default {
name: 'AllocatePigsDialog',
props: {
visible: {
type: Boolean,
required: true
},
unassignedPigCount: {
type: Number,
required: true
},
penId: {
type: Number,
required: true
}
},
emits: ['update:visible', 'confirm'],
data() {
return {
form: {
quantity: 1
},
rules: {
quantity: [
{ required: true, message: '请输入分配数量', trigger: 'blur' },
{ type: 'integer', message: '请输入整数', trigger: 'blur' },
{ validator: this.validateQuantity, trigger: 'blur' }
]
}
};
},
methods: {
validateQuantity(rule, value, callback) {
if (value > this.unassignedPigCount) {
callback(new Error('分配数量不能超过未分配数量'));
} else {
callback();
}
},
handleConfirm() {
this.$refs.form.validate(valid => {
if (valid) {
this.$emit('confirm', { penId: this.penId, quantity: this.form.quantity });
this.$emit('update:visible', false);
}
});
},
resetForm() {
this.$refs.form.resetFields();
this.form.quantity = 1;
}
}
};
</script>

View File

@@ -0,0 +1,330 @@
<template>
<div class="cron-expression-editor">
<el-input
v-model="cronExpression"
placeholder="请输入标准Unix 5位cron表达式如: 0 0 * * *"
readonly
>
<template #append>
<el-button @click="openCronDialog">可视化配置</el-button>
</template>
</el-input>
<el-dialog
v-model="showCronDialog"
title="可视化配置Cron表达式"
width="600px"
:before-close="handleCronDialogClose"
>
<div class="cron-dialog-content">
<el-form :model="cronConfig" label-width="100px">
<!-- -->
<el-form-item label="分">
<el-select
v-model="cronConfig.minute"
placeholder="请选择分钟"
clearable
filterable
allow-create
default-first-option
>
<el-option label="*" value="*">
<span>*</span>
<span class="option-desc">每分钟</span>
</el-option>
<el-option label="*/5" value="*/5">
<span>*/5</span>
<span class="option-desc">每隔5分钟</span>
</el-option>
<el-option label="*/10" value="*/10">
<span>*/10</span>
<span class="option-desc">每隔10分钟</span>
</el-option>
<el-option label="*/15" value="*/15">
<span>*/15</span>
<span class="option-desc">每隔15分钟</span>
</el-option>
<el-option label="*/30" value="*/30">
<span>*/30</span>
<span class="option-desc">每隔30分钟</span>
</el-option>
<el-option
v-for="minute in 60"
:key="minute-1"
:label="minute-1"
:value="(minute-1).toString()"
>
<span>{{ minute-1 }}</span>
<span class="option-desc">{{ minute-1 }}分钟</span>
</el-option>
</el-select>
</el-form-item>
<!-- -->
<el-form-item label="时">
<el-select
v-model="cronConfig.hour"
placeholder="请选择小时"
clearable
filterable
allow-create
default-first-option
>
<el-option label="*" value="*">
<span>*</span>
<span class="option-desc">每小时</span>
</el-option>
<el-option label="*/2" value="*/2">
<span>*/2</span>
<span class="option-desc">每隔2小时</span>
</el-option>
<el-option label="*/3" value="*/3">
<span>*/3</span>
<span class="option-desc">每隔3小时</span>
</el-option>
<el-option label="*/6" value="*/6">
<span>*/6</span>
<span class="option-desc">每隔6小时</span>
</el-option>
<el-option label="*/12" value="*/12">
<span>*/12</span>
<span class="option-desc">每隔12小时</span>
</el-option>
<el-option
v-for="hour in 24"
:key="hour-1"
:label="hour-1"
:value="(hour-1).toString()"
>
<span>{{ hour-1 }}</span>
<span class="option-desc">{{ hour-1 }}</span>
</el-option>
</el-select>
</el-form-item>
<!-- -->
<el-form-item label="日">
<el-select
v-model="cronConfig.day"
placeholder="请选择日期"
clearable
filterable
allow-create
default-first-option
>
<el-option label="*" value="*">
<span>*</span>
<span class="option-desc">每天</span>
</el-option>
<el-option label="*/2" value="*/2">
<span>*/2</span>
<span class="option-desc">每隔2天</span>
</el-option>
<el-option label="*/7" value="*/7">
<span>*/7</span>
<span class="option-desc">每隔7天</span>
</el-option>
<el-option label="*/15" value="*/15">
<span>*/15</span>
<span class="option-desc">每隔15天</span>
</el-option>
<el-option
v-for="day in 31"
:key="day"
:label="day"
:value="day.toString()"
>
<span>{{ day }}</span>
<span class="option-desc">每月{{ day }}</span>
</el-option>
</el-select>
</el-form-item>
<!-- -->
<el-form-item label="月">
<el-select
v-model="cronConfig.month"
placeholder="请选择月份"
clearable
filterable
allow-create
default-first-option
>
<el-option label="*" value="*">
<span>*</span>
<span class="option-desc">每月</span>
</el-option>
<el-option label="*/2" value="*/2">
<span>*/2</span>
<span class="option-desc">每隔2个月</span>
</el-option>
<el-option label="*/3" value="*/3">
<span>*/3</span>
<span class="option-desc">每隔3个月</span>
</el-option>
<el-option label="*/6" value="*/6">
<span>*/6</span>
<span class="option-desc">每隔6个月</span>
</el-option>
<el-option
v-for="month in 12"
:key="month"
:label="month"
:value="month.toString()"
>
<span>{{ month }}</span>
<span class="option-desc">{{ month }}</span>
</el-option>
</el-select>
</el-form-item>
<!-- -->
<el-form-item label="周">
<el-select
v-model="cronConfig.week"
placeholder="请选择星期"
clearable
filterable
allow-create
default-first-option
>
<el-option label="*" value="*">
<span>*</span>
<span class="option-desc">每周</span>
</el-option>
<el-option label="1-5" value="1-5">
<span>1-5</span>
<span class="option-desc">工作日</span>
</el-option>
<el-option
v-for="(week, index) in weekOptions"
:key="index"
:label="week.label"
:value="index.toString()"
>
<span>{{ week.label }}</span>
<span class="option-desc">{{ week.desc }}</span>
</el-option>
</el-select>
</el-form-item>
</el-form>
</div>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleCronDialogClose">取消</el-button>
<el-button type="primary" @click="confirmCronExpression">确认</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<script>
import { ref, computed, watch, reactive } from 'vue'
export default {
name: 'CronExpressionEditor',
props: {
modelValue: {
type: String,
default: ''
}
},
emits: ['update:modelValue'],
setup(props, { emit }) {
// 是否显示Cron表达式配置对话框
const showCronDialog = ref(false)
// 星期选项
const weekOptions = [
{ label: '日', desc: '星期日' },
{ label: '一', desc: '星期一' },
{ label: '二', desc: '星期二' },
{ label: '三', desc: '星期三' },
{ label: '四', desc: '星期四' },
{ label: '五', desc: '星期五' },
{ label: '六', desc: '星期六' }
]
// cron配置
const cronConfig = reactive({
minute: '*',
hour: '*',
day: '*',
month: '*',
week: '*'
})
// 计算属性cron表达式
const cronExpression = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value)
})
// 打开Cron对话框
const openCronDialog = () => {
// 解析当前的cron表达式
if (cronExpression.value) {
const parts = cronExpression.value.split(' ')
if (parts.length === 5) {
cronConfig.minute = parts[0]
cronConfig.hour = parts[1]
cronConfig.day = parts[2]
cronConfig.month = parts[3]
cronConfig.week = parts[4]
}
} else {
// 默认值
cronConfig.minute = '*'
cronConfig.hour = '*'
cronConfig.day = '*'
cronConfig.month = '*'
cronConfig.week = '*'
}
showCronDialog.value = true
}
// 确认Cron表达式
const confirmCronExpression = () => {
cronExpression.value = `${cronConfig.minute} ${cronConfig.hour} ${cronConfig.day} ${cronConfig.month} ${cronConfig.week}`
showCronDialog.value = false
}
// 处理Cron对话框关闭
const handleCronDialogClose = () => {
showCronDialog.value = false
}
return {
showCronDialog,
cronConfig,
cronExpression,
weekOptions,
openCronDialog,
confirmCronExpression,
handleCronDialogClose
}
}
}
</script>
<style scoped>
.cron-expression-editor {
width: 100%;
}
.cron-dialog-content {
padding: 20px;
}
.dialog-footer {
text-align: right;
}
.option-desc {
float: right;
color: #8492a6;
font-size: 13px;
}
</style>

View File

@@ -14,32 +14,32 @@
@submit.prevent
>
<!-- 基础信息 -->
<el-form-item label="设备名" prop="name">
<el-form-item label="名" prop="name">
<el-input v-model="formData.name" />
</el-form-item>
<el-form-item label="设备类型" prop="type">
<el-select v-model="formData.type" @change="handleTypeChange">
<el-form-item label="类型" prop="type">
<el-select v-model="formData.type" @change="handleTypeChange" :disabled="isEdit">
<el-option label="区域主控" value="area_controller" />
<el-option label="普通设备" value="device" />
</el-select>
</el-form-item>
<el-form-item label="设备位置描述" prop="location">
<el-form-item label="位置描述" prop="location">
<el-input v-model="formData.location" type="textarea" />
</el-form-item>
<!-- 区域主控类型额外字段 -->
<div v-if="formData.type === 'area_controller'">
<el-form-item label="LoRa地址" prop="loraAddress">
<el-input v-model="formData.loraAddress" />
<el-form-item label="网络ID" prop="network_id">
<el-input v-model="formData.network_id" />
</el-form-item>
</div>
<!-- 普通设备类型额外字段 -->
<div v-if="formData.type === 'device'">
<el-form-item label="区域主控" prop="parentControllerId">
<el-select v-model="formData.parentControllerId" placeholder="请选择区域主控">
<el-form-item label="所属区域主控" prop="area_controller_id">
<el-select v-model="formData.area_controller_id" placeholder="请选择区域主控">
<el-option
v-for="controller in areaControllers"
:key="controller.id"
@@ -49,38 +49,29 @@
</el-select>
</el-form-item>
<el-form-item label="设备种类" prop="subType">
<el-select v-model="formData.subType" placeholder="请选择设备种类">
<el-option label="风扇" value="fan" />
<el-option label="温度传感器" value="temperature" />
<el-option label="湿度传感器" value="humidity" />
<el-option label="氨气传感器" value="ammonia" />
<el-option label="饲料阀门" value="feed_valve" />
<el-option label="水帘" value="water_curtain" />
<el-form-item label="设备模板" prop="device_template_id">
<el-select v-model="formData.device_template_id" placeholder="请选择设备模板">
<el-option
v-for="template in deviceTemplates"
:key="template.id"
:label="template.name"
:value="template.id"
/>
</el-select>
</el-form-item>
<el-form-item label="485总线号" prop="busNumber">
<el-form-item label="485总线号" prop="properties.bus_number">
<el-input-number
v-model="formData.busNumber"
v-model="formData.properties.bus_number"
:min="0"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="485总线地址" prop="busAddress">
<el-form-item label="485总线地址" prop="properties.bus_address">
<el-input-number
v-model="formData.busAddress"
:min="0"
controls-position="right"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="继电器通道号" prop="relayChannel">
<el-input-number
v-model="formData.relayChannel"
v-model="formData.properties.bus_address"
:min="0"
controls-position="right"
style="width: 100%"
@@ -99,9 +90,10 @@
</template>
<script>
import { ref, reactive, onMounted, watch, computed } from 'vue';
import { ref, reactive, onMounted, watch, computed, nextTick } from 'vue';
import { ElMessage } from 'element-plus';
import { DeviceApi } from '../api/device.js';
import { AreaControllerApi, DeviceApi } from '../api/device.js';
import deviceTemplateService from '../services/deviceTemplateService.js'; // 导入设备模板服务
export default {
name: 'DeviceForm',
@@ -121,121 +113,161 @@ export default {
},
emits: ['update:visible', 'success', 'cancel'],
setup(props, { emit }) {
// 表单引用
const formRef = ref(null);
// 加载状态
const loading = ref(false);
// 区域主控列表
const areaControllers = ref([]);
const deviceTemplates = ref([]); // 新增设备模板列表状态
// 表单数据
const formData = reactive({
const initialFormData = () => ({
id: '',
name: '',
type: '',
type: 'device', // 默认创建普通设备
location: '',
// 区域主控字段
loraAddress: '',
// 普通设备字段
parentControllerId: '',
subType: '',
busNumber: 0,
busAddress: 0,
relayChannel: 0
network_id: '', // 区域主控字段
area_controller_id: '', // 普通设备字段
device_template_id: '', // 普通设备字段
properties: { // 嵌套的properties对象
bus_number: 0,
bus_address: 0,
}
});
const formData = reactive(initialFormData());
// 表单验证规则
const rules = {
const rules = computed(() => ({
name: [
{ required: true, message: '请输入设备名', trigger: 'blur' }
{ required: true, message: '请输入名', trigger: 'blur' }
],
type: [
{ required: true, message: '请选择设备类型', trigger: 'change' }
{ required: true, message: '请选择类型', trigger: 'change' }
],
location: [
{ required: true, message: '请输入设备位置描述', trigger: 'blur' }
{ required: true, message: '请输入位置描述', trigger: 'blur' }
],
loraAddress: [
{ required: true, message: '请输入LoRa地址', trigger: 'blur' }
network_id: [
{ required: formData.type === 'area_controller', message: '请输入网络ID', trigger: 'blur' }
],
parentControllerId: [
{ required: true, message: '请选择区域主控', trigger: 'change' }
area_controller_id: [
{ required: formData.type === 'device', message: '请选择所属区域主控', trigger: 'change' }
],
subType: [
{ required: true, message: '请选择设备种类', trigger: 'change' }
device_template_id: [
{ required: formData.type === 'device', message: '请选择设备模板', trigger: 'change' }
],
busNumber: [
{ required: true, message: '请输入485总线号', trigger: 'blur' }
'properties.bus_number': [
{ required: formData.type === 'device', message: '请输入485总线号', trigger: 'blur' }
],
busAddress: [
{ required: true, message: '请输入485总线地址', trigger: 'blur' }
'properties.bus_address': [
{ required: formData.type === 'device', message: '请输入485总线地址', trigger: 'blur' }
],
relayChannel: [
{ required: true, message: '请输入继电器通道号', trigger: 'blur' }
]
};
}));
// 标题计算
const title = computed(() => {
return props.isEdit ? '编辑设备' : '添加设备';
});
// 处理类型切换
const handleTypeChange = (value) => {
// 清除之前的数据
// 清除不同类型特有的字段
if (value === 'area_controller') {
// 清除普通设备字段
formData.parentControllerId = '';
formData.subType = '';
formData.busNumber = 0;
formData.busAddress = 0;
formData.relayChannel = 0;
formData.area_controller_id = '';
formData.device_template_id = '';
formData.properties = { bus_number: 0, bus_address: 0 };
} else {
// 清除区域主控字段
formData.loraAddress = '';
formData.network_id = '';
}
// 触发验证规则更新
nextTick(() => {
if (formRef.value) {
formRef.value.clearValidate();
}
});
};
// 获取区域主控列表
const loadAreaControllers = async () => {
try {
const response = await DeviceApi.list();
// 筛选出类型为区域主控的设备
areaControllers.value = response.data.filter(device => device.type === 'area_controller');
const response = await AreaControllerApi.list();
areaControllers.value = response.data || [];
} catch (error) {
console.error('获取区域主控列表失败:', error);
areaControllers.value = [];
}
};
// 新增:加载设备模板列表
const loadDeviceTemplates = async () => {
try {
const response = await deviceTemplateService.getDeviceTemplates();
deviceTemplates.value = response.data || [];
} catch (error) {
console.error('获取设备模板列表失败:', error);
deviceTemplates.value = [];
}
};
// 关闭对话框
const handleClose = () => {
emit('update:visible', false);
emit('cancel');
// 重置表单
Object.assign(formData, initialFormData());
nextTick(() => {
if (formRef.value) {
formRef.value.resetFields();
}
});
};
const getSubmitData = () => {
const data = {
name: formData.name,
location: formData.location,
properties: formData.properties // properties直接作为对象传递
};
if (formData.type === 'area_controller') {
data.network_id = formData.network_id;
} else {
data.area_controller_id = formData.area_controller_id;
data.device_template_id = formData.device_template_id;
}
return data;
};
// 提交表单
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
loading.value = true;
try {
let result;
const submitData = getSubmitData();
if (props.isEdit) {
// 编辑设备
result = await DeviceApi.update(formData.id, getSubmitData());
if (formData.type === 'area_controller') {
result = await AreaControllerApi.update(formData.id, submitData);
} else {
result = await DeviceApi.update(formData.id, submitData);
}
} else {
// 创建设备
result = await DeviceApi.create(getSubmitData());
if (formData.type === 'area_controller') {
result = await AreaControllerApi.create(submitData);
} else {
result = await DeviceApi.create(submitData);
}
}
emit('success', result);
// 适配DeviceList的树形结构添加type和parent_id
const processedResult = {
...result.data,
type: formData.type
};
if (formData.type === 'device') {
processedResult.parent_id = processedResult.area_controller_id;
}
emit('success', processedResult);
handleClose();
} catch (error) {
console.error('保存设备失败:', error);
ElMessage.error(props.isEdit ? '编辑设备失败' : '创建设备失败');
ElMessage.error(props.isEdit ? '编辑设备失败: ' + (error.message || '未知错误') : '创建设备失败: ' + (error.message || '未知错误'));
} finally {
loading.value = false;
}
@@ -243,75 +275,71 @@ export default {
});
};
// 获取提交数据
const getSubmitData = () => {
const data = {
name: formData.name,
type: formData.type,
location: formData.location
};
// 添加properties字段作为JSON对象传递
const properties = {};
if (formData.type === 'area_controller') {
properties.lora_address = formData.loraAddress;
} else if (formData.type === 'device') {
properties.bus_number = formData.busNumber;
properties.bus_address = formData.busAddress;
properties.relay_channel = formData.relayChannel;
data.parent_id = formData.parentControllerId;
data.sub_type = formData.subType;
}
// 直接使用properties对象不进行序列化
data.properties = properties;
return data;
};
// 监听设备数据变化
watch(() => props.deviceData, (newVal) => {
if (newVal && Object.keys(newVal).length > 0) {
// 填充表单数据
Object.keys(formData).forEach(key => {
if (newVal[key] !== undefined) {
formData[key] = newVal[key];
// 重置表单以清除旧数据和验证状态
Object.assign(formData, initialFormData());
nextTick(() => {
if (formRef.value) {
formRef.value.clearValidate();
}
});
formData.id = newVal.id;
formData.name = newVal.name;
formData.type = newVal.type;
formData.location = newVal.location;
if (newVal.type === 'area_controller') {
formData.network_id = newVal.network_id || '';
} else if (newVal.type === 'device') {
formData.area_controller_id = newVal.area_controller_id || newVal.parent_id || '';
formData.device_template_id = newVal.device_template_id || '';
}
// 处理properties对象的数据填充
if (newVal.properties) {
const props = typeof newVal.properties === 'string' ? JSON.parse(newVal.properties) : newVal.properties;
if (formData.type === 'area_controller') {
formData.loraAddress = props.lora_address || '';
} else if (formData.type === 'device') {
formData.busNumber = props.bus_number || 0;
formData.busAddress = props.bus_address || 0;
formData.relayChannel = props.relay_channel || 0;
}
// 确保properties是一个对象如果API返回的是字符串则尝试解析
const propsData = typeof newVal.properties === 'string' ? JSON.parse(newVal.properties) : newVal.properties;
Object.assign(formData.properties, propsData);
}
} else {
// 重置表单数据
Object.keys(formData).forEach(key => {
if (key === 'busNumber' || key === 'busAddress' || key === 'relayChannel') {
formData[key] = 0;
} else {
formData[key] = '';
// 如果没有传入deviceData则重置为初始状态
Object.assign(formData, initialFormData());
nextTick(() => {
if (formRef.value) {
formRef.value.clearValidate();
}
});
}
}, { immediate: true });
// 组件挂载时加载区域主控列表
watch(() => props.visible, (newVal) => {
if (newVal) {
loadAreaControllers();
loadDeviceTemplates(); // 新增:对话框打开时加载设备模板
// 如果是新增确保type是默认值且清空所有字段
if (!props.isEdit) {
Object.assign(formData, initialFormData());
nextTick(() => {
if (formRef.value) {
formRef.value.clearValidate();
}
});
}
}
}, { immediate: true });
onMounted(() => {
loadAreaControllers();
loadDeviceTemplates(); // 新增:组件挂载时加载设备模板
});
return {
formRef,
loading,
areaControllers,
deviceTemplates, // 暴露设备模板列表
formData,
rules,
title,

View File

@@ -1,238 +0,0 @@
<template>
<div class="device-list">
<el-card>
<template #header>
<div class="card-header">
<h2 class="page-title">设备管理</h2>
<el-button type="primary" @click="addDevice">添加设备</el-button>
</div>
</template>
<!-- 加载状态 -->
<div v-if="loading" class="loading">
<el-skeleton animated />
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error">
<el-alert
title="获取设备数据失败"
:description="error"
type="error"
show-icon
closable
@close="error = null"
/>
<el-button type="primary" @click="loadDevices" class="retry-btn">重新加载</el-button>
</div>
<!-- 设备列表 -->
<el-table
v-else
:data="devices"
style="width: 100%"
:fit="true"
table-layout="auto">
<el-table-column prop="id" label="设备ID" min-width="80" />
<el-table-column prop="name" label="设备名称" min-width="120" />
<el-table-column prop="type" label="设备类型" min-width="100">
<template #default="scope">
{{ formatDeviceType(scope.row.type) }}
</template>
</el-table-column>
<el-table-column prop="sub_type" label="设备子类型" min-width="100">
<template #default="scope">
{{ formatDeviceSubType(scope.row.sub_type) }}
</template>
</el-table-column>
<el-table-column prop="location" label="设备地址描述" min-width="150" />
<el-table-column prop="parentName" label="上级设备名" min-width="120" />
<el-table-column label="操作" min-width="120" align="center">
<template #default="scope">
<el-button size="small" @click="editDevice(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteDevice(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 设备表单对话框 -->
<DeviceForm
v-model:visible="dialogVisible"
:device-data="currentDevice"
:is-edit="isEdit"
@success="onDeviceSuccess"
@cancel="dialogVisible = false"
/>
</div>
</template>
<script>
import deviceService from '../services/deviceService.js';
import DeviceForm from './DeviceForm.vue';
export default {
name: 'DeviceList',
components: {
DeviceForm
},
data() {
return {
devices: [],
allDevices: [], // 存储所有设备用于查找上级设备
loading: false,
error: null,
saving: false,
dialogVisible: false,
currentDevice: {},
isEdit: false
};
},
async mounted() {
await this.loadDevices();
},
methods: {
// 加载设备列表
async loadDevices() {
this.loading = true;
this.error = null;
try {
const data = await deviceService.getDevices();
// 保存所有设备数据
this.allDevices = data;
// 处理设备数据,添加上级设备名称
this.devices = data.map(device => {
// 查找上级设备名称
let parentName = '-';
if (device.parent_id) {
const parentDevice = data.find(d => d.id === device.parent_id);
parentName = parentDevice ? parentDevice.name : '-';
}
return {
...device,
parentName
};
});
} catch (err) {
this.error = err.message || '未知错误';
console.error('加载设备列表失败:', err);
} finally {
this.loading = false;
}
},
// 格式化设备类型显示
formatDeviceType(type) {
const typeMap = {
'area_controller': '区域主控',
'device': '普通设备'
};
return typeMap[type] || type || '-';
},
// 格式化设备子类型显示
formatDeviceSubType(subType) {
const subTypeMap = {
'': '-',
'temperature': '温度传感器',
'humidity': '湿度传感器',
'ammonia': '氨气传感器',
'feed_valve': '饲料阀门',
'fan': '风扇',
'water_curtain': '水帘'
};
return subTypeMap[subType] || subType || '-';
},
addDevice() {
this.currentDevice = {};
this.isEdit = false;
this.dialogVisible = true;
},
editDevice(device) {
this.currentDevice = { ...device };
this.isEdit = true;
this.dialogVisible = true;
},
async deleteDevice(device) {
try {
await this.$confirm('确认删除该设备吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
});
await deviceService.deleteDevice(device.id);
this.$message.success('删除成功');
// 重新加载设备列表
await this.loadDevices();
} catch (err) {
if (err !== 'cancel') {
this.$message.error('删除失败: ' + (err.message || '未知错误'));
}
}
},
// 设备操作成功回调
async onDeviceSuccess() {
this.$message.success(this.isEdit ? '设备更新成功' : '设备添加成功');
this.dialogVisible = false;
// 重新加载设备列表
await this.loadDevices();
}
}
};
</script>
<style scoped>
.device-list {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 0;
}
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: bold;
}
.dialog-footer {
text-align: right;
}
.loading {
padding: 20px 0;
}
.error {
padding: 20px 0;
text-align: center;
}
.retry-btn {
margin-top: 15px;
}
@media (max-width: 768px) {
.device-list {
padding: 10px;
}
.card-header {
flex-direction: column;
gap: 15px;
}
}
</style>

View File

@@ -0,0 +1,288 @@
<template>
<el-dialog
:model-value="visible"
:title="title"
@close="handleClose"
:close-on-click-modal="false"
width="600px"
>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="120px"
@submit.prevent
>
<el-form-item label="名称" prop="name">
<el-input v-model="formData.name" />
</el-form-item>
<el-form-item label="制造商" prop="manufacturer">
<el-input v-model="formData.manufacturer" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="formData.description" type="textarea" />
</el-form-item>
<el-form-item label="类别" prop="category">
<el-select v-model="formData.category" placeholder="请选择类别" @change="handleCategoryChange">
<el-option label="执行器" value="执行器" />
<el-option label="传感器" value="传感器" />
</el-select>
</el-form-item>
<el-form-item label="指令 (JSON)" prop="commands">
<el-input
v-model="formData.commands"
type="textarea"
:rows="5"
placeholder="请输入JSON格式的指令参数"
/>
</el-form-item>
<el-form-item
v-if="formData.category === '传感器'"
label="值描述 (JSON)"
prop="values"
>
<el-input
v-model="formData.values"
type="textarea"
:rows="5"
placeholder="请输入JSON格式的值描述数组"
/>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="loading">确定</el-button>
</span>
</template>
</el-dialog>
</template>
<script>
import { ref, reactive, computed, watch, nextTick } from 'vue';
import { ElMessage } from 'element-plus';
import deviceTemplateService from '../services/deviceTemplateService.js';
// 默认的JSON模板
const DEFAULT_ACTUATOR_COMMANDS = JSON.stringify({
modbus_start_address: 0,
modbus_quantity: 1
}, null, 2);
const DEFAULT_SENSOR_COMMANDS = JSON.stringify({
modbus_function_code: 3,
modbus_start_address: 0,
modbus_quantity: 1
}, null, 2);
const DEFAULT_SENSOR_VALUES = JSON.stringify([
{
type: "temperature",
multiplier: 0.1,
offset: 0
}
], null, 2);
export default {
name: 'DeviceTemplateForm',
props: {
visible: {
type: Boolean,
default: false,
},
templateData: {
type: Object,
default: () => ({}),
},
isEdit: {
type: Boolean,
default: false,
},
},
emits: ['update:visible', 'success', 'cancel'],
setup(props, { emit }) {
const formRef = ref(null);
const loading = ref(false);
const initialFormData = () => ({
id: '',
name: '',
manufacturer: '',
description: '',
category: '执行器', // 默认执行器
commands: DEFAULT_ACTUATOR_COMMANDS, // 预填充执行器指令
values: '',
});
const formData = reactive(initialFormData());
// JSON 验证器
const validateJson = (rule, value, callback) => {
if (!value) {
return callback(new Error(rule.message));
}
try {
JSON.parse(value);
callback();
} catch (e) {
callback(new Error('请输入有效的 JSON 格式'));
}
};
const rules = computed(() => ({
name: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
category: [{ required: true, message: '请选择模板类别', trigger: 'change' }],
commands: [
{ required: true, message: '请输入指令参数', trigger: 'blur' },
{ validator: validateJson, message: '指令参数必须是有效的 JSON 格式', trigger: 'blur' },
],
values: [
{ required: formData.category === '传感器', message: '请输入值描述', trigger: 'blur' },
{ validator: validateJson, message: '值描述必须是有效的 JSON 格式', trigger: 'blur' },
],
}));
const title = computed(() => {
return props.isEdit ? '编辑设备模板' : '新增设备模板';
});
const handleCategoryChange = (newCategory) => {
if (newCategory === '执行器') {
formData.commands = DEFAULT_ACTUATOR_COMMANDS;
formData.values = ''; // 执行器没有values
} else if (newCategory === '传感器') {
formData.commands = DEFAULT_SENSOR_COMMANDS;
formData.values = DEFAULT_SENSOR_VALUES; // 传感器预填充values
}
nextTick(() => {
if (formRef.value) {
formRef.value.clearValidate();
}
});
};
const handleClose = () => {
emit('update:visible', false);
emit('cancel');
// 重置表单
Object.assign(formData, initialFormData());
nextTick(() => {
if (formRef.value) {
formRef.value.resetFields();
}
});
};
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
loading.value = true;
try {
const submitData = {
name: formData.name,
manufacturer: formData.manufacturer,
description: formData.description,
category: formData.category,
commands: JSON.parse(formData.commands),
};
if (formData.category === '传感器' && formData.values) {
submitData.values = JSON.parse(formData.values);
}
if (props.isEdit) {
await deviceTemplateService.updateDeviceTemplate(formData.id, submitData);
} else {
await deviceTemplateService.createDeviceTemplate(submitData);
}
emit('success');
handleClose();
} catch (error) {
console.error('保存设备模板失败:', error);
ElMessage.error(
props.isEdit ? '编辑设备模板失败: ' + (error.message || '未知错误') : '创建设备模板失败: ' + (error.message || '未知错误')
);
} finally {
loading.value = false;
}
}
});
};
watch(
() => props.templateData,
(newVal) => {
if (newVal && Object.keys(newVal).length > 0) {
// 填充表单数据
formData.id = newVal.id;
formData.name = newVal.name;
formData.manufacturer = newVal.manufacturer;
formData.description = newVal.description;
if (newVal.category === 'sensor') {
formData.category = '传感器';
} else if (newVal.category === 'actuator') {
formData.category = '执行器';
} else {
formData.category = newVal.category;
}
// 格式化JSON显示
formData.commands = newVal.commands ? JSON.stringify(newVal.commands, null, 2) : '';
formData.values = newVal.values ? JSON.stringify(newVal.values, null, 2) : '';
} else {
// 重置表单数据到初始状态 (新增模式)
Object.assign(formData, initialFormData());
}
nextTick(() => {
if (formRef.value) {
formRef.value.clearValidate();
}
});
},
{ immediate: true }
);
watch(
() => props.visible,
(newVal) => {
if (newVal && !props.isEdit) {
// 如果是新增模式且对话框打开,重置表单
Object.assign(formData, initialFormData());
nextTick(() => {
if (formRef.value) {
formRef.value.clearValidate();
}
});
}
},
{ immediate: true }
);
return {
formRef,
loading,
formData,
rules,
title,
handleCategoryChange,
handleClose,
handleSubmit,
};
},
};
</script>
<style scoped>
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>

View File

@@ -0,0 +1,268 @@
<template>
<div class="generic-monitor-list">
<el-card shadow="never">
<el-form :inline="true" :model="filters" class="filter-form">
<el-form-item v-for="col in filterableColumns" :key="col.dataIndex" :label="col.title">
<template v-if="col.filterType === 'text'">
<el-input
v-model="filters[col.dataIndex]"
:placeholder="`搜索 ${col.title}`"
clearable
@change="handleFilterChange(col.dataIndex, filters[col.dataIndex])"
></el-input>
</template>
<template v-else-if="col.filterType === 'number'">
<el-input-number
v-model="filters[col.dataIndex]"
:placeholder="`搜索 ${col.title}`"
:controls="false"
clearable
@change="handleFilterChange(col.dataIndex, filters[col.dataIndex])"
></el-input-number>
</template>
<template v-else-if="col.filterType === 'dateRange'">
<el-date-picker
v-model="filters[col.dataIndex]"
type="datetimerange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
@change="handleFilterChange(col.dataIndex, filters[col.dataIndex])"
></el-date-picker>
</template>
<template v-else-if="col.filterType === 'select'">
<el-select
v-model="filters[col.dataIndex]"
:placeholder="`选择 ${col.title}`"
clearable
@change="handleFilterChange(col.dataIndex, filters[col.dataIndex])"
>
<el-option
v-for="option in col.filterOptions"
:key="option.value"
:label="option.text"
:value="option.value"
></el-option>
</el-select>
</template>
<template v-else-if="col.filterType === 'boolean'">
<el-select
v-model="filters[col.dataIndex]"
:placeholder="`选择 ${col.title}`"
clearable
@change="handleFilterChange(col.dataIndex, filters[col.dataIndex])"
>
<el-option label="是" :value="true"></el-option>
<el-option label="否" :value="false"></el-option>
</el-select>
</template>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="loadData">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
</el-form>
<el-table
:data="data"
v-loading="loading"
border
stripe
style="width: 100%"
table-layout="auto"
:fit="true"
:scrollbar-always-on="true"
@sort-change="handleSortChange"
>
<el-table-column
v-for="col in tableColumns"
:key="col.key"
:prop="col.prop"
:label="col.title"
:sortable="col.sorter ? 'custom' : false"
:formatter="col.formatter"
:min-width="col.minWidth"
>
<template v-if="col.render" #default="{ row }">
<component :is="col.render(row)"/>
</template>
</el-table-column>
</el-table>
<el-pagination
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
:current-page="pagination.currentPage"
:page-sizes="[10, 20, 50, 100]"
:page-size="pagination.pageSize"
layout="total, sizes, prev, pager, next, jumper"
:total="pagination.total"
background
style="margin-top: 20px; text-align: right;"
></el-pagination>
</el-card>
</div>
</template>
<script setup>
import {ref, reactive, onMounted, watch, computed} from 'vue';
import {ElMessage} from 'element-plus';
const props = defineProps({
fetchData: {
type: Function,
required: true,
},
columnsConfig: {
type: Array,
required: true,
},
});
const data = ref([]);
const loading = ref(false);
const pagination = reactive({
currentPage: 1,
pageSize: 10,
total: 0,
});
const filters = reactive({});
const sortOrder = reactive({
prop: undefined,
order: undefined,
});
const filterableColumns = computed(() => {
return props.columnsConfig.filter(col => col.filterType);
});
const tableColumns = computed(() => {
return props.columnsConfig.map(col => {
const newCol = {...col};
newCol.prop = Array.isArray(col.dataIndex) ? col.dataIndex.join('.') : col.dataIndex;
// 添加智能默认 formatter
if (!newCol.formatter) {
newCol.formatter = (row, column, cellValue) => {
if (typeof cellValue === 'object' && cellValue !== null) {
try {
return JSON.stringify(cellValue, null, 2); // 格式化为可读的JSON字符串
} catch (e) {
console.warn('Failed to stringify object for display:', cellValue, e);
return '[Object]'; // 无法序列化时显示简短提示
}
} else if (Array.isArray(cellValue)) {
return cellValue.join(', '); // 数组也默认用逗号连接
}
return cellValue;
};
}
return newCol;
});
});
const loadData = async () => {
loading.value = true;
try {
const params = {
page: pagination.currentPage,
page_size: pagination.pageSize, // Changed from pageSize to page_size
...filters,
orderBy: sortOrder.prop,
order: sortOrder.order === 'ascending' ? 'asc' : (sortOrder.order === 'descending' ? 'desc' : undefined),
};
// Custom function to format Date objects to YYYY-MM-DDTHH:mm:ssZ
const formatToRFC3339WithOffset = (date) => {
if (!date) return ''; // Handle null or undefined dates
const year = date.getUTCFullYear();
const month = (date.getUTCMonth() + 1).toString().padStart(2, '0');
const day = date.getUTCDate().toString().padStart(2, '0');
const hours = date.getUTCHours().toString().padStart(2, '0');
const minutes = date.getUTCMinutes().toString().padStart(2, '0');
const seconds = date.getUTCSeconds().toString().padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}Z`;
};
// 将日期范围筛选转换为 start_time 和 end_time并确保是 RFC3339 UTC 格式 (不带毫秒)
filterableColumns.value.forEach(col => {
if (col.filterType === 'dateRange' && filters[col.dataIndex] && filters[col.dataIndex].length === 2) {
// filters[col.dataIndex] will now contain Date objects directly from el-date-picker
const startDateObj = filters[col.dataIndex][0];
const endDateObj = filters[col.dataIndex][1];
params[`start_time`] = formatToRFC3339WithOffset(startDateObj);
params[`end_time`] = formatToRFC3339WithOffset(endDateObj);
delete params[col.dataIndex];
}
});
console.log('Sending parameters to fetchData:', params);
const result = await props.fetchData(params);
data.value = result.list;
pagination.total = result.total;
} catch (error) {
console.error('Failed to fetch data:', error);
ElMessage.error('获取数据失败,请稍后再试。');
} finally {
loading.value = false;
}
};
const handleSizeChange = (val) => {
pagination.pageSize = val;
pagination.currentPage = 1;
loadData();
};
const handleCurrentChange = (val) => {
pagination.currentPage = val;
loadData();
};
const handleSortChange = ({prop, order}) => {
sortOrder.prop = prop;
sortOrder.order = order;
loadData();
};
const handleFilterChange = (key, value) => {
filters[key] = value;
pagination.currentPage = 1;
};
const resetFilters = () => {
for (const key in filters) {
delete filters[key];
}
sortOrder.prop = undefined;
sortOrder.order = undefined;
pagination.currentPage = 1;
loadData();
};
onMounted(() => {
loadData();
});
</script>
<style scoped>
.generic-monitor-list {
padding: 20px;
}
.filter-form {
margin-bottom: 20px;
}
.filter-form .el-form-item {
min-width: 220px; /* 增加最小宽度 */
}
.el-card {
border: none;
}
</style>

157
src/components/PenForm.vue Normal file
View File

@@ -0,0 +1,157 @@
<template>
<el-dialog
:model-value="visible"
:title="title"
@close="handleClose"
:close-on-click-modal="false"
width="500px"
>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="100px"
@submit.prevent
>
<el-form-item label="猪栏编号" prop="pen_number">
<el-input v-model="formData.pen_number" placeholder="例如A01-01" />
</el-form-item>
<el-form-item label="容量" prop="capacity">
<el-input-number v-model="formData.capacity" :min="1" controls-position="right" style="width: 100%" />
</el-form-item>
<el-form-item v-if="isEdit" label="状态" prop="status">
<el-select v-model="formData.status" placeholder="请选择状态" style="width: 100%">
<el-option v-for="item in penStatusOptions" :key="item" :label="item" :value="item" />
</el-select>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="loading">确定</el-button>
</span>
</template>
</el-dialog>
</template>
<script>
import { ref, reactive, watch, computed, nextTick } from 'vue';
import { ElMessage } from 'element-plus';
import { createPen, updatePen } from '@/api/pen.js';
export default {
name: 'PenForm',
props: {
visible: {
type: Boolean,
default: false
},
penData: {
type: Object,
required: true
},
isEdit: {
type: Boolean,
default: false
}
},
emits: ['update:visible', 'success', 'cancel'],
setup(props, { emit }) {
const formRef = ref(null);
const loading = ref(false);
const penStatusOptions = ["空闲", "使用中", "病猪栏", "康复栏", "清洗消毒", "维修中"];
const initialFormData = () => ({
id: null,
pen_number: '',
capacity: 10,
status: '空闲',
house_id: null
});
const formData = reactive(initialFormData());
const rules = {
pen_number: [
{ required: true, message: '请输入猪栏编号', trigger: 'blur' }
],
capacity: [
{ required: true, message: '请输入容量', trigger: 'blur' },
{ type: 'integer', min: 1, message: '容量必须是大于0的整数', trigger: 'blur' }
],
status: [
{ required: true, message: '请选择状态', trigger: 'change' }
]
};
const title = computed(() => (props.isEdit ? '编辑猪栏' : '添加猪栏'));
const handleClose = () => {
emit('update:visible', false);
emit('cancel');
nextTick(() => {
formRef.value?.resetFields();
});
};
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
loading.value = true;
try {
let response;
if (props.isEdit) {
const { id, ...updateData } = formData;
response = await updatePen(id, updateData);
} else {
const { id, status, ...createData } = formData;
response = await createPen(createData);
}
emit('success', response.data);
handleClose();
} catch (error) {
ElMessage.error((props.isEdit ? '更新' : '创建') + '猪栏失败: ' + (error.message || '未知错误'));
} finally {
loading.value = false;
}
}
});
};
watch(() => props.visible, (newVal) => {
if (newVal) {
Object.assign(formData, initialFormData());
if (props.isEdit && props.penData) {
Object.assign(formData, props.penData);
} else if (props.penData) {
formData.house_id = props.penData.house_id;
}
nextTick(() => {
formRef.value?.clearValidate();
});
}
});
return {
formRef,
loading,
formData,
rules,
title,
penStatusOptions,
handleClose,
handleSubmit
};
}
};
</script>
<style scoped>
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>

View File

@@ -0,0 +1,216 @@
<template>
<el-dialog
:model-value="visible"
:title="title"
@close="handleClose"
:close-on-click-modal="false"
width="600px"
>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="120px"
@submit.prevent
>
<el-form-item label="批次编号" prop="batch_number">
<el-input v-model="formData.batch_number" placeholder="请输入批次编号"/>
</el-form-item>
<el-form-item label="初始数量" prop="initial_count">
<el-input-number v-model="formData.initial_count" :min="1" controls-position="right" style="width: 100%"/>
</el-form-item>
<el-form-item label="来源类型" prop="origin_type">
<el-select v-model="formData.origin_type" placeholder="请选择来源类型" style="width: 100%">
<el-option v-for="item in originTypeOptions" :key="item" :label="item" :value="item"/>
</el-select>
</el-form-item>
<el-form-item label="开始日期" prop="start_date">
<el-date-picker
v-model="formData.start_date"
type="datetime"
placeholder="选择开始日期和时间"
value-format="YYYY-MM-DD HH:mm:ss"
style="width: 100%"
/>
</el-form-item>
<el-form-item label="批次状态" prop="status">
<el-select v-model="formData.status" placeholder="请选择批次状态" style="width: 100%">
<el-option v-for="item in batchStatusOptions" :key="item" :label="item" :value="item"/>
</el-select>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="loading">确定</el-button>
</span>
</template>
</el-dialog>
</template>
<script>
import {ref, reactive, watch, computed, nextTick} from 'vue';
import {ElMessage} from 'element-plus';
import {createPigBatch, updatePigBatch} from '@/api/pigBatch.js';
export default {
name: 'PigBatchForm',
props: {
visible: {
type: Boolean,
default: false
},
batchData: {
type: Object,
default: () => ({})
},
isEdit: {
type: Boolean,
default: false
}
},
emits: ['update:visible', 'success', 'cancel'],
setup(props, {emit}) {
const formRef = ref(null);
const loading = ref(false);
const originTypeOptions = ["自繁", "外购"];
const batchStatusOptions = ["保育", "生长", "育肥", "待售", "已出售", "已归档"];
const initialFormData = () => ({
id: null,
batch_number: '',
initial_count: 1,
origin_type: '自繁',
start_date: '',
status: '保育',
});
const formData = reactive(initialFormData());
const rules = {
batch_number: [
{required: true, message: '请输入批次编号', trigger: 'blur'}
],
initial_count: [
{required: true, message: '请输入初始数量', trigger: 'blur'},
{type: 'integer', min: 1, message: '初始数量必须是大于0的整数', trigger: 'blur'}
],
origin_type: [
{required: true, message: '请选择来源类型', trigger: 'change'}
],
start_date: [
{required: true, message: '请选择开始日期', trigger: 'change'}
],
status: [
{required: true, message: '请选择批次状态', trigger: 'change'}
]
};
const title = computed(() => (props.isEdit ? '编辑猪群' : '添加猪群'));
// ✅ 修复:东八区 RFC3339 转换函数
const toRFC3339 = (dateStr) => {
if (!dateStr) return null;
// "2025-10-23 14:30:00" → Date 对象(东八区)
const date = new Date(dateStr.replace(/ /, 'T') + '+08:00');
if (isNaN(date.getTime())) return null;
return date.toISOString().slice(0, -1) + '+08:00'; // "2025-10-23T14:30:00+08:00"
};
// ✅ 修复RFC3339 转显示格式
const fromRFC3339 = (rfc3339Str) => {
if (!rfc3339Str) return '';
const date = new Date(rfc3339Str);
if (isNaN(date.getTime())) return '';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
const handleClose = () => {
emit('update:visible', false);
emit('cancel');
nextTick(() => {
formRef.value?.resetFields();
Object.assign(formData, initialFormData());
});
};
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
loading.value = true;
try {
let response;
const submitData = {...formData};
delete submitData.id;
// ✅ 修复:正确转东八区 RFC3339
submitData.start_date = toRFC3339(formData.start_date);
console.log('提交的日期:', submitData.start_date); // 调试用
if (props.isEdit) {
response = await updatePigBatch(props.batchData.id, submitData);
} else {
response = await createPigBatch(submitData);
}
ElMessage.success((props.isEdit ? '更新' : '创建') + '猪群成功');
emit('success', response.data);
handleClose();
} catch (error) {
console.error('提交错误:', error);
ElMessage.error((props.isEdit ? '更新' : '创建') + '猪群失败: ' + (error.message || '未知错误'));
} finally {
loading.value = false;
}
}
});
};
watch(() => props.visible, (newVal) => {
if (newVal) {
Object.assign(formData, initialFormData());
if (props.isEdit && props.batchData) {
// ✅ 修复:正确转换编辑时的日期
formData.id = props.batchData.id;
formData.batch_number = props.batchData.batch_number || '';
formData.initial_count = props.batchData.initial_count || 1;
formData.origin_type = props.batchData.origin_type || '自繁';
formData.status = props.batchData.status || '保育';
formData.start_date = fromRFC3339(props.batchData.start_date);
}
nextTick(() => {
formRef.value?.clearValidate();
});
}
});
return {
formRef,
loading,
formData,
rules,
title,
originTypeOptions,
batchStatusOptions,
handleClose,
handleSubmit
};
}
};
</script>
<style scoped>
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>

View File

@@ -0,0 +1,300 @@
<template>
<div class="pig-batch-list">
<div v-for="batch in pigBatches" :key="batch.id" class="pig-batch-item">
<div class="batch-header" @click="toggleExpand(batch)">
<div class="batch-info">
<div class="batch-info-line">
<span>批次编号: {{ batch.batch_number }}</span>
<span>状态: {{ batch.status }}</span>
<span>初始数量: {{ batch.initial_count }}</span>
<span v-if="batch.currentTotalQuantity !== undefined && batch.currentTotalQuantity !== null">当前总数: {{
batch.currentTotalQuantity
}}</span>
<span v-if="batch.origin_type">批次来源: {{ batch.origin_type }}</span>
</div>
<div class="batch-info-line">
<span v-if="batch.start_date">批次开始日期: {{ formatRFC3339(batch.start_date) }}</span>
<span v-if="batch.end_date">
批次结束日期:
<template v-if="formatRFC3339(batch.end_date) === '0001-01-01 08:00:00'">
正在饲养中
</template>
<template v-else>
{{ formatRFC3339(batch.end_date) }}
</template>
</span>
<span v-if="batch.unassigned_pig_count !== undefined && batch.unassigned_pig_count > 0" class="red-text">
未分配数量: {{ batch.unassigned_pig_count }}
</span>
</div>
</div>
<div class="batch-actions">
<el-dropdown trigger="click" class="batch-dropdown">
<el-button type="primary" size="small">
管理猪群<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="showAddPenDialog(batch)" :disabled="!batch.is_active">增加猪栏</el-dropdown-item>
<el-dropdown-item @click="emitEditBatch(batch)">编辑</el-dropdown-item>
<el-dropdown-item @click="emitDeleteBatch(batch)" divided>删除</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-dropdown trigger="click" class="batch-dropdown">
<el-button type="success" size="small">
调栏<el-icon class="el-icon--right"><arrow-down /></el-icon>
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item
@click="emitTransferPigs(batch)"
:disabled="!batch.is_active || !batch.pens || batch.pens.length < 2"
>群内调栏</el-dropdown-item>
<el-dropdown-item
@click="emitTransferPigsAcrossBatches(batch)"
:disabled="!batch.is_active || !batch.pens || batch.pens.length === 0"
>跨群调栏</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
<div v-if="batch.isExpanded" class="batch-content">
<div v-if="batch.pens && batch.pens.length > 0" class="pig-pen-list">
<PigBatchPenCard
v-for="pen in batch.pens"
:key="pen.id"
:pen="pen"
:isBatchActive="batch.is_active"
:batchUnassignedPigCount="batch.unassigned_pig_count"
@allocate-pigs="showAllocatePigsDialog($event, batch)"
@remove="emitRemovePen"
/>
</div>
<div v-else class="no-pens-message">
<p>该猪群下没有猪栏信息</p>
</div>
</div>
</div>
<!-- 增加猪栏对话框 -->
<el-dialog title="选择猪栏" v-model="addPenDialogVisible" width="30%">
<el-select v-model="selectedPenId" placeholder="请选择猪栏" style="width: 100%;">
<el-option
v-for="pen in availablePens"
:key="pen.id"
:label="pen.label"
:value="pen.id">
</el-option>
</el-select>
<template #footer>
<span class="dialog-footer">
<el-button @click="addPenDialogVisible = false"> </el-button>
<el-button type="primary" @click="assignPen"> </el-button>
</span>
</template>
</el-dialog>
<!-- 分配猪只对话框 -->
<AllocatePigsDialog
v-model:visible="allocatePigsDialogVisible"
:unassigned-pig-count="currentBatch ? currentBatch.unassigned_pig_count : 0"
:pen-id="selectedPenForAllocation ? selectedPenForAllocation.id : 0"
@confirm="handleAllocatePigs"
/>
</div>
</template>
<script>
import PigBatchPenCard from './PigBatchPenCard.vue';
import AllocatePigsDialog from './AllocatePigsDialog.vue';
import {getAllPens, getAllPigHouses, movePigsIntoPen} from '../api/pigBatch';
import {formatRFC3339} from '../utils/format'; // 导入格式化函数
import { ArrowDown } from '@element-plus/icons-vue'; // 导入 ArrowDown 图标
export default {
name: 'PigBatchList',
components: {
PigBatchPenCard,
AllocatePigsDialog,
ArrowDown // 注册 ArrowDown 图标
},
props: {
pigBatches: {
type: Array,
required: true
}
},
emits: ['edit-batch', 'delete-batch', 'add-pen', 'remove-pen', 'assign-pen-to-batch', 'reload-data', 'transfer-pigs', 'transfer-pigs-across-batches'],
data() {
return {
addPenDialogVisible: false,
availablePens: [],
selectedPenId: null,
currentBatch: null, // To store the batch for which we are adding a pen
allocatePigsDialogVisible: false,
selectedPenForAllocation: null
};
},
methods: {
formatRFC3339,
toggleExpand(batch) {
batch.isExpanded = !batch.isExpanded;
},
async fetchAvailablePens() {
try {
const [pensResponse, housesResponse] = await Promise.all([
getAllPens(),
getAllPigHouses()
]);
const pens = pensResponse.data;
const houses = housesResponse.data;
// Create a map for quick lookup of house names by ID
const houseMap = new Map(houses.map(house => [house.id, house.name]));
// Filter for pens that are not assigned to any batch
const unassignedPens = pens.filter(pen => !pen.pig_batch_id);
this.availablePens = unassignedPens.map(pen => ({
id: pen.id,
label: `${pen.pen_number} (${houseMap.get(pen.house_id) || '未知猪舍'})`
}));
} catch (error) {
console.error("Error fetching pens or houses:", error);
this.$message.error("获取猪栏或猪舍信息失败");
}
},
showAddPenDialog(batch) {
this.currentBatch = batch;
this.selectedPenId = null; // Reset selection
this.fetchAvailablePens();
this.addPenDialogVisible = true;
},
assignPen() {
if (this.selectedPenId && this.currentBatch) {
this.$emit('assign-pen-to-batch', {
batchId: this.currentBatch.id,
penId: this.selectedPenId
});
this.addPenDialogVisible = false;
} else {
this.$message.warning("请选择一个猪栏");
}
},
showAllocatePigsDialog(pen, batch) {
this.currentBatch = batch;
this.selectedPenForAllocation = pen;
this.allocatePigsDialogVisible = true;
},
async handleAllocatePigs({penId, quantity}) {
try {
await movePigsIntoPen(this.currentBatch.id, {toPenID: penId, quantity});
this.$message.success('猪只分配成功');
this.allocatePigsDialogVisible = false;
this.$emit('reload-data'); // 通知父组件重新加载数据
} catch (error) {
console.error('Error allocating pigs:', error);
this.$message.error('分配猪只失败');
}
},
// 猪群操作
emitEditBatch(batch) {
this.$emit('edit-batch', batch);
},
emitDeleteBatch(batch) {
this.$emit('delete-batch', batch);
},
emitTransferPigs(batch) {
this.$emit('transfer-pigs', batch);
},
emitTransferPigsAcrossBatches(batch) {
this.$emit('transfer-pigs-across-batches', batch);
},
// 猪栏操作
emitRemovePen(pen) {
this.$emit('remove-pen', pen);
}
}
}
</script>
<style scoped>
.pig-batch-list {
width: 100%;
}
.pig-batch-item {
border: 1px solid #eee;
border-radius: 4px;
margin-bottom: 16px;
overflow: hidden;
}
.batch-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
cursor: pointer;
background-color: #f9f9f9;
transition: background-color 0.3s;
}
.batch-header:hover {
background-color: #f0f0f0;
}
.batch-info {
display: flex;
flex-direction: column; /* 垂直堆叠子元素 */
gap: 8px; /* 行间距 */
}
.batch-info-line {
display: flex;
flex-wrap: wrap;
gap: 0 20px; /* 列间距 */
}
.batch-info-line:first-child span:first-child {
font-weight: bold;
}
.batch-info span {
font-size: 14px;
color: #606266;
}
.batch-actions {
display: flex;
gap: 10px;
}
.batch-dropdown {
margin-left: 10px; /* 为下拉菜单添加左边距 */
}
.batch-content {
padding: 16px;
border-top: 1px solid #eee;
}
.pig-pen-list {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.no-pens-message {
color: #909399;
text-align: center;
padding: 20px 0;
}
.batch-info-line .red-text {
color: red !important;
}
</style>

View File

@@ -0,0 +1,135 @@
<template>
<div class="pig-pen-info-card">
<div class="info-section">
<div class="title">
猪栏: {{ pen.pen_number }} <el-tag size="small" :type="statusType">{{ pen.status || '未知' }}</el-tag>
</div>
<div class="info-item">猪舍: {{ pen.house_name || '未知' }}</div>
<div class="info-item border-left">容量: {{ pen.capacity }}</div>
<div class="info-item">批次: {{ pen.batch_number || '未分配' }}</div>
<div class="info-item border-left">存栏: <span :class="{'over-capacity': pen.current_pig_count > pen.capacity}">{{ pen.current_pig_count || 0 }}</span></div>
</div>
<div class="actions-section">
<el-button size="small" @click="emitAllocatePigs" :disabled="!isBatchActive || batchUnassignedPigCount <= 0">分配猪只</el-button>
<el-button size="small" type="danger" @click="emitRemove" :disabled="!isBatchActive || pen.current_pig_count > 0">移除</el-button>
</div>
</div>
</template>
<script>
import { computed } from 'vue';
export default {
name: 'PigBatchPenCard',
props: {
pen: {
type: Object,
required: true
},
isBatchActive: {
type: Boolean,
default: true // 默认活跃,以防万一没有传递
},
batchUnassignedPigCount: {
type: Number,
default: 0
}
},
emits: ['remove', 'allocate-pigs'],
setup(props, { emit }) {
const statusType = computed(() => {
switch (props.pen.status) {
case '使用中':
return 'success';
case '病猪栏':
case '维修中':
return 'danger';
case '清洗消毒':
return 'warning';
case '空闲':
default:
return 'info';
}
});
const emitAllocatePigs = () => {
emit('allocate-pigs', props.pen);
};
const emitRemove = () => {
emit('remove', props.pen);
};
return {
statusType,
emitAllocatePigs,
emitRemove
};
}
}
</script>
<style scoped>
.pig-pen-info-card {
border: 2px solid #ccc;
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
justify-content: space-between;
width: 220px; /* 适当加宽以容纳更多信息 */
height: auto; /* 让高度自适应内容 */
min-height: 240px; /* 设置最小高度 */
background-color: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: box-shadow 0.3s;
}
.pig-pen-info-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.info-section {
display: grid; /* 使用grid布局创建两列 */
grid-template-columns: 1fr 1fr; /* 两列等宽 */
gap: 8px 10px; /* 行间距 8px, 列间距 10px */
margin-bottom: 10px;
flex-grow: 1; /* 允许信息部分填充可用空间 */
}
.info-section .title {
font-weight: bold;
font-size: 1.1em;
color: #303133;
grid-column: 1 / -1; /* 标题横跨两列 */
margin-bottom: 5px; /* 标题下方增加一点间距 */
display: flex; /* Add flexbox for alignment */
align-items: center; /* Vertically align items */
gap: 8px; /* Space between title text and tag */
}
.info-section .info-item {
font-size: 0.9em;
color: #606266;
}
.info-item.border-left {
border-left: 1px solid #eee; /* 添加左边框作为分隔线 */
padding-left: 10px; /* 增加左内边距,使内容不紧贴边框 */
}
.actions-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.actions-section .el-button {
width: 100%;
margin: 0;
}
.over-capacity {
color: red;
}
</style>

View File

@@ -0,0 +1,145 @@
<template>
<el-dialog
:model-value="visible"
:title="title"
@close="handleClose"
:close-on-click-modal="false"
width="500px"
>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="100px"
@submit.prevent
>
<el-form-item label="猪舍名称" prop="name">
<el-input v-model="formData.name" placeholder="请输入猪舍名称" />
</el-form-item>
<el-form-item label="描述" prop="description">
<el-input v-model="formData.description" type="textarea" placeholder="请输入描述信息" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="loading">确定</el-button>
</span>
</template>
</el-dialog>
</template>
<script>
import { ref, reactive, watch, computed, nextTick } from 'vue';
import { ElMessage } from 'element-plus';
import { createPigHouse, updatePigHouse } from '@/api/pigHouse.js';
export default {
name: 'PigHouseForm',
props: {
visible: {
type: Boolean,
default: false
},
houseData: {
type: Object,
default: () => ({})
},
isEdit: {
type: Boolean,
default: false
}
},
emits: ['update:visible', 'success', 'cancel'],
setup(props, { emit }) {
const formRef = ref(null);
const loading = ref(false);
const initialFormData = () => ({
id: null,
name: '',
description: ''
});
const formData = reactive(initialFormData());
const rules = {
name: [
{ required: true, message: '请输入猪舍名称', trigger: 'blur' }
]
};
const title = computed(() => (props.isEdit ? '编辑猪舍' : '添加猪舍'));
const handleClose = () => {
emit('update:visible', false);
emit('cancel');
Object.assign(formData, initialFormData());
nextTick(() => {
formRef.value?.resetFields();
});
};
const handleSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
if (valid) {
loading.value = true;
try {
const submitData = { name: formData.name, description: formData.description };
let response;
if (props.isEdit) {
response = await updatePigHouse(formData.id, submitData);
} else {
response = await createPigHouse(submitData);
}
emit('success', response.data);
handleClose();
} catch (error) {
ElMessage.error((props.isEdit ? '更新' : '创建') + '猪舍失败: ' + (error.message || '未知错误'));
} finally {
loading.value = false;
}
}
});
};
watch(() => props.houseData, (newVal) => {
Object.assign(formData, initialFormData());
if (props.isEdit && newVal && newVal.id) {
formData.id = newVal.id;
formData.name = newVal.name;
formData.description = newVal.description;
}
}, { immediate: true, deep: true });
watch(() => props.visible, (newVal) => {
if (newVal && !props.isEdit) {
Object.assign(formData, initialFormData());
nextTick(() => {
formRef.value?.clearValidate();
});
}
});
return {
formRef,
loading,
formData,
rules,
title,
handleClose,
handleSubmit
};
}
};
</script>
<style scoped>
.dialog-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>

View File

@@ -0,0 +1,151 @@
<template>
<div class="pig-house-list">
<div v-for="house in enrichedPigHouses" :key="house.id" class="pig-house-item">
<div class="house-header" @click="emitToggleExpand(house.id)">
<div class="house-info">
<span>猪舍: {{ house.name }}</span>
<span v-if="house.description">描述: {{ house.description }}</span>
</div>
<div class="house-actions">
<el-button size="small" type="primary" @click.stop="emitAddPen(house)">增加猪栏</el-button>
<el-button size="small" @click.stop="emitEditHouse(house)">编辑</el-button>
<el-button size="small" type="danger" @click.stop="emitDeleteHouse(house)">删除</el-button>
</div>
</div>
<div v-if="house.isExpanded" class="house-content">
<div v-if="house.pens && house.pens.length > 0" class="pig-pen-list">
<PigPenInfoCard
v-for="pen in house.pens"
:key="pen.id"
:pen="pen"
@edit="emitEditPen"
@delete="emitDeletePen"
/>
</div>
<div v-else class="no-pens-message">
<p>该猪舍下没有猪栏信息</p>
</div>
</div>
</div>
</div>
</template>
<script>
import PigPenInfoCard from './PigPenInfoCard.vue';
export default {
name: 'PigHouseList',
components: {
PigPenInfoCard
},
props: {
pigHouses: {
type: Array,
required: true
}
},
emits: ['edit-house', 'delete-house', 'add-pen', 'edit-pen', 'delete-pen', 'toggle-house-expand'],
computed: {
enrichedPigHouses() {
return this.pigHouses.map(house => {
const pensWithHouseInfo = house.pens ? house.pens.map(pen => ({
...pen,
house_name: house.name,
house_id: house.id
})) : [];
return {
...house,
pens: pensWithHouseInfo
};
});
}
},
methods: {
emitToggleExpand(houseId) {
this.$emit('toggle-house-expand', houseId);
},
// 猪舍操作
emitAddPen(house) {
this.$emit('add-pen', house);
},
emitEditHouse(house) {
this.$emit('edit-house', house);
},
emitDeleteHouse(house) {
this.$emit('delete-house', house);
},
// 猪栏操作
emitEditPen(pen) {
this.$emit('edit-pen', pen);
},
emitDeletePen(pen) {
this.$emit('delete-pen', pen);
}
}
}
</script>
<style scoped>
.pig-house-list {
width: 100%;
}
.pig-house-item {
border: 1px solid #eee;
border-radius: 4px;
margin-bottom: 16px;
overflow: hidden;
}
.house-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
cursor: pointer;
background-color: #f9f9f9;
transition: background-color 0.3s;
}
.house-header:hover {
background-color: #f0f0f0;
}
.house-info {
display: flex;
flex-direction: column;
gap: 8px;
}
.house-info span:first-child {
font-weight: bold;
}
.house-info span {
margin-right: 20px;
font-size: 14px;
color: #606266;
}
.house-actions {
display: flex;
gap: 10px;
}
.house-content {
padding: 16px;
border-top: 1px solid #eee;
}
.pig-pen-list {
display: flex;
flex-wrap: wrap;
gap: 16px;
}
.no-pens-message {
color: #909399;
text-align: center;
padding: 20px 0;
}
</style>

View File

@@ -0,0 +1,127 @@
<template>
<div class="pig-pen-info-card">
<div class="info-section">
<div class="title">
猪栏: {{ pen.pen_number }} <el-tag size="small" :type="statusType">{{ pen.status || '未知' }}</el-tag>
</div>
<div class="info-item">猪舍: {{ pen.house_name || '未知' }}</div>
<div class="info-item border-left">容量: {{ pen.capacity }}</div>
<div class="info-item">批次: {{ pen.batch_number || '未分配' }}</div>
<div class="info-item border-left">存栏: <span :class="{'over-capacity': pen.current_pig_count > pen.capacity}">{{ pen.current_pig_count || 0 }}</span></div>
</div>
<div class="actions-section">
<el-button size="small" @click="emitEdit">编辑</el-button>
<el-button size="small" type="danger" @click="emitDelete">删除</el-button>
</div>
</div>
</template>
<script>
import { computed } from 'vue';
export default {
name: 'PigPenInfoCard',
props: {
pen: {
type: Object,
required: true
}
},
emits: ['edit', 'delete'],
setup(props, { emit }) {
const statusType = computed(() => {
switch (props.pen.status) {
case '使用中':
return 'success';
case '病猪栏':
case '维修中':
return 'danger';
case '清洗消毒':
return 'warning';
case '空闲':
default:
return 'info';
}
});
const emitEdit = () => {
emit('edit', props.pen);
};
const emitDelete = () => {
emit('delete', props.pen);
};
return {
statusType,
emitEdit,
emitDelete
};
}
}
</script>
<style scoped>
.pig-pen-info-card {
border: 2px solid #ccc;
border-radius: 8px;
padding: 16px;
display: flex;
flex-direction: column;
justify-content: space-between;
width: 220px; /* 适当加宽以容纳更多信息 */
height: auto; /* 让高度自适应内容 */
min-height: 240px; /* 设置最小高度 */
background-color: #fff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
transition: box-shadow 0.3s;
}
.pig-pen-info-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.info-section {
display: grid; /* 使用grid布局创建两列 */
grid-template-columns: 1fr 1fr; /* 两列等宽 */
gap: 8px 10px; /* 行间距 8px, 列间距 10px */
margin-bottom: 10px;
flex-grow: 1; /* 允许信息部分填充可用空间 */
}
.info-section .title {
font-weight: bold;
font-size: 1.1em;
color: #303133;
grid-column: 1 / -1; /* 标题横跨两列 */
margin-bottom: 5px; /* 标题下方增加一点间距 */
display: flex; /* Add flexbox for alignment */
align-items: center; /* Vertically align items */
gap: 8px; /* Space between title text and tag */
}
.info-section .info-item {
font-size: 0.9em;
color: #606266;
}
.info-item.border-left {
border-left: 1px solid #eee; /* 添加左边框作为分隔线 */
padding-left: 10px; /* 增加左内边距,使内容不紧贴边框 */
}
.actions-section {
display: flex;
flex-direction: column;
gap: 8px;
}
.actions-section .el-button {
width: 100%;
margin: 0;
}
.over-capacity {
color: red;
}
</style>

View File

@@ -0,0 +1,605 @@
<template>
<div class="plan-detail">
<div v-if="loading" class="loading">
<el-skeleton animated/>
</div>
<div v-else-if="error" class="error">
<el-alert
:title="'加载计划内容失败 (ID: ' + planId + ')'"
:description="error"
type="error"
show-icon
@close="error = null"
/>
<el-button type="primary" @click="fetchPlan" class="retry-btn">重新加载</el-button>
</div>
<div v-else-if="plan && plan.id">
<el-card class="box-card">
<template #header>
<div class="card-header">
<span>{{ plan.name }} - 内容</span>
<div>
<template v-if="!isSubPlan">
<el-button class="button" type="primary" @click="savePlanContent" v-if="isEditingContent"
:disabled="plan.plan_type === '系统任务'">保存
</el-button>
<el-button class="button" type="danger" @click="cancelEdit" v-if="isEditingContent"
:disabled="plan.plan_type === '系统任务'">取消
</el-button>
<el-button class="button" @click="enterEditMode" v-else :disabled="plan.plan_type === '系统任务'">
编辑内容
</el-button>
</template>
<!-- Dynamic Add Buttons -->
<template v-if="isEditingContent">
<el-button
v-if="plan.content_type === 'sub_plans' || !plan.content_type"
type="primary"
size="small"
@click="showAddSubPlanDialog"
:disabled="plan.plan_type === '系统任务'"
>增加子计划
</el-button>
<el-button
v-if="plan.content_type === 'tasks' || !plan.content_type"
type="primary"
size="small"
@click="showTaskEditorDialog()"
:disabled="plan.plan_type === '系统任务'"
>增加子任务
</el-button>
</template>
</div>
</div>
</template>
<!-- Display Tasks -->
<div v-if="plan.content_type === 'tasks'">
<h4>任务列表</h4>
<el-timeline v-if="plan.tasks.length > 0">
<el-timeline-item
v-for="(task, index) in plan.tasks"
:key="task.id || 'new-task-' + index"
:timestamp="'执行顺序: ' + (task.execution_order !== undefined ? task.execution_order : index + 1)"
placement="top"
>
<el-card>
<h5>{{ task.name }} ({{ task.type === 'waiting' ? '延时任务' : '未知任务' }})</h5>
<p>{{ task.description }}</p>
<p v-if="task.type === 'waiting' && task.parameters?.delay_duration">
延时: {{ task.parameters.delay_duration }}
</p>
<el-button-group v-if="isEditingContent">
<el-button type="primary" size="small" @click="editTask(task)"
:disabled="plan.plan_type === '系统任务'">编辑
</el-button>
<el-button type="danger" size="small" @click="deleteTask(task)"
:disabled="plan.plan_type === '系统任务'">删除
</el-button>
</el-button-group>
</el-card>
</el-timeline-item>
</el-timeline>
<el-empty v-else description="暂无任务"></el-empty>
</div>
<!-- Display Sub-plans -->
<div v-else-if="plan.content_type === 'sub_plans'">
<h4>子计划列表</h4>
<div v-if="plan.sub_plans.length > 0">
<div v-for="(subPlan, index) in plan.sub_plans" :key="subPlan.id || 'new-subplan-' + index"
class="sub-plan-wrapper">
<el-card>
<div class="sub-plan-card-content">
<!-- Pass child_plan_id to recursive PlanDetail -->
<plan-detail :plan-id="subPlan.child_plan_id" :is-sub-plan="true"/>
<el-button-group v-if="isEditingContent" class="sub-plan-actions">
<el-button type="danger" size="small" @click="deleteSubPlan(subPlan)"
:disabled="plan.plan_type === '系统任务'">删除
</el-button>
</el-button-group>
</div>
</el-card>
</div>
</div>
<el-empty v-else description="暂无子计划"></el-empty>
</div>
<div v-else-if="!plan.content_type">
<el-empty description="请添加子计划或子任务"></el-empty>
</div>
</el-card>
</div>
<div v-else>
<el-empty description="没有计划数据"></el-empty>
</div>
<!-- Add Sub-plan Dialog -->
<el-dialog
v-model="addSubPlanDialogVisible"
title="选择子计划"
width="600px"
@close="resetAddSubPlanDialog"
>
<el-select
v-model="selectedSubPlanId"
placeholder="请选择一个计划作为子计划"
filterable
style="width: 100%;"
>
<el-option
v-for="item in availablePlans"
:key="item.id"
:label="item.name"
:value="item.id"
></el-option>
</el-select>
<template #footer>
<span class="dialog-footer">
<el-button @click="addSubPlanDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmAddSubPlan">确定</el-button>
</span>
</template>
</el-dialog>
<!-- Task Editor Dialog (for Add and Edit) -->
<el-dialog
v-model="taskEditorDialogVisible"
:title="isEditingTask ? '编辑子任务' : '增加子任务'"
width="600px"
@close="resetTaskEditorDialog"
>
<el-form :model="currentTaskForm" ref="taskFormRef" :rules="taskFormRules" label-width="100px">
<el-form-item label="任务类型" prop="type">
<el-select v-model="currentTaskForm.type" placeholder="请选择任务类型" style="width: 100%;"
:disabled="isEditingTask || plan.plan_type === '系统任务'">
<!-- Only Delay Task for now -->
<el-option label="延时任务" value="delay_task"></el-option>
</el-select>
</el-form-item>
<el-form-item label="任务名称" prop="name">
<el-input v-model="currentTaskForm.name" placeholder="请输入任务名称"
:disabled="plan.plan_type === '系统任务'"></el-input>
</el-form-item>
<el-form-item label="任务描述" prop="description">
<el-input type="textarea" v-model="currentTaskForm.description" placeholder="请输入任务描述"
:disabled="plan.plan_type === '系统任务'"></el-input>
</el-form-item>
<!-- Dynamic task component for specific parameters -->
<template v-if="currentTaskForm.type === 'delay_task'">
<DelayTaskEditor
:parameters="currentTaskForm.parameters"
@update:parameters="val => currentTaskForm.parameters = val"
prop-path="parameters.delay_duration"
:is-editing="true"
:disabled="plan.plan_type === '系统任务'"
/>
</template>
<!-- More task types can be rendered here -->
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="taskEditorDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmTaskEdit" :disabled="plan.plan_type === '系统任务'">确定</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script>
import apiClient from '../api/index.js';
import {ElMessage, ElMessageBox} from 'element-plus';
import {ArrowDown} from '@element-plus/icons-vue';
import DelayTaskEditor from './tasks/DelayTask.vue';
export default {
name: 'PlanDetail',
components: {
DelayTaskEditor,
// Self-reference for recursion
'plan-detail': this,
ArrowDown,
},
props: {
planId: {
type: [Number, String],
required: true,
},
isSubPlan: {
type: Boolean,
default: false,
},
},
data() {
return {
plan: {
id: null,
name: '',
description: '',
execution_type: 'automatic',
execute_num: 0,
cron_expression: '',
content_type: null,
sub_plans: [],
tasks: [],
},
loading: false,
error: null,
isEditingContent: false,
// Add Sub-plan dialog
addSubPlanDialogVisible: false,
selectedSubPlanId: null,
availablePlans: [],
// Task Editor dialog (for Add and Edit)
taskEditorDialogVisible: false,
isEditingTask: false,
editingTaskOriginalId: null,
currentTaskForm: {
type: 'delay_task',
name: '',
description: '',
parameters: {},
},
taskFormRules: {
type: [{required: true, message: '请选择任务类型', trigger: 'change'}],
name: [{required: true, message: '请输入任务名称', trigger: 'blur'}],
// Rule for delay_duration will be added/removed dynamically
},
};
},
computed: {
delayDurationRules() {
return [{required: true, message: '请输入延时时间', trigger: 'blur'}];
},
},
watch: {
planId: {
immediate: true,
handler(newId) {
if (newId) {
this.fetchPlan();
}
},
},
'currentTaskForm.type'(newType) {
console.log("PlanDetail: currentTaskForm.type changed to", newType);
if (newType === 'delay_task') {
this.taskFormRules['parameters.delay_duration'] = this.delayDurationRules;
} else {
if (this.taskFormRules['parameters.delay_duration']) {
delete this.taskFormRules['parameters.delay_duration'];
console.log("PlanDetail: Removed delay_duration rule.");
}
if (this.currentTaskForm.parameters.delay_duration !== undefined) {
delete this.currentTaskForm.parameters.delay_duration;
console.log("PlanDetail: Removed delay_duration parameter.");
}
}
},
},
methods: {
async fetchPlan() {
this.loading = true;
this.error = null;
try {
const response = await apiClient.plans.getPlanById(this.planId);
this.plan = {
...response.data,
sub_plans: response.data.sub_plans || [],
tasks: response.data.tasks || [],
};
this.updateContentType();
} catch (err) {
this.error = err.message || '未知错误';
console.error(`加载计划 (ID: ${this.planId}) 失败:`, err);
} finally {
this.loading = false;
}
},
updateContentType() {
if (this.plan.sub_plans.length > 0) {
this.plan.content_type = 'sub_plans';
} else if (this.plan.tasks.length > 0) {
this.plan.content_type = 'tasks';
} else {
this.plan.content_type = null;
}
},
enterEditMode() {
this.isEditingContent = true;
console.log("PlanDetail: Entered edit mode.");
},
async savePlanContent() {
this.updateContentType();
try {
const submitData = {
id: this.plan.id,
name: this.plan.name,
description: this.plan.description,
execution_type: this.plan.execution_type,
execute_num: this.plan.execute_num,
cron_expression: this.plan.cron_expression,
sub_plan_ids: this.plan.content_type === 'sub_plans'
? this.plan.sub_plans.map(sp => sp.child_plan_id)
: [],
tasks: this.plan.content_type === 'tasks'
? this.plan.tasks.map((task, index) => ({
name: task.name,
description: task.description,
type: task.type,
execution_order: index + 1,
parameters: task.parameters || {},
}))
: [],
};
delete submitData.execute_count;
delete submitData.status;
console.log("PlanDetail: Submitting data", submitData);
await apiClient.plans.updatePlan(this.planId, submitData);
ElMessage.success('计划内容已保存');
this.isEditingContent = false;
this.fetchPlan();
} catch (error) {
ElMessage.error('保存计划内容失败: ' + (error.message || '未知错误'));
console.error('保存计划内容失败:', error);
}
},
async cancelEdit() {
console.log("PlanDetail: Cancelled edit, re-fetching plan.");
await this.fetchPlan();
this.isEditingContent = false;
ElMessage.info('已取消编辑');
},
// --- Sub-plan related methods ---
async showAddSubPlanDialog() {
if (this.plan.tasks.length > 0) {
try {
await ElMessageBox.confirm('当前计划包含任务,添加子计划将清空现有任务。是否继续?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
});
this.plan.tasks = [];
} catch (e) {
return;
}
}
this.addSubPlanDialogVisible = true;
await this.fetchAvailablePlans();
},
async fetchAvailablePlans() {
try {
const response = await apiClient.plans.getPlans({plan_type: '自定义任务', page: 1, page_size: 1000});
this.availablePlans = response.data.plans.filter(p =>
p.id !== this.planId
);
} catch (error) {
ElMessage.error('加载可用计划失败: ' + (error.message || '未知错误'));
console.error('加载可用计划失败:', error);
}
},
confirmAddSubPlan() {
if (!this.selectedSubPlanId) {
ElMessage.warning('请选择一个子计划');
return;
}
const selectedPlan = this.availablePlans.find(p => p.id === this.selectedSubPlanId);
if (selectedPlan) {
this.plan.sub_plans.push({
id: Date.now(),
child_plan_id: selectedPlan.id,
child_plan: selectedPlan,
execution_order: this.plan.sub_plans.length + 1,
});
this.updateContentType();
ElMessage.success(`子计划 "${selectedPlan.name}" 已添加`);
this.addSubPlanDialogVisible = false;
this.resetAddSubPlanDialog();
} else {
ElMessage.error('未找到选中的计划');
}
},
resetAddSubPlanDialog() {
this.selectedSubPlanId = null;
this.availablePlans = [];
},
deleteSubPlan(subPlanToDelete) {
ElMessageBox.confirm(`确认删除子计划 "${subPlanToDelete.child_plan?.name || '未知子计划'}" 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.plan.sub_plans = this.plan.sub_plans.filter(sub => sub.id !== subPlanToDelete.id);
this.plan.sub_plans.forEach((item, index) => item.execution_order = index + 1);
this.updateContentType();
ElMessage.success('子计划已删除');
}).catch(() => {
});
},
// --- Task related methods ---
showTaskEditorDialog(task = null) {
console.log("PlanDetail: Showing task editor dialog.");
if (this.plan.sub_plans.length > 0 && !task) {
ElMessageBox.confirm('当前计划包含子计划,添加任务将清空现有子计划。是否继续?', '警告', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.plan.sub_plans = [];
this.taskEditorDialogVisible = true;
this.prepareTaskForm(task);
}).catch(() => {
// User cancelled
});
return;
}
this.taskEditorDialogVisible = true;
this.prepareTaskForm(task);
},
prepareTaskForm(task = null) {
// Reset properties of the existing reactive object
this.currentTaskForm.type = 'delay_task';
this.currentTaskForm.name = '';
this.currentTaskForm.description = '';
if (task) {
this.isEditingTask = true;
this.editingTaskOriginalId = task.id;
// Update properties of the existing reactive object
this.currentTaskForm.type = task.type === 'waiting' ? 'delay_task' : task.type; // Convert backend type to UI type
this.currentTaskForm.name = task.name;
this.currentTaskForm.description = task.description;
// Deep copy parameters to ensure reactivity for nested changes
this.currentTaskForm.parameters = JSON.parse(JSON.stringify(task.parameters || {}));
console.log("PlanDetail: Prepared currentTaskForm for editing:", JSON.parse(JSON.stringify(this.currentTaskForm)));
} else {
this.isEditingTask = false;
this.editingTaskOriginalId = null;
// For new tasks, ensure delay_duration is reactive from start
this.currentTaskForm.parameters = {delay_duration: null};
console.log("PlanDetail: Prepared currentTaskForm for adding:", JSON.parse(JSON.stringify(this.currentTaskForm)));
}
// Manually trigger watch for type to ensure rules and default parameters are set
this.updateTaskFormRules();
},
updateTaskFormRules() {
// Clear existing dynamic rules
if (this.taskFormRules['parameters.delay_duration']) {
delete this.taskFormRules['parameters.delay_duration'];
}
// Apply rules based on current type
if (this.currentTaskForm.type === 'delay_task') {
this.taskFormRules['parameters.delay_duration'] = this.delayDurationRules;
}
console.log("PlanDetail: Updated taskFormRules:", JSON.parse(JSON.stringify(this.taskFormRules)));
},
confirmTaskEdit() {
console.log("PlanDetail: confirmTaskEdit called. currentTaskForm before validation:", JSON.parse(JSON.stringify(this.currentTaskForm)));
this.$refs.taskFormRef.validate(async (valid) => {
console.log("PlanDetail: Form validation result:", valid);
if (valid) {
if (this.isEditingTask) {
// Find and update the existing task
const index = this.plan.tasks.findIndex(t => t.id === this.editingTaskOriginalId);
if (index !== -1) {
// Create a new task object to ensure reactivity
const updatedTask = {
...this.plan.tasks[index], // Keep existing properties
name: this.currentTaskForm.name,
description: this.currentTaskForm.description,
type: this.currentTaskForm.type === 'delay_task' ? 'waiting' : this.currentTaskForm.type,
parameters: {...this.currentTaskForm.parameters}, // Deep copy parameters to ensure new reference
};
this.plan.tasks.splice(index, 1, updatedTask); // Replace the old task with the new one
ElMessage.success(`子任务 "${this.currentTaskForm.name}" 已更新`);
} else {
ElMessage.error('未找到要编辑的任务');
}
} else {
// Add a new task
const newTask = {
id: Date.now(),
execution_order: this.plan.tasks.length + 1,
type: this.currentTaskForm.type === 'delay_task' ? 'waiting' : this.currentTaskForm.type,
name: this.currentTaskForm.name,
description: this.currentTaskForm.description,
parameters: {...this.currentTaskForm.parameters}, // Deep copy parameters to ensure new reference
};
this.plan.tasks = [...this.plan.tasks, newTask]; // Create a new array reference
ElMessage.success(`子任务 "${newTask.name}" 已添加`);
}
this.updateContentType();
this.taskEditorDialogVisible = false;
this.resetTaskEditorDialog();
}
});
},
resetTaskEditorDialog() {
console.log("PlanDetail: Resetting task editor dialog.");
this.$refs.taskFormRef.resetFields();
this.isEditingTask = false;
this.editingTaskOriginalId = null;
// Manually reset properties to ensure clean state for next use
this.currentTaskForm.type = 'delay_task';
this.currentTaskForm.name = '';
this.currentTaskForm.description = '';
this.currentTaskForm.parameters = {};
console.log("PlanDetail: currentTaskForm after full reset:", JSON.parse(JSON.stringify(this.currentTaskForm)));
this.updateTaskFormRules();
},
editTask(task) {
console.log('PlanDetail: Calling showTaskEditorDialog for editing task:', task);
this.showTaskEditorDialog(task);
},
deleteTask(taskToDelete) {
ElMessageBox.confirm(`确认删除任务 "${taskToDelete.name}" 吗?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.plan.tasks = this.plan.tasks.filter(task => task.id !== taskToDelete.id);
this.plan.tasks.forEach((item, index) => item.execution_order = index + 1);
this.updateContentType();
ElMessage.success('任务已删除');
}).catch(() => {
});
},
},
};
</script>
<style scoped>
.plan-detail {
margin-top: 10px;
}
.loading, .error {
padding: 20px;
text-align: center;
}
.retry-btn {
margin-top: 15px;
}
.sub-plan-wrapper {
margin-bottom: 10px;
}
.sub-plan-container {
margin-left: 20px;
margin-top: 10px;
border-left: 2px solid #ebeef5;
padding-left: 10px;
}
.sub-plan-card-content {
display: flex;
flex-direction: column;
gap: 10px;
}
.sub-plan-actions {
align-self: flex-end;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
/* 调整子计划卡片内部的header避免重复样式 */
.sub-plan-container .card-header {
padding: 0;
}
</style>

252
src/components/PlanForm.vue Normal file
View File

@@ -0,0 +1,252 @@
<template>
<el-dialog
:model-value="visible"
:title="title"
@close="handleClose"
:close-on-click-modal="false"
width="600px"
>
<el-form
ref="formRef"
:model="formData"
:rules="rules"
label-width="120px"
@submit.prevent
>
<!-- 任务名 -->
<el-form-item label="任务名" prop="name">
<el-input v-model="formData.name" placeholder="请输入任务名" />
</el-form-item>
<!-- 任务描述 -->
<el-form-item label="任务描述" prop="description">
<el-input
v-model="formData.description"
type="textarea"
placeholder="请输入任务描述"
:rows="3"
/>
</el-form-item>
<!-- 执行方式 -->
<el-form-item label="执行方式" prop="execution_type">
<el-radio-group v-model="formData.execution_type" @change="handleExecutionTypeChange">
<el-radio label="automatic">自动执行</el-radio>
<el-radio label="manual">手动执行</el-radio>
</el-radio-group>
</el-form-item>
<!-- 执行次数 -->
<el-form-item
label="执行次数"
prop="execute_num"
v-if="formData.execution_type === 'automatic'">
<el-input-number
v-model="formData.execute_num"
:min="0"
:max="999999"
placeholder="请输入执行次数"
controls-position="right"
style="width: 100%"
/>
<div class="form-item-tip">0表示无限执行正数表示执行次数</div>
</el-form-item>
<!-- 执行频率 (仅自动执行时显示) -->
<el-form-item
label="执行频率"
prop="cron_expression"
v-if="formData.execution_type === 'automatic'">
<cron-expression-editor
v-model="formData.cron_expression"
/>
<div class="form-item-tip">Cron表达式格式 </div>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="handleClose">取消</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="loading">
{{ isEdit ? '更新' : '创建' }}
</el-button>
</div>
</template>
</el-dialog>
</template>
<script>
import { ref, reactive, computed, watch } from 'vue';
import { ElMessage } from 'element-plus';
import CronExpressionEditor from './CronExpressionEditor.vue';
export default {
name: 'PlanForm',
components: {
CronExpressionEditor
},
props: {
visible: {
type: Boolean,
default: false
},
planData: {
type: Object,
default: () => ({})
},
isEdit: {
type: Boolean,
default: false
}
},
emits: ['update:visible', 'success', 'cancel'],
setup(props, { emit }) {
// 表单引用
const formRef = ref(null);
// 加载状态
const loading = ref(false);
// 表单数据
const formData = reactive({
id: null,
name: '',
description: '',
execution_type: 'automatic', // 默认自动执行
execute_num: 0, // 0表示无限执行
cron_expression: '', // cron表达式
content_type: 'tasks' // 默认类型为tasks
});
// 表单验证规则
const rules = {
name: [
{ required: true, message: '请输入任务名', trigger: 'blur' }
],
execution_type: [
{ required: true, message: '请选择执行方式', trigger: 'change' }
],
execute_num: [
{ required: true, message: '请输入执行次数', trigger: 'blur' }
],
cron_expression: [
{ required: true, message: '请输入执行频率(cron表达式)', trigger: 'blur' }
]
};
// 标题计算
const title = computed(() => {
return props.isEdit ? '编辑计划' : '创建计划';
});
// 处理执行方式变更
const handleExecutionTypeChange = (value) => {
// 如果切换为手动执行清空执行次数和cron表达式
if (value === '手动') {
formData.execute_num = 0;
formData.cron_expression = '';
} else {
// 切换为自动执行时,默认设置为无限执行
formData.execute_num = 0;
formData.cron_expression = '';
}
};
// 关闭对话框
const handleClose = () => {
emit('update:visible', false);
emit('cancel');
};
// 提交表单
const handleSubmit = async () => {
await formRef.value.validate(async (valid) => {
if (valid) {
loading.value = true;
try {
// 准备提交数据
const submitData = {
...formData
};
// 确保content_type字段有默认值
if (!submitData.content_type) {
submitData.content_type = 'tasks';
}
// 如果是手动执行清除执行次数和cron表达式
if (formData.execution_type === '手动') {
submitData.execute_num = 0;
submitData.cron_expression = '';
}
emit('success', submitData);
handleClose();
} catch (error) {
console.error('保存计划失败:', error);
ElMessage.error(props.isEdit ? '更新计划失败' : '创建计划失败');
} finally {
loading.value = false;
}
}
});
};
// 监听计划数据变化
watch(() => props.planData, (newVal) => {
if (newVal && Object.keys(newVal).length > 0) {
// 填充表单数据
Object.keys(formData).forEach(key => {
if (newVal[key] !== undefined) {
formData[key] = newVal[key];
}
});
// 确保content_type字段有默认值
if (!formData.content_type) {
formData.content_type = 'tasks';
}
} else {
// 重置表单数据
Object.keys(formData).forEach(key => {
if (key === 'execute_num') {
formData[key] = 0;
} else if (key === 'content_type') {
formData[key] = 'tasks'; // 默认类型为tasks
} else {
formData[key] = '';
}
});
// 默认值
formData.execution_type = 'automatic';
formData.content_type = 'tasks'; // 确保content_type有默认值
}
}, { immediate: true });
return {
formRef,
loading,
formData,
rules,
title,
handleExecutionTypeChange,
handleClose,
handleSubmit
};
}
};
</script>
<style scoped>
.dialog-footer {
text-align: right;
}
.form-item-tip {
font-size: 12px;
color: #909399;
margin-top: 5px;
}
</style>

View File

@@ -1,220 +0,0 @@
<template>
<div class="plan-list">
<el-card>
<template #header>
<div class="card-header">
<h2 class="page-title">计划管理</h2>
<el-button type="primary" @click="addPlan">添加计划</el-button>
</div>
</template>
<el-table :data="plans" style="width: 100%">
<el-table-column prop="id" label="计划ID" width="80" />
<el-table-column prop="name" label="计划名称" width="180" />
<el-table-column prop="description" label="描述" />
<el-table-column prop="execution_type" label="执行类型" width="120">
<template #default="scope">
<el-tag v-if="scope.row.execution_type === 'automatic'">自动</el-tag>
<el-tag v-else>手动</el-tag>
</template>
</el-table-column>
<el-table-column prop="status" label="状态" width="100">
<template #default="scope">
<el-tag v-if="scope.row.status === 0" type="success">启用</el-tag>
<el-tag v-else-if="scope.row.status === 1" type="warning">禁用</el-tag>
<el-tag v-else type="info">已完成</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button size="small" @click="editPlan(scope.row)">编辑</el-button>
<el-button size="small" @click="startPlan(scope.row)" :loading="startingPlanId === scope.row.id">
启动
</el-button>
<el-button size="small" type="danger" @click="deletePlan(scope.row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 添加/编辑计划对话框 -->
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="50%">
<el-form :model="currentPlan" label-width="120px">
<el-form-item label="计划名称">
<el-input v-model="currentPlan.name" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="currentPlan.description" type="textarea" />
</el-form-item>
<el-form-item label="执行类型">
<el-select v-model="currentPlan.execution_type" placeholder="请选择执行类型">
<el-option label="自动执行" value="automatic" />
<el-option label="手动执行" value="manual" />
</el-select>
</el-form-item>
<el-form-item label="内容类型" v-if="!isEdit">
<el-radio-group v-model="contentType">
<el-radio label="tasks">任务</el-radio>
<el-radio label="sub_plans">子计划</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="savePlan">保存</el-button>
</span>
</template>
</el-dialog>
</div>
</template>
<script>
import apiClient from '../api/index.js';
export default {
name: 'PlanList',
data() {
return {
plans: [],
dialogVisible: false,
dialogTitle: '',
isEdit: false,
contentType: 'tasks',
currentPlan: {
id: null,
name: '',
description: '',
execution_type: 'automatic',
content_type: 'tasks'
},
startingPlanId: null
};
},
async mounted() {
await this.loadPlans();
},
methods: {
// 加载计划列表
async loadPlans() {
try {
const response = await apiClient.plans.list();
this.plans = response.data?.plans || [];
} catch (err) {
this.$message.error('加载计划列表失败: ' + (err.message || '未知错误'));
console.error('加载计划列表失败:', err);
}
},
addPlan() {
this.dialogTitle = '添加计划';
this.currentPlan = {
id: null,
name: '',
description: '',
execution_type: 'automatic'
};
this.isEdit = false;
this.dialogVisible = true;
},
editPlan(plan) {
this.dialogTitle = '编辑计划';
this.currentPlan = { ...plan };
this.isEdit = true;
this.dialogVisible = true;
},
async deletePlan(plan) {
try {
await this.$confirm('确认删除该计划吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
});
await apiClient.plans.delete(plan.id);
this.$message.success('删除成功');
await this.loadPlans();
} catch (err) {
if (err !== 'cancel') {
this.$message.error('删除失败: ' + (err.message || '未知错误'));
}
}
},
async startPlan(plan) {
try {
this.startingPlanId = plan.id;
await apiClient.plans.start(plan.id);
this.$message.success('计划启动成功');
await this.loadPlans();
} catch (err) {
this.$message.error('启动失败: ' + (err.message || '未知错误'));
} finally {
this.startingPlanId = null;
}
},
async savePlan() {
try {
if (this.isEdit) {
// 编辑计划
await apiClient.plans.update(this.currentPlan.id, this.currentPlan);
this.$message.success('计划更新成功');
} else {
// 添加新计划
const planData = {
...this.currentPlan,
content_type: this.contentType
};
await apiClient.plans.create(planData);
this.$message.success('计划添加成功');
}
this.dialogVisible = false;
await this.loadPlans();
} catch (err) {
this.$message.error('保存失败: ' + (err.message || '未知错误'));
}
}
}
};
</script>
<style scoped>
.plan-list {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 0;
}
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: bold;
}
.dialog-footer {
text-align: right;
}
@media (max-width: 768px) {
.plan-list {
padding: 10px;
}
.card-header {
flex-direction: column;
gap: 15px;
}
}
</style>

View File

@@ -0,0 +1,204 @@
<template>
<el-dialog
title="跨群调栏"
:model-value="visible"
@update:model-value="$emit('update:visible', $event)"
width="40%"
@close="resetForm"
>
<el-form ref="form" :model="form" :rules="rules" label-width="120px">
<el-form-item label="源猪群批次">
<span>{{ batch.batch_number }}</span>
</el-form-item>
<el-form-item label="源猪栏" prop="fromPenID">
<el-select v-model="form.fromPenID" placeholder="请选择源猪栏" style="width: 100%;">
<el-option
v-for="pen in sourcePens"
:key="pen.id"
:label="`${pen.pen_number} (存栏: ${pen.current_pig_count})`"
:value="pen.id"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="目标猪群批次" prop="destBatchID">
<el-select v-model="form.destBatchID" placeholder="请选择目标猪群" style="width: 100%;" @change="onDestBatchChange">
<el-option
v-for="b in availableBatches"
:key="b.id"
:label="b.batch_number"
:value="b.id"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="目标猪栏" prop="toPenID">
<el-select v-model="form.toPenID" placeholder="请选择目标猪栏" style="width: 100%;">
<el-option
v-for="pen in destinationPens"
:key="pen.id"
:label="`${pen.pen_number} (容量: ${pen.capacity})`"
:value="pen.id"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="调栏数量" prop="quantity">
<el-input-number v-model="form.quantity" :min="1" :max="maxTransferQuantity" />
</el-form-item>
<el-form-item label="备注" prop="remarks">
<el-input v-model="form.remarks" type="textarea" :rows="2" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="$emit('update:visible', false)"> </el-button>
<el-button type="primary" @click="handleConfirm"> </el-button>
</span>
</template>
</el-dialog>
</template>
<script>
import { getPigBatches, transferPigsAcrossBatches } from '@/api/pigBatch';
import { getPens } from '@/api/pen';
export default {
name: 'TransferPigsAcrossBatchesModal',
props: {
visible: {
type: Boolean,
required: true
},
batch: {
type: Object,
required: true // 源猪群批次信息
}
},
emits: ['update:visible', 'success'],
data() {
return {
form: {
fromPenID: null,
destBatchID: null,
toPenID: null,
quantity: 1,
remarks: ''
},
rules: {
fromPenID: [{ required: true, message: '请选择源猪栏', trigger: 'change' }],
destBatchID: [{ required: true, message: '请选择目标猪群批次', trigger: 'change' }],
toPenID: [{ required: true, message: '请选择目标猪栏', trigger: 'change' }],
quantity: [
{ required: true, message: '请输入调栏数量', trigger: 'blur' },
{ type: 'integer', message: '请输入整数', trigger: 'blur' },
{ validator: this.validateQuantity, trigger: 'blur' }
]
},
sourcePens: [], // 源猪群的猪栏列表
availableBatches: [], // 可用的目标猪群列表
destinationPens: [], // 目标猪群的猪栏列表
maxTransferQuantity: 1 // 最大可调栏数量
};
},
watch: {
visible(newVal) {
if (newVal) {
this.initData();
}
},
'form.fromPenID': function(newVal) {
if (newVal) {
const selectedPen = this.sourcePens.find(pen => pen.id === newVal);
this.maxTransferQuantity = selectedPen ? selectedPen.current_pig_count : 1;
// 如果当前数量大于最大值,重置数量
if (this.form.quantity > this.maxTransferQuantity) {
this.form.quantity = this.maxTransferQuantity;
}
}
}
},
methods: {
async initData() {
this.resetForm();
this.sourcePens = this.batch.pens.filter(pen => pen.current_pig_count > 0); // 过滤掉没有猪的猪栏
await this.fetchAvailableBatchesAndPens();
},
async fetchAvailableBatchesAndPens() {
try {
const [batchesResponse, pensResponse] = await Promise.all([
getPigBatches({ is_active: true }), // 获取所有活跃猪群
getPens() // 获取所有猪栏
]);
const allBatches = batchesResponse.data || [];
const allPens = pensResponse.data || [];
// 过滤掉源猪群自身,以及非活跃的猪群
this.availableBatches = allBatches.filter(b => b.id !== this.batch.id && b.is_active);
// 将所有猪栏按批次ID分组方便后续查找
const pensByBatch = allPens.reduce((acc, pen) => {
if (pen.pig_batch_id) {
if (!acc[pen.pig_batch_id]) {
acc[pen.pig_batch_id] = [];
}
acc[pen.pig_batch_id].push(pen);
}
return acc;
}, {});
this.pensByBatch = pensByBatch; // 存储起来,以便 onDestBatchChange 使用
} catch (error) {
console.error("Error fetching batches or pens:", error);
this.$message.error("获取猪群或猪栏信息失败");
}
},
onDestBatchChange(batchId) {
this.form.toPenID = null; // 重置目标猪栏选择
// 允许选择目标批次下的所有猪栏,包括已满的
this.destinationPens = (this.pensByBatch[batchId] || []);
},
validateQuantity(rule, value, callback) {
if (value > this.maxTransferQuantity) {
callback(new Error(`调栏数量不能超过源猪栏存栏数量 (${this.maxTransferQuantity})`));
} else {
callback();
}
},
handleConfirm() {
this.$refs.form.validate(async valid => {
if (valid) {
try {
await transferPigsAcrossBatches(this.batch.id, {
fromPenID: this.form.fromPenID,
destBatchID: this.form.destBatchID,
toPenID: this.form.toPenID,
quantity: this.form.quantity,
remarks: this.form.remarks
});
this.$message.success('跨群调栏成功');
this.$emit('success');
this.$emit('update:visible', false);
} catch (error) {
console.error('Error transferring pigs across batches:', error);
this.$message.error('跨群调栏失败: ' + (error.response?.data?.message || error.message || '未知错误'));
}
}
});
},
resetForm() {
this.$refs.form?.resetFields();
this.form.fromPenID = null;
this.form.destBatchID = null;
this.form.toPenID = null;
this.form.quantity = 1;
this.form.remarks = '';
this.sourcePens = [];
this.availableBatches = [];
this.destinationPens = [];
this.maxTransferQuantity = 1;
}
}
};
</script>
<style scoped>
</style>

View File

@@ -0,0 +1,161 @@
<template>
<el-dialog
title="群内调栏"
:model-value="visible"
@update:model-value="$emit('update:visible', $event)"
width="500px"
:before-close="handleClose"
>
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="调出猪栏" prop="fromPenID">
<el-select v-model="form.fromPenID" placeholder="请选择调出猪栏" style="width: 100%;" @change="handleFromPenChange">
<el-option
v-for="pen in sourcePens"
:key="pen.id"
:label="`${pen.pen_number} (存栏: ${pen.current_pig_count})`"
:value="pen.id"
:disabled="pen.current_pig_count === 0"
/>
</el-select>
</el-form-item>
<el-form-item label="调入猪栏" prop="toPenID">
<el-select v-model="form.toPenID" placeholder="请选择调入猪栏" style="width: 100%;" :disabled="!form.fromPenID">
<el-option
v-for="pen in destinationPens"
:key="pen.id"
:label="`${pen.pen_number} (存栏: ${pen.current_pig_count})`"
:value="pen.id"
/>
</el-select>
</el-form-item>
<el-form-item label="调栏数量" prop="quantity">
<el-input-number
v-model="form.quantity"
:min="1"
:max="maxQuantity"
:disabled="!form.fromPenID"
placeholder="请输入数量"
style="width: 100%;"
/>
</el-form-item>
<el-form-item label="备注" prop="remarks">
<el-input v-model="form.remarks" type="textarea" placeholder="请输入备注" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="handleClose"> </el-button>
<el-button type="primary" @click="handleSubmit" :loading="loading"> </el-button>
</span>
</template>
</el-dialog>
</template>
<script>
import { transferPigsWithinBatch } from '@/api/pigBatch.js';
export default {
name: 'TransferPigsModal',
props: {
visible: {
type: Boolean,
default: false,
},
batch: {
type: Object,
required: true,
},
},
emits: ['update:visible', 'success'],
data() {
return {
loading: false,
form: {
fromPenID: null,
toPenID: null,
quantity: 1,
remarks: '',
},
rules: {
fromPenID: [{ required: true, message: '请选择调出猪栏', trigger: 'change' }],
toPenID: [{ required: true, message: '请选择调入猪栏', trigger: 'change' }],
quantity: [{ required: true, message: '请输入调栏数量', trigger: 'blur' }],
},
};
},
computed: {
sourcePens() {
return this.batch.pens || [];
},
destinationPens() {
if (!this.form.fromPenID) return [];
return this.batch.pens.filter(pen => pen.id !== this.form.fromPenID);
},
maxQuantity() {
if (!this.form.fromPenID) return 1;
const selectedPen = this.sourcePens.find(p => p.id === this.form.fromPenID);
return selectedPen ? selectedPen.current_pig_count : 1;
},
},
watch: {
visible(newVal) {
if (newVal) {
this.resetForm();
}
},
},
methods: {
handleFromPenChange(penId) {
this.form.toPenID = null;
this.form.quantity = 1;
const selectedPen = this.sourcePens.find(p => p.id === penId);
if (selectedPen && selectedPen.current_pig_count === 0) {
this.$message.warning('该猪栏没有猪,无法调出。');
this.form.fromPenID = null;
}
},
handleSubmit() {
this.$refs.formRef.validate(async (valid) => {
if (valid) {
if (this.form.quantity > this.maxQuantity) {
this.$message.error('调栏数量不能超过当前存栏量');
return;
}
this.loading = true;
try {
await transferPigsWithinBatch(this.batch.id, this.form);
this.$message.success('调栏成功');
this.$emit('success');
this.handleClose();
} catch (error) {
this.$message.error('调栏失败: ' + (error.response?.data?.message || error.message));
console.error('Failed to transfer pigs:', error);
} finally {
this.loading = false;
}
}
});
},
handleClose() {
this.$emit('update:visible', false);
},
resetForm() {
if (this.$refs.formRef) {
this.$refs.formRef.resetFields();
}
this.form = {
fromPenID: null,
toPenID: null,
quantity: 1,
remarks: '',
};
},
},
};
</script>
<style scoped>
.dialog-footer {
text-align: right;
}
</style>

View File

View File

@@ -0,0 +1,67 @@
<template>
<div class="delay-task-editor">
<el-form-item
label="延时时间(秒)"
:prop="propPath"
:rules="isEditing ? {
required: true,
message: '请输入延时时间',
trigger: 'blur'
} : null"
class="delay-time-form-item"
>
<el-input-number
:model-value="delayDuration"
@update:model-value="updateDelayDuration"
placeholder="请输入延时时间"
style="width: 100%;"
></el-input-number>
</el-form-item>
</div>
</template>
<script>
export default {
name: "DelayTaskEditor",
props: {
// The parameters object for the task
parameters: {
type: Object,
required: true,
},
// The full prop path for validation, e.g., 'parameters.delay_duration'
propPath: {
type: String,
default: "parameters.delay_duration", // Updated default prop path
},
// New prop to control editability
isEditing: {
type: Boolean,
default: true,
},
},
emits: ["update:parameters"],
computed: {
delayDuration() { // Renamed from delaySeconds
return this.parameters?.delay_duration;
},
},
methods: {
updateDelayDuration(value) { // Renamed from updateDelaySeconds
this.$emit("update:parameters", {
...this.parameters,
delay_duration: value,
});
},
},
};
</script>
<style scoped>
.delay-task-editor {
width: 100%;
}
.delay-time-form-item :deep(.el-form-item__label) {
white-space: nowrap;
}
</style>

View File

@@ -15,19 +15,130 @@
:collapse="isCollapse"
:collapse-transition="false"
router
:default-openeds="['/device-management', '/monitor', '/pms']"
>
<el-menu-item index="/">
<el-icon><House /></el-icon>
<template #title>首页</template>
</el-menu-item>
<el-menu-item index="/devices">
<el-icon><Monitor /></el-icon>
<template #title>设备管理</template>
</el-menu-item>
<!-- 设备管理二级菜单 -->
<el-sub-menu index="/device-management">
<template #title>
<el-icon><Setting /></el-icon>
<span>设备</span>
</template>
<el-menu-item index="/devices">
<el-icon><Monitor /></el-icon>
<template #title>设备管理</template>
</el-menu-item>
<el-menu-item index="/device-templates">
<el-icon><Tickets /></el-icon>
<template #title>设备模板管理</template>
</el-menu-item>
</el-sub-menu>
<!-- 猪场管理二级菜单 -->
<el-sub-menu index="/pms">
<template #title>
<el-icon><OfficeBuilding /></el-icon>
<span>猪场管理</span>
</template>
<el-menu-item index="/pms/farm-management">
<el-icon><Tickets /></el-icon>
<template #title>栏舍管理</template>
</el-menu-item>
<el-menu-item index="/pms/batch-management">
<el-icon><Management /></el-icon>
<template #title>猪群管理</template>
</el-menu-item>
</el-sub-menu>
<el-menu-item index="/plans">
<el-icon><Calendar /></el-icon>
<template #title>计划管理</template>
</el-menu-item>
<!-- 系统监控二级菜单 -->
<el-sub-menu index="/monitor">
<template #title>
<el-icon><DataAnalysis /></el-icon>
<span>系统监控</span>
</template>
<el-menu-item index="/monitor/device-command-logs">
<el-icon><Document /></el-icon>
<template #title>设备命令日志</template>
</el-menu-item>
<el-menu-item index="/monitor/feed-usage-records">
<el-icon><Food /></el-icon>
<template #title>饲料使用记录</template>
</el-menu-item>
<el-menu-item index="/monitor/medication-logs">
<el-icon><FirstAidKit /></el-icon>
<template #title>用药记录</template>
</el-menu-item>
<el-menu-item index="/monitor/notifications">
<el-icon><Bell /></el-icon>
<template #title>通知记录</template>
</el-menu-item>
<el-menu-item index="/monitor/pending-collections">
<el-icon><Clock /></el-icon>
<template #title>待采集请求</template>
</el-menu-item>
<el-menu-item index="/monitor/pig-batch-logs">
<el-icon><Files /></el-icon>
<template #title>猪批次日志</template>
</el-menu-item>
<el-menu-item index="/monitor/pig-purchases">
<el-icon><ShoppingCart /></el-icon>
<template #title>猪只采购记录</template>
</el-menu-item>
<el-menu-item index="/monitor/pig-sales">
<el-icon><SoldOut /></el-icon>
<template #title>猪只售卖记录</template>
</el-menu-item>
<el-menu-item index="/monitor/pig-sick-logs">
<el-icon><Warning /></el-icon>
<template #title>病猪日志</template>
</el-menu-item>
<el-menu-item index="/monitor/pig-transfer-logs">
<el-icon><Switch /></el-icon>
<template #title>猪只迁移日志</template>
</el-menu-item>
<el-menu-item index="/monitor/raw-material-purchases">
<el-icon><Shop /></el-icon>
<template #title>原料采购记录</template>
</el-menu-item>
<el-menu-item index="/monitor/raw-material-stock-logs">
<el-icon><Coin /></el-icon>
<template #title>原料库存日志</template>
</el-menu-item>
<el-menu-item index="/monitor/sensor-data">
<el-icon><DataLine /></el-icon>
<template #title>传感器数据</template>
</el-menu-item>
<el-menu-item index="/monitor/plan-execution-logs">
<el-icon><List /></el-icon>
<template #title>计划执行日志</template>
</el-menu-item>
<el-menu-item index="/monitor/task-execution-logs">
<el-icon><Finished /></el-icon>
<template #title>任务执行日志</template>
</el-menu-item>
<el-menu-item index="/monitor/user-action-logs">
<el-icon><User /></el-icon>
<template #title>用户操作日志</template>
</el-menu-item>
<el-menu-item index="/monitor/weighing-batches">
<el-icon><ScaleToOriginal /></el-icon>
<template #title>批次称重记录</template>
</el-menu-item>
<el-menu-item index="/monitor/weighing-records">
<el-icon><ScaleToOriginal /></el-icon>
<template #title>单次称重记录</template>
</el-menu-item>
</el-sub-menu>
</el-menu>
</el-aside>
@@ -45,12 +156,12 @@
<div class="user-info">
<el-dropdown>
<span class="el-dropdown-link">
管理员 <el-icon><ArrowDown /></el-icon>
{{ username }} <el-icon><ArrowDown /></el-icon>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item>个人信息</el-dropdown-item>
<el-dropdown-item>退出登录</el-dropdown-item>
<el-dropdown-item @click="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
@@ -72,50 +183,93 @@
</template>
<script>
import { ref, computed } from 'vue';
import { useRoute } from 'vue-router';
import { House, Monitor, Calendar, ArrowDown, Menu, Fold, Expand } from '@element-plus/icons-vue';
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import {
House, Monitor, Calendar, ArrowDown, Menu, Fold, Expand, Setting, Tickets, DataAnalysis, Document, Food,
FirstAidKit, Clock, Files, ShoppingCart, SoldOut, Warning, Switch, List, Shop, Coin, DataLine, Finished, User, ScaleToOriginal, OfficeBuilding, Management, Bell
} from '@element-plus/icons-vue';
export default {
name: 'MainLayout',
components: {
House,
Monitor,
Calendar,
ArrowDown,
Menu,
Fold,
Expand
House, Monitor, Calendar, ArrowDown, Menu, Fold, Expand, Setting, Tickets, DataAnalysis, Document, Food,
FirstAidKit, Clock, Files, ShoppingCart, SoldOut, Warning, Switch, List, Shop, Coin, DataLine, Finished, User, ScaleToOriginal, OfficeBuilding, Management, Bell
},
setup() {
const route = useRoute();
const router = useRouter();
const isCollapse = ref(false);
const username = ref(localStorage.getItem('username') || '管理员');
const handleStorageChange = () => {
username.value = localStorage.getItem('username') || '管理员';
};
onMounted(() => {
window.addEventListener('storage', handleStorageChange);
});
onUnmounted(() => {
window.removeEventListener('storage', handleStorageChange);
});
// 切换侧边栏折叠状态
const toggleCollapse = () => {
isCollapse.value = !isCollapse.value;
};
// 当前激活的菜单项
const activeMenu = computed(() => {
const path = route.path;
if (path.startsWith('/monitor') || path.startsWith('/pms') || path.startsWith('/devices') || path.startsWith('/device-templates')) {
return path;
}
return route.path;
});
// 当前页面标题
const currentPageTitle = computed(() => {
const routeMap = {
'/': '系统首页',
'/devices': '设备管理',
'/plans': '计划管理'
'/device-templates': '设备模板管理',
'/pms/farm-management': '栏舍管理',
'/pms/batch-management': '猪群管理',
'/plans': '计划管理',
'/monitor/device-command-logs': '设备命令日志',
'/monitor/feed-usage-records': '饲料使用记录',
'/monitor/medication-logs': '用药记录',
'/monitor/notifications': '通知记录',
'/monitor/pending-collections': '待采集请求',
'/monitor/pig-batch-logs': '猪批次日志',
'/monitor/pig-purchases': '猪只采购记录',
'/monitor/pig-sales': '猪只售卖记录',
'/monitor/pig-sick-logs': '病猪日志',
'/monitor/pig-transfer-logs': '猪只迁移日志',
'/monitor/plan-execution-logs': '计划执行日志',
'/monitor/raw-material-purchases': '原料采购记录',
'/monitor/raw-material-stock-logs': '原料库存日志',
'/monitor/sensor-data': '传感器数据',
'/monitor/task-execution-logs': '任务执行日志',
'/monitor/user-action-logs': '用户操作日志',
'/monitor/weighing-batches': '批次称重记录',
'/monitor/weighing-records': '单次称重记录',
};
return routeMap[route.path] || '猪场管理系统';
});
const logout = () => {
localStorage.removeItem('jwt_token');
localStorage.removeItem('username');
username.value = '管理员';
router.push('/login');
};
return {
isCollapse,
activeMenu,
currentPageTitle,
toggleCollapse
toggleCollapse,
logout,
username
};
}
};
@@ -205,4 +359,4 @@ export default {
font-size: 14px;
border-top: 1px solid #eee;
}
</style>
</style>

View File

@@ -1,40 +1,102 @@
import { createApp } from 'vue';
import { createRouter, createWebHistory } from 'vue-router';
import {createApp} from 'vue';
import {createRouter, createWebHistory} from 'vue-router';
import ElementPlus from 'element-plus';
import 'element-plus/dist/index.css';
import zhCn from 'element-plus/dist/locale/zh-cn.mjs'; // 导入 Element Plus 中文语言包
import App from './App.vue';
import Home from './components/Home.vue';
import DeviceList from './components/DeviceList.vue';
import PlanList from './components/PlanList.vue';
import Home from './views/home/Home.vue';
import DeviceList from './views/device/DeviceList.vue';
import PlanList from './views/plan/PlanList.vue';
import LoginForm from './views/home/LoginForm.vue';
import DeviceTemplateList from './views/device/DeviceTemplateList.vue';
import PigFarmManagementView from './views/pms/PigFarmManagementView.vue'; // 导入栏舍管理视图
import PigBatchManagementView from './views/pms/PigBatchManagementView.vue'; // 导入猪群管理视图
// --- 统一导入所有监控视图 ---
import DeviceCommandLogView from './views/monitor/DeviceCommandLogView.vue';
import FeedUsageRecordsView from './views/monitor/FeedUsageRecordsView.vue';
import MedicationLogsView from './views/monitor/MedicationLogsView.vue';
import NotificationLogView from './views/monitor/NotificationLogView.vue';
import PendingCollectionsView from './views/monitor/PendingCollectionsView.vue';
import PigBatchLogsView from './views/monitor/PigBatchLogsView.vue';
import PigPurchasesView from './views/monitor/PigPurchasesView.vue';
import PigSalesView from './views/monitor/PigSalesView.vue';
import PigSickLogsView from './views/monitor/PigSickLogsView.vue';
import PigTransferLogsView from './views/monitor/PigTransferLogsView.vue';
import PlanExecutionLogsView from './views/monitor/PlanExecutionLogsView.vue';
import RawMaterialPurchasesView from './views/monitor/RawMaterialPurchasesView.vue';
import RawMaterialStockLogsView from './views/monitor/RawMaterialStockLogsView.vue';
import SensorDataView from './views/monitor/SensorDataView.vue';
import TaskExecutionLogsView from './views/monitor/TaskExecutionLogsView.vue';
import UserActionLogsView from './views/monitor/UserActionLogsView.vue';
import WeighingBatchesView from './views/monitor/WeighingBatchesView.vue';
import WeighingRecordsView from './views/monitor/WeighingRecordsView.vue';
// ---------------------------
// 导入全局样式
import './assets/styles/main.css';
// 配置路由
const routes = [
{ path: '/', component: Home },
{ path: '/devices', component: DeviceList },
{ path: '/plans', component: PlanList }
{path: '/', component: Home, meta: {requiresAuth: true}},
{path: '/devices', component: DeviceList, meta: {requiresAuth: true}},
{path: '/device-templates', component: DeviceTemplateList, meta: {requiresAuth: true}},
{path: '/plans', component: PlanList, meta: {requiresAuth: true}},
{path: '/login', component: LoginForm},
{path: '/pms/farm-management', name: 'PigFarmManagement', component: PigFarmManagementView, meta: { requiresAuth: true, title: '栏舍管理' }},
{path: '/pms/batch-management', name: 'PigBatchManagement', component: PigBatchManagementView, meta: { requiresAuth: true, title: '猪群管理' }},
// --- 统一注册所有监控路由 ---
{path: '/monitor/device-command-logs', component: DeviceCommandLogView, meta: {requiresAuth: true}},
{path: '/monitor/feed-usage-records', component: FeedUsageRecordsView, meta: {requiresAuth: true}},
{path: '/monitor/medication-logs', component: MedicationLogsView, meta: {requiresAuth: true}},
{path: '/monitor/notifications', component: NotificationLogView, meta: {requiresAuth: true}},
{path: '/monitor/pending-collections', component: PendingCollectionsView, meta: {requiresAuth: true}},
{path: '/monitor/pig-batch-logs', component: PigBatchLogsView, meta: {requiresAuth: true}},
{path: '/monitor/pig-purchases', component: PigPurchasesView, meta: {requiresAuth: true}},
{path: '/monitor/pig-sales', component: PigSalesView, meta: {requiresAuth: true}},
{path: '/monitor/pig-sick-logs', component: PigSickLogsView, meta: {requiresAuth: true}},
{path: '/monitor/pig-transfer-logs', component: PigTransferLogsView, meta: {requiresAuth: true}},
{path: '/monitor/plan-execution-logs', component: PlanExecutionLogsView, meta: {requiresAuth: true}},
{path: '/monitor/raw-material-purchases', component: RawMaterialPurchasesView, meta: {requiresAuth: true}},
{path: '/monitor/raw-material-stock-logs', component: RawMaterialStockLogsView, meta: {requiresAuth: true}},
{path: '/monitor/sensor-data', component: SensorDataView, meta: {requiresAuth: true}},
{path: '/monitor/task-execution-logs', component: TaskExecutionLogsView, meta: {requiresAuth: true}},
{path: '/monitor/user-action-logs', component: UserActionLogsView, meta: {requiresAuth: true}},
{path: '/monitor/weighing-batches', component: WeighingBatchesView, meta: {requiresAuth: true}},
{path: '/monitor/weighing-records', component: WeighingRecordsView, meta: {requiresAuth: true}},
// ---------------------------
];
const router = createRouter({
history: createWebHistory(),
routes
history: createWebHistory(),
routes
});
// 全局路由守卫
router.beforeEach((to, from, next) => {
const loggedIn = localStorage.getItem('jwt_token');
if (to.matched.some(record => record.meta.requiresAuth) && !loggedIn) {
// 如果路由需要认证但用户未登录,则重定向到登录页
next('/login');
} else if (to.path === '/login' && loggedIn) {
// 如果用户已登录但试图访问登录页,则重定向到首页
next('/');
} else {
next(); // 正常放行
}
});
// 创建Vue应用实例
const app = createApp(App);
// 使用Element Plus组件库
app.use(ElementPlus);
// 全局配置 Element Plus 为中文
app.use(ElementPlus, {locale: zhCn});
// 使用路由
app.use(router);
// 初始化服务(示例)
// app.config.globalProperties.$api = ApiService;
// app.config.globalProperties.$utils = Utils;
// 挂载应用
app.mount('#app');
app.mount('#app');

View File

@@ -1,80 +1,119 @@
import apiClient from '../api/index.js';
import { AreaControllerApi, DeviceApi } from '../api/device.js';
class DeviceService {
/**
* 获取设备列表
* @returns {Promise<Array>} 设备列表
*/
async getDevices() {
try {
const response = await apiClient.devices.list();
return response.data || [];
} catch (error) {
console.error('获取设备列表失败:', error);
throw error;
}
}
/**
* 获取所有设备和区域主控的列表,并将其合并为树形结构所需的数据
* @returns {Promise<Array>} 合并后的设备列表
*/
async getDevices() {
try {
// 1. 并行发起两个独立的API请求一个获取区域主控一个获取普通设备
const [areaControllersResponse, devicesResponse] = await Promise.all([
AreaControllerApi.list(), // 调用 GET /api/v1/area-controllers
DeviceApi.list() // 调用 GET /api/v1/devices
]);
/**
* 创建新设备
* @param {Object} device 设备信息
* @returns {Promise<Object>} 创建的设备信息
*/
async createDevice(device) {
try {
const response = await apiClient.devices.create(device);
return response.data;
} catch (error) {
console.error('创建设备失败:', error);
throw error;
}
}
// 2. 处理区域主控数据,并添加前端所需的'type'标识
const areaControllers = (areaControllersResponse.data || []).map(controller => ({
...controller,
type: 'area_controller'
}));
/**
* 获取设备详情
* @param {number} deviceId 设备ID
* @returns {Promise<Object>} 设备详情
*/
async getDevice(deviceId) {
try {
const response = await apiClient.devices.get(deviceId);
return response.data;
} catch (error) {
console.error('获取设备详情失败:', error);
throw error;
}
}
// 3. 处理普通设备数据,并添加'type'和'parent_id'以构建树形结构
const devices = (devicesResponse.data || []).map(device => ({
...device,
type: 'device',
parent_id: device.area_controller_id
}));
/**
* 更新设备信息
* @param {number} deviceId 设备ID
* @param {Object} device 更新的设备信息
* @returns {Promise<Object>} 更新后的设备信息
*/
async updateDevice(deviceId, device) {
try {
const response = await apiClient.devices.update(deviceId, device);
return response.data;
} catch (error) {
console.error('更新设备失败:', error);
throw error;
// 4. 合并两份数据形成完整的列表返回给UI组件
return [...areaControllers, ...devices];
} catch (error) {
console.error('获取设备列表失败:', error);
throw error;
}
}
}
/**
* 删除设备
* @param {number} deviceId 设备ID
* @returns {Promise<void>}
*/
async deleteDevice(deviceId) {
try {
await apiClient.devices.delete(deviceId);
} catch (error) {
console.error('删除设备失败:', error);
throw error;
/**
* 创建新设备或区域主控
* @param {Object} data - 设备或区域主控信息包含type字段
* @returns {Promise<Object>} 创建结果
*/
async createDevice(data) {
try {
if (data.type === 'area_controller') {
const response = await AreaControllerApi.create(data);
return {...response.data, type: 'area_controller'};
} else {
const response = await DeviceApi.create(data);
return {...response.data, type: 'device', parent_id: response.data.area_controller_id};
}
} catch (error) {
console.error('创建设备失败:', error);
throw error;
}
}
/**
* 获取设备或区域主控详情
* @param {number} id - ID
* @param {string} type - 类型 ('area_controller' 或 'device')
* @returns {Promise<Object>} 详情
*/
async getDevice(id, type) {
try {
if (type === 'area_controller') {
const response = await AreaControllerApi.getById(id);
return {...response.data, type: 'area_controller'};
} else {
const response = await DeviceApi.getById(id);
return {...response.data, type: 'device', parent_id: response.data.area_controller_id};
}
} catch (error) {
console.error('获取设备详情失败:', error);
throw error;
}
}
/**
* 更新设备或区域主控信息
* @param {number} id - ID
* @param {Object} data - 更新的设备或区域主控信息包含type字段
* @returns {Promise<Object>} 更新后的信息
*/
async updateDevice(id, data) {
try {
if (data.type === 'area_controller') {
const response = await AreaControllerApi.update(id, data);
return {...response.data, type: 'area_controller'};
} else {
const response = await DeviceApi.update(id, data);
return {...response.data, type: 'device', parent_id: response.data.area_controller_id};
}
} catch (error) {
console.error('更新设备失败:', error);
throw error;
}
}
/**
* 删除设备或区域主控
* @param {Object} device - 包含id和type属性的设备或区域主控对象
* @returns {Promise<void>}
*/
async deleteDevice(device) {
try {
if (device.type === 'area_controller') {
await AreaControllerApi.delete(device.id);
} else {
await DeviceApi.delete(device.id);
}
} catch (error) {
console.error('删除设备失败:', error);
throw error;
}
}
}
}
// 导出设备服务实例
export default new DeviceService();
export default new DeviceService();

View File

@@ -0,0 +1,79 @@
import apiClient from '../api/index.js';
class DeviceTemplateService {
/**
* 获取设备模板列表
* @returns {Promise<Array>} 设备模板列表
*/
async getDeviceTemplates() {
try {
const response = await apiClient.deviceTemplates.getDeviceTemplates(); // 更正此处
return response || [];
} catch (error) {
console.error('获取设备模板列表失败:', error);
throw error;
}
}
/**
* 创建新设备模板
* @param {Object} deviceTemplateData 设备模板数据
* @returns {Promise<Object>} 创建的设备模板信息
*/
async createDeviceTemplate(deviceTemplateData) {
try {
const response = await apiClient.deviceTemplates.createDeviceTemplate(deviceTemplateData);
return response.data;
} catch (error) {
console.error('创建设备模板失败:', error);
throw error;
}
}
/**
* 获取设备模板详情
* @param {number} id 设备模板ID
* @returns {Promise<Object>} 设备模板详情
*/
async getDeviceTemplate(id) {
try {
const response = await apiClient.deviceTemplates.getDeviceTemplateById(id);
return response.data;
} catch (error) {
console.error('获取设备模板详情失败:', error);
throw error;
}
}
/**
* 更新设备模板信息
* @param {number} id 设备模板ID
* @param {Object} deviceTemplateData 更新的设备模板信息
* @returns {Promise<Object>} 更新后的设备模板信息
*/
async updateDeviceTemplate(id, deviceTemplateData) {
try {
const response = await apiClient.deviceTemplates.updateDeviceTemplate(id, deviceTemplateData);
return response.data;
} catch (error) {
console.error('更新设备模板失败:', error);
throw error;
}
}
/**
* 删除设备模板
* @param {number} id 设备模板ID
* @returns {Promise<void>}
*/
async deleteDeviceTemplate(id) {
try {
await apiClient.deviceTemplates.deleteDeviceTemplate(id);
} catch (error) {
console.error('删除设备模板失败:', error);
throw error;
}
}
}
export default new DeviceTemplateService();

22
src/utils/format.js Normal file
View File

@@ -0,0 +1,22 @@
/**
* 将 RFC3339 格式的日期时间字符串格式化为 'YYYY-MM-DD HH:mm:ss'
* @param {string | null | undefined} rfc3339String - 后端返回的日期时间字符串
* @returns {string} - 格式化后的字符串,如果输入无效则返回空字符串或提示
*/
export function formatRFC3339(rfc3339String) {
if (!rfc3339String) {
return '--'; // 或者返回空字符串 ''
}
try {
// 例如: "2025-10-17T14:57:01.570169+08:00"
// 替换 'T' 为空格,并截取前 19 个字符即可得到 'YYYY-MM-DD HH:mm:ss'
return rfc3339String.replace('T', ' ').substring(0, 19);
} catch (error) {
console.error('Error formatting date:', rfc3339String, error);
return rfc3339String; // 格式化失败时返回原始值
}
}
// 你未来还可以添加其他全局格式化函数
// export function formatCurrency(number) { ... }

View File

@@ -14,11 +14,11 @@ const http = axios.create({
// 请求拦截器
http.interceptors.request.use(
config => {
// 可以在这里添加认证token
// const token = localStorage.getItem('access_token');
// if (token) {
// config.headers.Authorization = `Bearer ${token}`;
// }
// 在这里添加认证token
const token = localStorage.getItem('jwt_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => {
@@ -29,14 +29,32 @@ http.interceptors.request.use(
// 响应拦截器
http.interceptors.response.use(
response => {
// 可以在这里统一处理响应数据
return response.data;
// 统一处理响应数据检查code字段
const data = response.data;
// 如果存在code字段且不在2000到3000之间表示请求失败
if (data.code !== undefined && (data.code < 2000 || data.code >= 3000)) {
// 抛出错误,包含错误信息
const error = new Error(data.message || '请求失败');
error.code = data.code;
error.data = data;
return Promise.reject(error);
}
// code在2000到3000之间或不存在code字段时返回数据
return data;
},
error => {
// 统一错误处理
if (error.response) {
// 服务器返回错误状态码
console.error('API Error:', error.response.status, error.response.data);
// 如果是401未授权可以考虑重定向到登录页
if (error.response.status === 401) {
// 清除token并重定向到登录页
localStorage.removeItem('jwt_token');
window.location.href = '/login';
}
} else if (error.request) {
// 请求发出但没有收到响应
console.error('Network Error:', error.message);

View File

@@ -0,0 +1,372 @@
<template>
<div class="device-list">
<el-card>
<template #header>
<div class="card-header">
<div class="title-container">
<h2 class="page-title">设备管理</h2>
<el-button type="text" @click="loadDevices" class="refresh-btn" title="刷新设备列表">
<el-icon :size="20"><Refresh /></el-icon>
</el-button>
</div>
<el-button type="primary" @click="addDevice">添加设备</el-button>
</div>
</template>
<!-- 加载状态 -->
<div v-if="loading" class="loading">
<el-skeleton animated />
</div>
<!-- 错误状态 -->
<div v-else-if="error" class="error">
<el-alert
title="获取设备数据失败"
:description="error"
type="error"
show-icon
closable
@close="error = null"
/>
<el-button type="primary" @click="loadDevices" class="retry-btn">重新加载</el-button>
</div>
<!-- 设备列表 -->
<el-table
v-else
:data="tableData"
style="width: 100%"
:fit="true"
table-layout="auto"
row-key="id"
default-expand-all
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
:row-class-name="tableRowClassName"
:highlight-current-row="false"
:scrollbar-always-on="true"
@sort-change="handleSortChange">
<el-table-column width="40"></el-table-column>
<el-table-column prop="id" label="ID" min-width="100" sortable="custom" />
<el-table-column prop="name" label="名称" min-width="120" sortable="custom" />
<el-table-column prop="type" label="类型" min-width="100">
<template #default="scope">
{{ formatDeviceType(scope.row.type) }}
</template>
</el-table-column>
<el-table-column prop="device_template_name" label="设备模板" min-width="120">
<template #default="scope">
{{ scope.row.type === 'device' ? scope.row.device_template_name || '-' : '-' }}
</template>
</el-table-column>
<el-table-column prop="location" label="地址描述" min-width="150" />
<el-table-column label="操作" min-width="120" align="center">
<template #default="scope">
<el-button size="small" @click="editDevice(scope.row)">编辑</el-button>
<el-button size="small" type="danger" @click="deleteDevice(scope.row)">删除</el-button>
<el-button size="small" @click="handleTestDevice(scope.row)" :disabled="isTestButtonDisabled(scope.row)">测试</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 设备表单对话框 -->
<DeviceForm
v-model:visible="dialogVisible"
:device-data="currentDevice"
:is-edit="isEdit"
@success="onDeviceSuccess"
@cancel="dialogVisible = false"
/>
</div>
</template>
<script>
import { Refresh } from '@element-plus/icons-vue';
import deviceService from '../../services/deviceService.js';
import { DeviceApi } from '../../api/device.js';
import { DeviceTemplateApi } from '../../api/deviceTemplate.js';
import DeviceForm from '../../components/DeviceForm.vue';
export default {
name: 'DeviceList',
components: {
DeviceForm,
Refresh // 导入刷新图标组件
},
data() {
return {
tableData: [], // 树形表格数据
originalTableData: [], // 存储原始未排序的树形数据
allDevices: [], // 存储所有设备用于构建树形结构
deviceTemplates: [], // 存储所有设备模板
loading: false,
error: null,
saving: false,
dialogVisible: false,
currentDevice: {},
isEdit: false
};
},
async mounted() {
await this.loadDevices();
},
methods: {
// 加载设备列表
async loadDevices() {
this.loading = true;
this.error = null;
try {
const [deviceData, templateData] = await Promise.all([
deviceService.getDevices(),
DeviceTemplateApi.getDeviceTemplates()
]);
this.allDevices = deviceData;
this.tableData = this.buildTreeData(deviceData);
this.originalTableData = [...this.tableData]; // 保存原始顺序
this.deviceTemplates = templateData.data; // 存储设备模板
} catch (err) {
this.error = err.message || '未知错误';
console.error('加载设备列表失败:', err);
} finally {
this.loading = false;
}
},
// 处理表格排序
handleSortChange({ prop, order }) {
if (!order) {
// 如果取消排序,则恢复原始顺序
this.tableData = [...this.originalTableData];
return;
}
const sortFactor = order === 'ascending' ? 1 : -1;
// 只对顶层项(区域主控)进行排序
this.tableData.sort((a, b) => {
const valA = a[prop];
const valB = b[prop];
if (valA < valB) {
return -1 * sortFactor;
}
if (valA > valB) {
return 1 * sortFactor;
}
return 0;
});
},
// 构建树形结构数据
buildTreeData(devices) {
const areaControllers = devices.filter(device => device.type === 'area_controller');
return areaControllers.map(controller => {
const children = devices.filter(device =>
device.type === 'device' && device.parent_id === controller.id
).map(childDevice => {
// 对于作为子设备的普通设备,确保它们没有 'children' 属性,并显式设置 hasChildren 为 false。
const { children, ...rest } = childDevice;
return { ...rest, hasChildren: false }; // 显式添加 hasChildren: false
});
return {
...controller,
children: children.length > 0 ? children : undefined
};
});
},
// 格式化设备类型显示
formatDeviceType(type) {
const typeMap = {
'area_controller': '区域主控',
'device': '普通设备'
};
return typeMap[type] || type || '-';
},
addDevice() {
// 默认添加普通设备如果需要添加区域主控可以在DeviceForm中选择或通过其他入口触发
this.currentDevice = { type: 'device' };
this.isEdit = false;
this.dialogVisible = true;
},
editDevice(device) {
const processedDevice = { ...device };
if (processedDevice.properties && typeof processedDevice.properties === 'string') {
try {
processedDevice.properties = JSON.parse(processedDevice.properties);
} catch (e) {
console.error('解析properties失败:', e);
processedDevice.properties = {};
}
}
this.currentDevice = processedDevice;
this.isEdit = true;
this.dialogVisible = true;
},
async deleteDevice(device) {
try {
await this.$confirm('确认删除该设备吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
});
await deviceService.deleteDevice(device);
this.$message.success('删除成功');
await this.loadDevices();
} catch (err) {
if (err !== 'cancel') {
this.$message.error('删除失败: ' + (err.message || '未知错误'));
}
}
},
async onDeviceSuccess() {
this.$message.success(this.isEdit ? '设备更新成功' : '设备添加成功');
this.dialogVisible = false;
await this.loadDevices();
},
tableRowClassName({ row, rowIndex }) {
if (row.type === 'area_controller') {
return 'is-area-controller-row';
} else if (row.type === 'device') {
return 'is-device-row';
}
return '';
},
isTestButtonDisabled(device) {
// 区域主控禁用
if (device.type !== 'device') {
return true;
}
// 查找设备模板
const deviceTemplate = this.deviceTemplates.find(template => template.id === device.device_template_id);
// 如果找不到设备模板,也禁用
if (!deviceTemplate) {
return true;
}
// 如果设备模板类型不是'传感器',则禁用
if (deviceTemplate.category !== '传感器') {
return true;
}
return false; // 否则启用
},
async handleTestDevice(device) {
if (device.type !== 'device') {
this.$message.warning('暂不支持非传感器类的测试');
return;
}
const deviceTemplate = this.deviceTemplates.find(template => template.id === device.device_template_id);
if (!deviceTemplate) {
this.$message.error('无法获取设备模板信息,请刷新重试');
return;
}
if (deviceTemplate.category === '传感器') {
try {
await DeviceApi.manualControl(device.id, {});
this.$message.success('测试请求已发送,请前往传感器数据页面查看结果');
} catch (error) {
this.$message.error('发送测试请求失败: ' + (error.message || '未知错误'));
}
} else if (deviceTemplate.category === '执行器') {
this.$message.warning('暂不支持非传感器类的测试');
}
}
}
};
</script>
<style scoped>
.device-list {
padding: 20px;
max-width: 1200px;
margin: 0 auto;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 0;
}
.title-container {
display: flex;
align-items: center;
gap: 5px;
}
.page-title {
margin: 0;
font-size: 1.5rem;
font-weight: bold;
line-height: 1;
}
.refresh-btn {
color: black;
background-color: transparent;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border: none;
}
.dialog-footer {
text-align: right;
}
.loading {
padding: 20px 0;
}
.error {
padding: 20px 0;
text-align: center;
}
.retry-btn {
margin-top: 15px;
}
/* 确保区域主控设备始终高亮显示 */
:deep(.is-area-controller-row) {
background-color: #f5f7fa !important;
}
/* 隐藏普通设备行的展开图标 */
:deep(.is-device-row) .el-table__expand-icon {
visibility: hidden;
}
@media (max-width: 768px) {
.device-list {
padding: 10px;
}
.card-header {
flex-direction: column;
gap: 15px;
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More