Compare commits
54 Commits
56e40efadb
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
933f1618ca | ||
|
|
c43bf79e2d | ||
|
|
55ef28440a | ||
|
|
7ee92b8905 | ||
|
|
c6c78d0c43 | ||
|
|
36465d81e3 | ||
|
|
53e9f5b2e9 | ||
|
|
cde30bbd59 | ||
|
|
6b7ce7c9aa | ||
|
|
db574986dd | ||
|
|
5acf0a52cc | ||
|
|
8f4e2df19b | ||
|
|
83d4b6b536 | ||
|
|
c0bedfa4aa | ||
|
|
bd4f900790 | ||
|
|
9ca053e755 | ||
|
|
b3fd370f39 | ||
|
|
ed4416ddb4 | ||
|
|
3d95b1da28 | ||
|
|
0ff985f606 | ||
|
|
cd56eb7886 | ||
|
|
44acef5f7b | ||
|
|
126e983414 | ||
|
|
4f3ea34334 | ||
|
|
9ad2fd2992 | ||
|
|
e673a68173 | ||
|
|
fcfdd9bea2 | ||
|
|
354dd2c22b | ||
|
|
e17d5610f5 | ||
|
|
aa2d03ee30 | ||
|
|
7826cfcd72 | ||
|
|
60f1c5e628 | ||
|
|
6cdc0120d2 | ||
|
|
264df638af | ||
|
|
582463463d | ||
|
|
8f6e7e7a97 | ||
|
|
10aff2f266 | ||
|
|
abe2ae3c3a | ||
|
|
bccd2d31d2 | ||
|
|
155abf2381 | ||
|
|
a549d39f61 | ||
|
|
46ba6e12c3 | ||
|
|
5e04c591d6 | ||
|
|
cfbcbc39fd | ||
|
|
4db1f77536 | ||
|
|
b0c9a065a2 | ||
|
|
8a44c48ad4 | ||
|
|
7d4aee1c77 | ||
|
|
eca314e686 | ||
|
|
34ebad316a | ||
|
|
f233f368ed | ||
|
|
73a0999e20 | ||
|
|
891dfc5777 | ||
|
|
699c1d4f55 |
15
.vscode/launch.json
vendored
15
.vscode/launch.json
vendored
@@ -9,7 +9,7 @@
|
||||
"request": "launch",
|
||||
"name": "后端调试",
|
||||
"skipFiles": [
|
||||
"<node_internals>/**",
|
||||
"<node_internals>/**"
|
||||
],
|
||||
"resolveSourceMapLocations":[
|
||||
"${workspaceFolder}/",
|
||||
@@ -28,7 +28,16 @@
|
||||
"npm run dev"
|
||||
],
|
||||
"cwd": "${workspaceFolder}/admin"
|
||||
},
|
||||
|
||||
}
|
||||
],
|
||||
"compounds": [
|
||||
{
|
||||
"name": "启动全部(后端+前端)",
|
||||
"configurations": [
|
||||
"后端调试",
|
||||
"后端前端"
|
||||
],
|
||||
"stopAll": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
根据需求,AI 接入功能暂时禁用,作为二期规划。当前使用简单的文本匹配来实现职位过滤功能。
|
||||
|
||||
|
||||
|
||||
## ✅ 已完成的修改
|
||||
|
||||
### 1. 创建文本匹配过滤服务
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
# 指令流程映射关系文档
|
||||
|
||||
## 📋 完整的指令执行流程
|
||||
|
||||
本文档说明从 Admin 前端到 boss-automation-nodejs 的完整指令映射关系。
|
||||
|
||||
## 🔄 执行流程图
|
||||
|
||||
```
|
||||
Admin 前端 (pla_account_detail.vue)
|
||||
↓ commandType
|
||||
plaAccountServer.runCommand()
|
||||
↓ HTTP POST
|
||||
后端 API (pla_account.js)
|
||||
↓ taskType
|
||||
taskQueue.addTask()
|
||||
↓
|
||||
taskQueue.getTaskCommands()
|
||||
↓ command_type
|
||||
command.executeCommand()
|
||||
↓
|
||||
jobManager[command_type]()
|
||||
↓ MQTT action
|
||||
boss-automation-nodejs
|
||||
↓
|
||||
BossService[action]()
|
||||
```
|
||||
|
||||
## 📊 完整映射表
|
||||
|
||||
| Admin commandType | taskType | command_type | MQTT action | Boss 方法 | 说明 |
|
||||
|------------------|----------|--------------|-------------|-----------|------|
|
||||
| `get_login_qr_code` | `get_login_qr_code` | `getLoginQrCode` | `get_login_qr_code` | `get_login_qr_code()` | 获取登录二维码 |
|
||||
| `openBotDetection` | `openBotDetection` | `openBotDetection` | `openBotDetection` | `openBotDetection()` | 打开测试页 |
|
||||
| `get_resume` | `get_resume` | `getOnlineResume` | `get_resume` | `get_resume()` | 获取用户简历 |
|
||||
| `get_user_info` | `get_user_info` | `getUserInfo` | `get_user_info` | `get_user_info()` | 获取用户信息 |
|
||||
| `search_jobs` | `search_jobs` | `searchJob` | `search_jobs` | `search_jobs()` | 搜索岗位 |
|
||||
| `getJobList` | `getJobList` | `getJobList` | `getJobList` | `getJobList()` | 获取岗位列表 |
|
||||
| `getChatList` | `getChatList` | `getChatList` | `getChatList` | `getChatList()` | 获取聊天列表 |
|
||||
|
||||
## 🔍 详细说明
|
||||
|
||||
### 1. Admin 前端 (commandType)
|
||||
|
||||
在 `pla_account_detail.vue` 中定义的操作类型:
|
||||
|
||||
```javascript
|
||||
actionMenuList: [
|
||||
{
|
||||
value: 'get_login_qr_code',
|
||||
label: '用户登录',
|
||||
commandType: 'get_login_qr_code',
|
||||
commandName: '获取登录二维码'
|
||||
},
|
||||
{
|
||||
value: 'getJobList',
|
||||
label: '岗位列表',
|
||||
commandType: 'getJobList',
|
||||
commandName: '获取岗位列表'
|
||||
},
|
||||
// ...
|
||||
]
|
||||
```
|
||||
|
||||
### 2. 后端 API (taskType)
|
||||
|
||||
在 `pla_account.js` 中,`commandType` 直接作为 `taskType` 传递:
|
||||
|
||||
```javascript
|
||||
const task = await task_status.create({
|
||||
sn_code: account.sn_code,
|
||||
taskType: commandConfig.type, // 使用 commandType
|
||||
taskName: commandName || commandConfig.name,
|
||||
taskParams: JSON.stringify(finalParams)
|
||||
});
|
||||
```
|
||||
|
||||
### 3. 任务队列 (command_type)
|
||||
|
||||
在 `taskQueue.js` 的 `getTaskCommands()` 方法中,将 `taskType` 映射为 `command_type`:
|
||||
|
||||
```javascript
|
||||
async getTaskCommands(task) {
|
||||
const { taskType, taskParams } = task;
|
||||
|
||||
switch (taskType) {
|
||||
case 'get_login_qr_code':
|
||||
return [{
|
||||
command_type: 'getLoginQrCode', // jobManager 方法名
|
||||
command_name: '获取登录二维码',
|
||||
command_params: JSON.stringify({ platform })
|
||||
}];
|
||||
|
||||
case 'getJobList':
|
||||
return [{
|
||||
command_type: 'getJobList', // jobManager 方法名
|
||||
command_name: '获取岗位列表',
|
||||
command_params: JSON.stringify({ keyword, platform })
|
||||
}];
|
||||
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 指令管理器 (jobManager 方法)
|
||||
|
||||
在 `command.js` 中调用 `jobManager` 的方法:
|
||||
|
||||
```javascript
|
||||
async executeCommand(taskId, command, mqttClient) {
|
||||
const commandType = command.command_type;
|
||||
const commandParams = JSON.parse(command.command_params);
|
||||
|
||||
// 调用 jobManager 的方法
|
||||
if (commandType && jobManager[commandType]) {
|
||||
result = await jobManager[commandType](sn_code, mqttClient, commandParams);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. MQTT 通信 (action)
|
||||
|
||||
在 `jobManager.js` 中,通过 MQTT 发送指令:
|
||||
|
||||
```javascript
|
||||
async getJobList(sn_code, mqttClient, params = {}) {
|
||||
const response = await mqttClient.publishAndWait(sn_code, {
|
||||
platform: 'boss',
|
||||
action: "getJobList", // MQTT action,对应 Boss 方法名
|
||||
data: { keyword, pageCount }
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Boss 模块执行
|
||||
|
||||
在 `boss-automation-nodejs` 中,根据 `action` 调用对应方法:
|
||||
|
||||
```javascript
|
||||
// modules/index.js
|
||||
async executeAction(platform, action, data) {
|
||||
const modules = this.getModules();
|
||||
|
||||
// 调用 BossService[action]
|
||||
let result = await modules[platform][action](data);
|
||||
return result;
|
||||
}
|
||||
```
|
||||
|
||||
## ⚠️ 关键注意事项
|
||||
|
||||
### 1. 命名一致性
|
||||
|
||||
- **Admin commandType** → 用户界面显示的操作类型
|
||||
- **taskType** → 任务类型,与 commandType 相同
|
||||
- **command_type** → jobManager 中的方法名(驼峰命名)
|
||||
- **MQTT action** → Boss 模块中的方法名(可能有别名)
|
||||
|
||||
### 2. 参数传递
|
||||
|
||||
参数在整个流程中的传递:
|
||||
|
||||
```javascript
|
||||
// Admin 前端
|
||||
commandParams: { keyword: '前端', platform: 'boss' }
|
||||
↓
|
||||
// taskParams
|
||||
taskParams: { keyword: '前端', platform: 'boss' }
|
||||
↓
|
||||
// command_params
|
||||
command_params: '{"keyword":"前端","platform":"boss"}'
|
||||
↓
|
||||
// jobManager 方法参数
|
||||
params: { keyword: '前端', platform: 'boss' }
|
||||
↓
|
||||
// MQTT data
|
||||
data: { keyword: '前端', pageCount: 3 }
|
||||
↓
|
||||
// Boss 方法参数
|
||||
data: { keyword: '前端', pageCount: 3 }
|
||||
```
|
||||
|
||||
### 3. 别名支持
|
||||
|
||||
Boss 模块中的别名方法:
|
||||
|
||||
```javascript
|
||||
// BossService 类中
|
||||
async openBotDetection(data) {
|
||||
return this.open_bot_detection(data);
|
||||
}
|
||||
|
||||
async get_resume(data) {
|
||||
return this.getOnlineResume(data);
|
||||
}
|
||||
|
||||
async search_jobs(data) {
|
||||
return this.searchJob(data);
|
||||
}
|
||||
```
|
||||
|
||||
## 🐛 常见问题
|
||||
|
||||
### Q1: 提示"未知的指令类型"
|
||||
|
||||
**原因:** `command_type` 在 `jobManager` 中不存在
|
||||
|
||||
**解决:**
|
||||
1. 检查 `taskQueue.js` 中的 `getTaskCommands()` 方法
|
||||
2. 确保 `command_type` 与 `jobManager` 中的方法名一致
|
||||
|
||||
### Q2: MQTT 消息发送失败
|
||||
|
||||
**原因:** `action` 在 Boss 模块中不存在
|
||||
|
||||
**解决:**
|
||||
1. 检查 `jobManager.js` 中的 MQTT action
|
||||
2. 确保 Boss 模块中有对应的方法或别名
|
||||
|
||||
### Q3: 参数传递错误
|
||||
|
||||
**原因:** 参数格式不正确
|
||||
|
||||
**解决:**
|
||||
1. 确保 `command_params` 是 JSON 字符串
|
||||
2. 在 `jobManager` 中正确解析参数
|
||||
3. 在 MQTT 消息中正确传递参数
|
||||
|
||||
---
|
||||
|
||||
**创建时间**: 2025-11-13
|
||||
**作者**: Augment Agent
|
||||
|
||||
968
_doc/MQTT指令列表.md
Normal file
968
_doc/MQTT指令列表.md
Normal file
@@ -0,0 +1,968 @@
|
||||
# 自动找工作系统 - MQTT指令列表
|
||||
|
||||
> 版本: v1.0 | 更新日期: 2025-12-25
|
||||
|
||||
## 文档说明
|
||||
|
||||
本文档定义了服务端通过MQTT向客户端下发的所有指令格式和规范。所有操作都通过任务和指令的方式异步执行。
|
||||
|
||||
---
|
||||
|
||||
## 一、MQTT通信架构
|
||||
|
||||
### 1.1 通信流程
|
||||
|
||||
```
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ 服务端 │ │ 客户端 │
|
||||
│ (Node.js) │ │ (设备端) │
|
||||
└──────┬───────┘ └──────┬───────┘
|
||||
│ │
|
||||
│ ① 创建任务(task_status表) │
|
||||
│ ② 生成指令(task_commands表) │
|
||||
│ │
|
||||
│ ③ MQTT Publish │
|
||||
│ Topic: {sn_code}/command │
|
||||
│ ─────────────────────────────> │
|
||||
│ │
|
||||
│ ④ 执行指令 │
|
||||
│ ⑤ 生成结果 │
|
||||
│ │
|
||||
│ ⑥ MQTT Publish │
|
||||
│ Topic: response │
|
||||
│ <───────────────────────────── │
|
||||
│ │
|
||||
│ ⑦ 更新指令状态(task_commands) │
|
||||
│ ⑧ 更新任务状态(task_status) │
|
||||
│ │
|
||||
```
|
||||
|
||||
### 1.2 MQTT配置
|
||||
|
||||
- **Broker地址**: `mqtt://192.144.167.231:1883`
|
||||
- **订阅主题**:
|
||||
- `heartbeat` - 设备心跳信息
|
||||
- `response` - 设备响应信息
|
||||
- **发布主题**:
|
||||
- `{sn_code}/command` - 向指定设备发送指令
|
||||
|
||||
### 1.3 消息格式
|
||||
|
||||
**服务端 → 客户端 (指令)**
|
||||
```json
|
||||
{
|
||||
"commandId": "uuid",
|
||||
"taskId": "uuid",
|
||||
"platform": "boss",
|
||||
"action": "search_jobs",
|
||||
"data": {
|
||||
"keyword": "全栈工程师",
|
||||
"city": "101020100",
|
||||
"page": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**客户端 → 服务端 (响应)**
|
||||
```json
|
||||
{
|
||||
"commandId": "uuid",
|
||||
"taskId": "uuid",
|
||||
"code": 200,
|
||||
"message": "执行成功",
|
||||
"data": {
|
||||
// 返回数据
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**客户端 → 服务端 (心跳)**
|
||||
```json
|
||||
{
|
||||
"sn_code": "device001",
|
||||
"platform": "boss",
|
||||
"timestamp": 1672531200000,
|
||||
"status": "online",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、已实现指令列表
|
||||
|
||||
### 2.1 用户登录指令
|
||||
|
||||
#### get_login_qr_code - 获取登录二维码
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "get_login_qr_code",
|
||||
"platform": "boss",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "二维码获取成功",
|
||||
"data": {
|
||||
"qrCode": "https://example.com/qrcode.png",
|
||||
"qr_code_url": "https://example.com/qrcode.png",
|
||||
"expire_time": 300
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 获取登录二维码,用户扫码登录后客户端需要保存cookies/token
|
||||
|
||||
---
|
||||
|
||||
#### get_user_info - 获取用户信息
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "get_user_info",
|
||||
"platform": "boss",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"userId": "123456",
|
||||
"userName": "张三",
|
||||
"phone": "138****5678",
|
||||
"isLoggedIn": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 获取当前登录用户的基本信息,验证登录状态
|
||||
|
||||
---
|
||||
|
||||
### 2.2 简历管理指令
|
||||
|
||||
#### get_online_resume - 获取在线简历
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "get_online_resume",
|
||||
"platform": "boss",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"baseInfo": {
|
||||
"name": "张三",
|
||||
"gender": 1,
|
||||
"age": 28,
|
||||
"account": "138****5678",
|
||||
"emailBlur": "zhang***@qq.com",
|
||||
"workYears": 5,
|
||||
"workYearDesc": "5年",
|
||||
"degreeCategory": "本科"
|
||||
},
|
||||
"expectList": [{
|
||||
"positionName": "全栈工程师",
|
||||
"locationName": "上海",
|
||||
"salaryDesc": "20-30K",
|
||||
"industryDesc": "互联网"
|
||||
}],
|
||||
"workExpList": [{
|
||||
"companyName": "XX科技公司",
|
||||
"positionName": "高级前端工程师",
|
||||
"startDate": "2020-01",
|
||||
"endDate": "2023-12",
|
||||
"workContent": "负责前端架构设计和开发..."
|
||||
}],
|
||||
"projectExpList": [{
|
||||
"name": "电商平台项目",
|
||||
"roleName": "技术负责人",
|
||||
"startDate": "2022-01",
|
||||
"endDate": "2023-06",
|
||||
"projectDesc": "项目描述...",
|
||||
"performance": "项目成果..."
|
||||
}],
|
||||
"educationExpList": [{
|
||||
"school": "XX大学",
|
||||
"major": "计算机科学与技术",
|
||||
"degreeName": "本科",
|
||||
"endYear": 2018
|
||||
}],
|
||||
"userDesc": "熟悉Vue、React、Node.js等技术栈...",
|
||||
"certificationList": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 获取用户在招聘平台上的完整简历信息
|
||||
|
||||
---
|
||||
|
||||
### 2.3 岗位搜索指令
|
||||
|
||||
#### search_jobs - 搜索岗位 (已实现但需完善)
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "search_jobs",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"keyword": "全栈工程师",
|
||||
"city": "101020100",
|
||||
"page": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "搜索成功",
|
||||
"data": {
|
||||
"total": 150,
|
||||
"page": 1,
|
||||
"jobList": [{
|
||||
"jobId": "job123456",
|
||||
"jobTitle": "全栈工程师",
|
||||
"companyName": "XX科技公司",
|
||||
"companySize": "100-499人",
|
||||
"salary": "20-30K",
|
||||
"location": "上海·浦东新区",
|
||||
"experience": "3-5年",
|
||||
"education": "本科",
|
||||
"jobRequirements": "1. 熟悉Vue/React...",
|
||||
"jobDescription": "岗位职责...",
|
||||
"bossName": "张经理",
|
||||
"bossTitle": "技术总监"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 当前实现基础,需要扩展支持更多搜索条件
|
||||
|
||||
---
|
||||
|
||||
#### get_job_list - 获取岗位列表
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "get_job_list",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"page": 1,
|
||||
"pageSize": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"total": 50,
|
||||
"page": 1,
|
||||
"jobList": [
|
||||
// 同 search_jobs 的 jobList 格式
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 获取推荐岗位列表
|
||||
|
||||
---
|
||||
|
||||
### 2.4 投递管理指令
|
||||
|
||||
#### apply_job - 投递岗位 (基础实现)
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "apply_job",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"jobId": "job123456",
|
||||
"expectSalary": "20-30K"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "投递成功",
|
||||
"data": {
|
||||
"applyId": "apply123456",
|
||||
"jobId": "job123456",
|
||||
"applyTime": "2025-12-25 10:30:00",
|
||||
"status": "success"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**错误码**
|
||||
- `400` - 参数错误
|
||||
- `403` - 已投递过该岗位
|
||||
- `429` - 投递次数达到上限
|
||||
- `500` - 投递失败
|
||||
|
||||
**说明**: 向指定岗位投递简历
|
||||
|
||||
---
|
||||
|
||||
### 2.5 聊天管理指令
|
||||
|
||||
#### get_chat_list - 获取聊天列表
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "get_chat_list",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"page": 1,
|
||||
"pageSize": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"total": 15,
|
||||
"chatList": [{
|
||||
"conversationId": "conv123456",
|
||||
"jobId": "job123456",
|
||||
"jobTitle": "全栈工程师",
|
||||
"companyName": "XX科技",
|
||||
"bossName": "张经理",
|
||||
"lastMessage": "您好,请问...",
|
||||
"lastMessageTime": "2025-12-25 10:30:00",
|
||||
"unreadCount": 2,
|
||||
"hasInterview": false
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 获取与HR的聊天会话列表
|
||||
|
||||
---
|
||||
|
||||
#### send_chat_message - 发送聊天消息
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "send_chat_message",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"conversationId": "conv123456",
|
||||
"jobId": "job123456",
|
||||
"content": "您好,我对这个岗位很感兴趣..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "发送成功",
|
||||
"data": {
|
||||
"messageId": "msg123456",
|
||||
"sendTime": "2025-12-25 10:30:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 向HR发送聊天消息
|
||||
|
||||
---
|
||||
|
||||
### 2.6 测试和调试指令
|
||||
|
||||
#### open_bot_detection - 打开测试页
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "open_bot_detection",
|
||||
"platform": "boss",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "测试页已打开",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 打开测试页面,用于调试
|
||||
|
||||
---
|
||||
|
||||
## 三、待开发指令列表
|
||||
|
||||
### 3.1 搜索投递增强指令 (优先级: HIGH)
|
||||
|
||||
#### search_jobs_enhanced - 增强搜索岗位 ⭐⭐⭐⭐⭐
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "search_jobs_enhanced",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"keyword": "全栈工程师",
|
||||
"city": "101020100",
|
||||
"page": 1,
|
||||
"pageSize": 20,
|
||||
|
||||
// 新增搜索条件
|
||||
"experience": "3",
|
||||
"degree": "203",
|
||||
"salary": "406",
|
||||
"scale": "303",
|
||||
"stage": "807",
|
||||
"position": "100109",
|
||||
|
||||
// 滚动加载方式
|
||||
"scrollLoadType": "auto",
|
||||
"maxScrollPages": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明**
|
||||
| 参数 | 说明 | 示例值 |
|
||||
|------|------|--------|
|
||||
| keyword | 搜索关键词 | "全栈工程师" |
|
||||
| city | 城市代码 | "101020100" (上海) |
|
||||
| page | 页码 | 1 |
|
||||
| pageSize | 每页数量 | 20 |
|
||||
| experience | 工作经验 | "1"=1年以下, "3"=1-3年, "4"=3-5年, "5"=5-10年, "6"=10年以上 |
|
||||
| degree | 学历要求 | "202"=不限, "203"=大专, "204"=本科, "205"=硕士, "206"=博士 |
|
||||
| salary | 薪资范围 | "402"=3-5K, "403"=5-10K, "404"=10-15K, "405"=15-20K, "406"=20-30K, "407"=30-50K, "408"=50K以上 |
|
||||
| scale | 公司规模 | "302"=0-20人, "303"=20-99人, "304"=100-499人, "305"=500-999人, "306"=1000人以上 |
|
||||
| stage | 融资阶段 | "801"=未融资, "802"=天使轮, "803"=A轮, "804"=B轮, "805"=C轮, "806"=D轮及以上, "807"=已上市, "808"=不需要融资 |
|
||||
| position | 职位类型 | "100109"=全栈, "100110"=前端, "100111"=后端, "100112"=移动端 |
|
||||
| scrollLoadType | 加载方式 | "auto"=自动滚动, "manual"=手动翻页 |
|
||||
| maxScrollPages | 最大滚动页数 | 5 |
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "搜索成功",
|
||||
"data": {
|
||||
"total": 150,
|
||||
"page": 1,
|
||||
"hasMore": true,
|
||||
"jobList": [{
|
||||
"jobId": "job123456",
|
||||
"jobTitle": "全栈工程师",
|
||||
"companyName": "XX科技公司",
|
||||
"companySize": "100-499人",
|
||||
"companyIndustry": "互联网",
|
||||
"companyStage": "已上市",
|
||||
"salary": "20-30K",
|
||||
"salaryMonth": "14薪",
|
||||
"location": "上海·浦东新区",
|
||||
"longitude": 121.5273,
|
||||
"latitude": 31.2172,
|
||||
"experience": "3-5年",
|
||||
"education": "本科",
|
||||
"skills": ["Vue", "React", "Node.js"],
|
||||
"jobRequirements": "1. 熟悉Vue/React...",
|
||||
"jobDescription": "岗位职责...",
|
||||
"welfare": ["五险一金", "带薪年假", "弹性工作"],
|
||||
"bossName": "张经理",
|
||||
"bossTitle": "技术总监",
|
||||
"bossActiveStatus": "刚刚活跃",
|
||||
"publishTime": "2025-12-25",
|
||||
"viewCount": 150,
|
||||
"applyCount": 30,
|
||||
"isOutsourcing": false,
|
||||
"jobLink": "https://www.zhipin.com/job_detail/xxx"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 支持Boss直聘完整的搜索筛选条件
|
||||
- 支持自动滚动加载更多岗位
|
||||
- 返回更详细的岗位信息
|
||||
|
||||
---
|
||||
|
||||
#### search_by_url - 通过URL搜索岗位 ⭐⭐⭐⭐⭐
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "search_by_url",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"url": "https://www.zhipin.com/web/geek/jobs?city=101020100&query=%E5%85%A8%E6%A0%88%E5%B7%A5%E7%A8%8B%E5%B8%88",
|
||||
"scrollLoadType": "auto",
|
||||
"maxScrollPages": 5
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "搜索成功",
|
||||
"data": {
|
||||
// 同 search_jobs_enhanced 返回格式
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 直接使用Boss直聘的搜索URL
|
||||
- 自动解析URL参数
|
||||
- 支持所有筛选条件
|
||||
|
||||
---
|
||||
|
||||
### 3.2 批量投递指令 (优先级: HIGH)
|
||||
|
||||
#### batch_apply_jobs - 批量投递岗位 ⭐⭐⭐⭐⭐
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "batch_apply_jobs",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"jobIds": ["job001", "job002", "job003"],
|
||||
"expectSalary": "20-30K",
|
||||
"applyInterval": 30,
|
||||
"maxApplyCount": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明**
|
||||
| 参数 | 说明 | 示例值 |
|
||||
|------|------|--------|
|
||||
| jobIds | 岗位ID数组 | ["job001", "job002"] |
|
||||
| expectSalary | 期望薪资 | "20-30K" |
|
||||
| applyInterval | 投递间隔(秒) | 30 |
|
||||
| maxApplyCount | 最大投递数量 | 10 |
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "批量投递完成",
|
||||
"data": {
|
||||
"total": 10,
|
||||
"success": 8,
|
||||
"failed": 2,
|
||||
"results": [{
|
||||
"jobId": "job001",
|
||||
"status": "success",
|
||||
"applyId": "apply001",
|
||||
"message": "投递成功"
|
||||
}, {
|
||||
"jobId": "job002",
|
||||
"status": "failed",
|
||||
"message": "已投递过该岗位"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 批量投递多个岗位
|
||||
- 控制投递间隔避免被限制
|
||||
- 返回每个岗位的投递结果
|
||||
|
||||
---
|
||||
|
||||
### 3.3 简历刷新指令 (优先级: HIGH)
|
||||
|
||||
#### refresh_resume - 刷新简历 ⭐⭐⭐⭐
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "refresh_resume",
|
||||
"platform": "boss",
|
||||
"data": {}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "简历刷新成功",
|
||||
"data": {
|
||||
"refreshTime": "2025-12-25 10:30:00",
|
||||
"nextRefreshTime": "2025-12-25 12:30:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 刷新简历提升排名
|
||||
- 每2小时可刷新一次
|
||||
|
||||
---
|
||||
|
||||
### 3.4 账号保活指令 (优先级: HIGH)
|
||||
|
||||
#### auto_active - 自动活跃账号 ⭐⭐⭐⭐
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "auto_active",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"actionType": "random",
|
||||
"actions": ["browse_jobs", "view_company", "search_keyword", "update_visibility"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**参数说明**
|
||||
| 参数 | 说明 | 可选值 |
|
||||
|------|------|--------|
|
||||
| actionType | 动作类型 | "random"=随机, "sequence"=顺序 |
|
||||
| actions | 动作列表 | ["browse_jobs", "view_company", "search_keyword", "update_visibility"] |
|
||||
|
||||
**动作说明**
|
||||
- `browse_jobs` - 浏览岗位(随机点击5-10个岗位)
|
||||
- `view_company` - 查看公司主页
|
||||
- `search_keyword` - 搜索关键词(随机关键词)
|
||||
- `update_visibility` - 修改简历可见性
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "活跃操作完成",
|
||||
"data": {
|
||||
"executedActions": ["browse_jobs", "view_company"],
|
||||
"duration": 120,
|
||||
"timestamp": "2025-12-25 10:30:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**:
|
||||
- 模拟真实用户行为
|
||||
- 随机时间间隔
|
||||
- 避免账号被标记为机器人
|
||||
|
||||
---
|
||||
|
||||
### 3.5 聊天增强指令 (优先级: MEDIUM)
|
||||
|
||||
#### get_chat_detail - 获取聊天详情 ⭐⭐⭐
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "get_chat_detail",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"conversationId": "conv123456",
|
||||
"page": 1,
|
||||
"pageSize": 50
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"conversationId": "conv123456",
|
||||
"jobId": "job123456",
|
||||
"messages": [{
|
||||
"messageId": "msg001",
|
||||
"senderId": "boss123",
|
||||
"senderType": "boss",
|
||||
"content": "您好,请问什么时候方便面试?",
|
||||
"sendTime": "2025-12-25 10:30:00",
|
||||
"isRead": true,
|
||||
"messageType": "text",
|
||||
"isInterviewInvitation": true
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 获取完整的聊天历史记录
|
||||
|
||||
---
|
||||
|
||||
#### send_greeting - 发送打招呼 ⭐⭐⭐
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "send_greeting",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"jobId": "job123456",
|
||||
"content": "您好,我对这个岗位很感兴趣,期待能有机会详聊。"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "打招呼成功",
|
||||
"data": {
|
||||
"conversationId": "conv123456",
|
||||
"messageId": "msg001",
|
||||
"sendTime": "2025-12-25 10:30:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 主动向HR发起沟通
|
||||
|
||||
---
|
||||
|
||||
### 3.6 数据采集指令 (优先级: MEDIUM)
|
||||
|
||||
#### get_job_detail - 获取岗位详情 ⭐⭐⭐
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "get_job_detail",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"jobId": "job123456"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"jobId": "job123456",
|
||||
// 完整的岗位详情(同search_jobs_enhanced中的jobList项)
|
||||
"companyDetail": {
|
||||
"companyId": "company123",
|
||||
"companyName": "XX科技公司",
|
||||
"companyLogo": "https://...",
|
||||
"companySize": "100-499人",
|
||||
"companyIndustry": "互联网",
|
||||
"companyStage": "已上市",
|
||||
"companyAddress": "上海市浦东新区...",
|
||||
"companyDesc": "公司介绍..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 获取岗位的完整详情信息
|
||||
|
||||
---
|
||||
|
||||
#### get_company_info - 获取公司信息 ⭐⭐⭐
|
||||
|
||||
**指令格式**
|
||||
```json
|
||||
{
|
||||
"action": "get_company_info",
|
||||
"platform": "boss",
|
||||
"data": {
|
||||
"companyId": "company123"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**返回格式**
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "获取成功",
|
||||
"data": {
|
||||
"companyId": "company123",
|
||||
"companyName": "XX科技公司",
|
||||
"companyLogo": "https://...",
|
||||
"companySize": "100-499人",
|
||||
"companyIndustry": "互联网",
|
||||
"companyStage": "已上市",
|
||||
"companyAddress": "上海市浦东新区...",
|
||||
"companyDesc": "公司介绍...",
|
||||
"companyBenefit": ["五险一金", "带薪年假"],
|
||||
"companyPhotos": ["https://...", "https://..."],
|
||||
"jobCount": 50,
|
||||
"isVerified": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**说明**: 获取公司的详细信息
|
||||
|
||||
---
|
||||
|
||||
## 四、指令执行规范
|
||||
|
||||
### 4.1 指令生命周期
|
||||
|
||||
```
|
||||
1. 创建 (pending)
|
||||
↓
|
||||
2. 下发 (sent)
|
||||
↓
|
||||
3. 执行中 (executing)
|
||||
↓
|
||||
4. 完成 (completed) / 失败 (failed) / 超时 (timeout)
|
||||
```
|
||||
|
||||
### 4.2 超时设置
|
||||
|
||||
| 指令类型 | 超时时间 | 重试次数 |
|
||||
|---------|----------|----------|
|
||||
| 登录类指令 | 60秒 | 1次 |
|
||||
| 简历获取 | 30秒 | 2次 |
|
||||
| 岗位搜索 | 60秒 | 2次 |
|
||||
| 岗位投递 | 30秒 | 1次 |
|
||||
| 聊天消息 | 30秒 | 2次 |
|
||||
| 保活操作 | 120秒 | 0次 |
|
||||
|
||||
### 4.3 错误码规范
|
||||
|
||||
| 错误码 | 说明 | 处理方式 |
|
||||
|--------|------|----------|
|
||||
| 200 | 成功 | - |
|
||||
| 400 | 参数错误 | 不重试 |
|
||||
| 401 | 未登录 | 触发重新登录 |
|
||||
| 403 | 无权限/已操作 | 不重试 |
|
||||
| 429 | 请求过于频繁 | 延迟后重试 |
|
||||
| 500 | 服务器错误 | 重试 |
|
||||
| 503 | 服务不可用 | 延迟后重试 |
|
||||
| 600 | 网络超时 | 重试 |
|
||||
| 700 | 客户端错误 | 记录日志,不重试 |
|
||||
|
||||
### 4.4 重试策略
|
||||
|
||||
- **指数退避**: `delay = min(1000 * 2^(retryCount-1), 30000ms)`
|
||||
- **最大重试次数**: 根据指令类型决定(见4.2表格)
|
||||
- **可重试错误**: 429, 500, 503, 600
|
||||
- **不可重试错误**: 400, 401, 403, 700
|
||||
|
||||
---
|
||||
|
||||
## 五、客户端实现要求
|
||||
|
||||
### 5.1 MQTT客户端
|
||||
|
||||
- **连接保持**: 断线自动重连
|
||||
- **心跳间隔**: 10秒
|
||||
- **订阅主题**: `{sn_code}/command`
|
||||
- **发布主题**: `response`, `heartbeat`
|
||||
|
||||
### 5.2 指令处理
|
||||
|
||||
1. **接收指令**
|
||||
- 解析JSON格式
|
||||
- 验证必需字段
|
||||
- 记录指令日志
|
||||
|
||||
2. **执行指令**
|
||||
- 根据action分发到对应处理器
|
||||
- 更新执行状态
|
||||
- 捕获异常错误
|
||||
|
||||
3. **返回响应**
|
||||
- 统一响应格式
|
||||
- 包含commandId用于追踪
|
||||
- 返回详细的执行结果
|
||||
|
||||
### 5.3 异常处理
|
||||
|
||||
- **网络异常**: 自动重试
|
||||
- **登录过期**: 通知服务端重新登录
|
||||
- **页面加载失败**: 刷新页面重试
|
||||
- **元素定位失败**: 记录截图,返回错误
|
||||
|
||||
### 5.4 日志记录
|
||||
|
||||
- **请求日志**: 记录所有接收到的指令
|
||||
- **响应日志**: 记录所有返回的响应
|
||||
- **错误日志**: 记录所有异常和错误
|
||||
- **操作日志**: 记录关键操作步骤
|
||||
|
||||
---
|
||||
|
||||
## 六、开发优先级
|
||||
|
||||
### P0 - 立即开发 (投递核心功能)
|
||||
|
||||
1. ✅ `search_jobs_enhanced` - 增强搜索
|
||||
2. ✅ `search_by_url` - URL搜索
|
||||
3. ✅ `batch_apply_jobs` - 批量投递
|
||||
4. ✅ `refresh_resume` - 简历刷新
|
||||
|
||||
### P1 - 短期开发 (保活和聊天)
|
||||
|
||||
5. ✅ `auto_active` - 账号保活
|
||||
6. ✅ `send_greeting` - 发送打招呼
|
||||
7. ✅ `get_chat_detail` - 聊天详情
|
||||
|
||||
### P2 - 中期开发 (数据采集)
|
||||
|
||||
8. ⭐ `get_job_detail` - 岗位详情
|
||||
9. ⭐ `get_company_info` - 公司信息
|
||||
|
||||
---
|
||||
|
||||
**文档维护**: 开发团队
|
||||
**最后更新**: 2025-12-25
|
||||
@@ -1,686 +0,0 @@
|
||||
徽标
|
||||
工单管理
|
||||
合并请求
|
||||
里程碑
|
||||
探索
|
||||
zc
|
||||
/
|
||||
admin_core
|
||||
代码
|
||||
工单
|
||||
合并请求
|
||||
Actions
|
||||
软件包
|
||||
项目
|
||||
版本发布
|
||||
百科
|
||||
动态
|
||||
设置
|
||||
文件
|
||||
使用说明.md
|
||||
完整使用文档.md
|
||||
快速开始.md
|
||||
.gitignore
|
||||
README.md
|
||||
babel.config.js
|
||||
package-lock.json
|
||||
package.json
|
||||
postcss.config.js
|
||||
webpack.config.js
|
||||
admin_core
|
||||
/
|
||||
_doc
|
||||
/
|
||||
使用说明.md
|
||||
|
||||
张成
|
||||
463d7921c1
|
||||
1
|
||||
1分钟前
|
||||
18 KiB
|
||||
Admin Framework 使用说明
|
||||
一个基于 Vue2 的通用后台管理系统框架,包含完整的系统功能、登录、路由管理、布局等核心功能。
|
||||
|
||||
📦 框架特性
|
||||
✨ 核心功能
|
||||
✅ 简化的 API - 只需调用 createApp() 即可完成所有初始化
|
||||
✅ 模块化设计 - 组件、路由、状态管理等功能按模块组织
|
||||
✅ 完整的系统管理页面 - 用户、角色、菜单、日志等管理
|
||||
✅ 登录和权限管理 - 完整的登录流程和权限控制
|
||||
✅ 动态路由管理 - 基于权限菜单的动态路由生成
|
||||
✅ Vuex 状态管理 - 用户、应用状态管理
|
||||
✅ 全局组件库 - Tables、Editor、Upload、TreeGrid、FieldRenderer、FloatPanel 等
|
||||
✅ 工具库 - HTTP、日期、Token、Cookie 等工具
|
||||
✅ 内置样式 - base.less、animate.css、iconfont 等
|
||||
✅ 响应式布局 - 支持移动端适配
|
||||
🎯 内置页面组件
|
||||
主页组件 (HomePage) - 欢迎页面,显示系统标题
|
||||
系统管理页面 (SysUser, SysRole, SysLog, SysParamSetup)
|
||||
高级管理页面 (SysMenu, SysControl, SysTitle)
|
||||
登录页面 (LoginPage)
|
||||
错误页面 (Page401, Page404, Page500)
|
||||
🛠️ 内置工具
|
||||
HTTP 工具 (http) - 封装了 axios,支持拦截器、文件上传下载
|
||||
UI 工具 (uiTool) - 删除确认、树形转换、响应式设置、文件下载
|
||||
通用工具 (tools) - 日期格式化、UUID 生成、Cookie 操作、深拷贝等
|
||||
文件下载 - 支持 CSV 等格式的文件下载,自动处理换行符
|
||||
🚀 快速开始
|
||||
方式一:使用 Demo 项目(推荐)
|
||||
我们提供了一个完整的 demo 项目,可以直接运行查看效果:
|
||||
|
||||
# 1. 进入 demo 项目
|
||||
cd demo
|
||||
|
||||
# 2. 安装依赖
|
||||
npm install
|
||||
|
||||
# 3. 启动开发服务器
|
||||
npm run dev
|
||||
浏览器会自动打开 http://localhost:8080,查看:
|
||||
|
||||
/login - 登录页面
|
||||
/home - 主页
|
||||
/system/user - 用户管理
|
||||
/ball/games - 业务示例页面
|
||||
方式二:构建框架
|
||||
# 1. 安装依赖
|
||||
npm install
|
||||
|
||||
# 2. 构建框架
|
||||
npm run build
|
||||
|
||||
# 3. 产物在 dist/admin-framework.js
|
||||
🎯 极简使用方式
|
||||
只需 3 步即可完成集成!
|
||||
1. 引入框架
|
||||
import AdminFramework from './admin-framework.js'
|
||||
2. 创建应用
|
||||
const app = AdminFramework.createApp({
|
||||
title: '我的管理系统',
|
||||
apiUrl: 'http://localhost:9098/admin_api/',
|
||||
componentMap: {
|
||||
'business/product': ProductComponent,
|
||||
'business/order': OrderComponent
|
||||
}
|
||||
})
|
||||
3. 挂载应用
|
||||
app.$mount('#app')
|
||||
就这么简单! 框架会自动完成所有初始化工作。
|
||||
|
||||
📖 完整使用指南
|
||||
1. 项目结构准备
|
||||
your-project/
|
||||
├── src/
|
||||
│ ├── config/
|
||||
│ │ └── index.js # 配置文件
|
||||
│ ├── libs/
|
||||
│ │ └── admin-framework.js # 框架文件
|
||||
│ ├── views/
|
||||
│ │ └── business/ # 业务页面
|
||||
│ ├── api/
|
||||
│ │ └── business/ # 业务 API
|
||||
│ ├── App.vue
|
||||
│ └── main.js
|
||||
├── package.json
|
||||
└── webpack.config.js
|
||||
2. 安装依赖
|
||||
npm install vue vue-router vuex view-design axios dayjs js-cookie vuex-persistedstate
|
||||
3. 创建配置文件
|
||||
在 src/config/index.js 中:
|
||||
|
||||
module.exports = {
|
||||
title: '你的系统名称',
|
||||
homeName: '首页',
|
||||
apiUrl: 'http://localhost:9090/admin_api/',
|
||||
uploadUrl: 'http://localhost:9090/admin_api/upload',
|
||||
cookieExpires: 7,
|
||||
uploadMaxLimitSize: 10,
|
||||
oss: {
|
||||
region: 'oss-cn-shanghai',
|
||||
accessKeyId: 'your-key',
|
||||
accessKeySecret: 'your-secret',
|
||||
bucket: 'your-bucket',
|
||||
url: 'http://your-bucket.oss-cn-shanghai.aliyuncs.com',
|
||||
basePath: 'your-path/'
|
||||
}
|
||||
}
|
||||
4. 创建 main.js(新版本 - 推荐)
|
||||
import AdminFramework from './libs/admin-framework.js'
|
||||
|
||||
// 导入业务组件(根据权限菜单接口的 component 字段)
|
||||
import GamesComponent from './views/ball/games.vue'
|
||||
import PayOrdersComponent from './views/order/pay_orders.vue'
|
||||
|
||||
// 🎉 只需一行代码!框架自动完成所有初始化
|
||||
const app = AdminFramework.createApp({
|
||||
title: '我的管理系统',
|
||||
apiUrl: 'http://localhost:9098/admin_api/',
|
||||
componentMap: {
|
||||
'ball/games': GamesComponent,
|
||||
'order/pay_orders': PayOrdersComponent
|
||||
// 添加更多业务组件...
|
||||
},
|
||||
onReady() {
|
||||
console.log('应用已启动!')
|
||||
// 应用启动完成后的回调
|
||||
}
|
||||
})
|
||||
|
||||
// 挂载应用
|
||||
app.$mount('#app')
|
||||
5. 创建 App.vue
|
||||
<template>
|
||||
<div id="app">
|
||||
<router-view/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'App'
|
||||
}
|
||||
</script>
|
||||
🔧 API 使用指南
|
||||
框架实例方法
|
||||
createApp(config) - 推荐使用
|
||||
创建应用实例(新版本 API)
|
||||
|
||||
const app = AdminFramework.createApp({
|
||||
title: '我的管理系统', // 应用标题(必需)
|
||||
apiUrl: 'http://localhost:9098/admin_api/', // API 基础地址(必需)
|
||||
uploadUrl: 'http://localhost:9098/admin_api/upload', // 上传地址(可选,默认为 apiUrl + 'upload')
|
||||
componentMap: { // 业务组件映射(可选)
|
||||
'business/product': ProductComponent,
|
||||
'business/order': OrderComponent
|
||||
},
|
||||
onReady() { // 应用启动完成回调(可选)
|
||||
console.log('应用已启动!')
|
||||
}
|
||||
})
|
||||
工具库使用
|
||||
HTTP 工具
|
||||
// 在组件中使用
|
||||
export default {
|
||||
async mounted() {
|
||||
// GET 请求
|
||||
const res = await this.$http.get('/api/users', { page: 1 })
|
||||
|
||||
// POST 请求
|
||||
const result = await this.$http.post('/api/users', { name: 'test' })
|
||||
|
||||
// 文件导出
|
||||
await this.$http.fileExport('/api/export', { type: 'excel' })
|
||||
}
|
||||
}
|
||||
|
||||
// 在非 Vue 组件中使用
|
||||
import AdminFramework from './libs/admin-framework.js'
|
||||
const res = await AdminFramework.http.get('/api/users')
|
||||
UI 工具
|
||||
// 在组件中使用
|
||||
export default {
|
||||
methods: {
|
||||
handleDelete() {
|
||||
// 删除确认
|
||||
this.$uiTool.delConfirm(() => {
|
||||
// 执行删除逻辑
|
||||
})
|
||||
|
||||
// 设置响应式字体
|
||||
this.$uiTool.setRem()
|
||||
|
||||
// 树形转换
|
||||
const treeData = this.$uiTool.transformTree(flatData)
|
||||
}
|
||||
}
|
||||
}
|
||||
功能工具
|
||||
// 在组件中使用
|
||||
export default {
|
||||
methods: {
|
||||
downloadFile() {
|
||||
// 文件下载
|
||||
this.$uiTool.downloadFile(response, 'filename.csv')
|
||||
}
|
||||
}
|
||||
}
|
||||
通用工具
|
||||
// 在组件中使用
|
||||
export default {
|
||||
methods: {
|
||||
formatDate() {
|
||||
// 日期格式化
|
||||
return this.$tools.formatDate(new Date(), 'YYYY-MM-DD HH:mm:ss')
|
||||
},
|
||||
|
||||
generateId() {
|
||||
// UUID 生成
|
||||
return this.$tools.generateUUID()
|
||||
},
|
||||
|
||||
setCookie() {
|
||||
// Cookie 操作
|
||||
this.$tools.setCookie('name', 'value')
|
||||
const value = this.$tools.getCookie('name')
|
||||
}
|
||||
}
|
||||
}
|
||||
Store 模块使用
|
||||
user 模块
|
||||
// 登录
|
||||
await this.$store.dispatch('user/handleLogin', {
|
||||
userFrom: { username, password },
|
||||
Main: AdminFramework.Main,
|
||||
ParentView: AdminFramework.ParentView,
|
||||
Page404: AdminFramework.Page404
|
||||
})
|
||||
|
||||
// 登出
|
||||
this.$store.dispatch('user/handleLogOut')
|
||||
|
||||
// 设置权限菜单
|
||||
this.$store.dispatch('user/setAuthorityMenus', {
|
||||
Main: AdminFramework.Main,
|
||||
ParentView: AdminFramework.ParentView,
|
||||
Page404: AdminFramework.Page404
|
||||
})
|
||||
|
||||
// 获取用户信息
|
||||
const userName = this.$store.getters['user/userName']
|
||||
const token = this.$store.state.user.token
|
||||
app 模块
|
||||
// 设置面包屑
|
||||
this.$store.commit('app/setBreadCrumb', route)
|
||||
|
||||
// 获取系统标题
|
||||
this.$store.dispatch('app/getSysTitle', {
|
||||
defaultTitle: '系统名称',
|
||||
defaultLogo: '/logo.png'
|
||||
})
|
||||
|
||||
// 获取系统配置
|
||||
const sysFormModel = this.$store.getters['app/sysFormModel']
|
||||
🗂️ 组件映射配置
|
||||
业务组件映射
|
||||
当后端权限菜单接口返回组件路径时,需要配置映射表:
|
||||
|
||||
// 1. 导入业务组件
|
||||
import GamesComponent from './views/ball/games.vue'
|
||||
import PayOrdersComponent from './views/order/pay_orders.vue'
|
||||
|
||||
// 2. 配置映射
|
||||
const componentMap = {
|
||||
'ball/games': GamesComponent,
|
||||
'ball/games.vue': GamesComponent, // 支持带 .vue 后缀
|
||||
'order/pay_orders': PayOrdersComponent,
|
||||
'order/pay_orders.vue': PayOrdersComponent
|
||||
}
|
||||
|
||||
// 3. 在 Vue.use 时传入
|
||||
Vue.use(AdminFramework, {
|
||||
config,
|
||||
ViewUI,
|
||||
VueRouter,
|
||||
Vuex,
|
||||
createPersistedState,
|
||||
componentMap // 传入组件映射表
|
||||
})
|
||||
框架已自动映射的系统组件
|
||||
以下组件无需配置,框架已自动映射:
|
||||
|
||||
✅ home/index - 主页
|
||||
✅ system/sys_user - 用户管理
|
||||
✅ system/sys_role - 角色管理
|
||||
✅ system/sys_log - 日志管理
|
||||
✅ system/sys_param_setup - 参数设置
|
||||
✅ system/sys_menu - 菜单管理
|
||||
✅ system/sys_control - 控制器管理
|
||||
✅ system/sys_title - 系统标题设置
|
||||
🌐 全局访问
|
||||
window.framework
|
||||
框架实例会自动暴露到全局,可以在任何地方访问:
|
||||
|
||||
// 在非 Vue 组件中使用
|
||||
const http = window.framework.http
|
||||
const uiTool = window.framework.uiTool
|
||||
const config = window.framework.config
|
||||
|
||||
// HTTP 请求
|
||||
const res = await window.framework.http.get('/api/users')
|
||||
|
||||
// UI 工具
|
||||
window.framework.uiTool.delConfirm(() => {
|
||||
// 删除逻辑
|
||||
})
|
||||
Vue 原型方法
|
||||
在 Vue 组件中可以直接使用:
|
||||
|
||||
export default {
|
||||
methods: {
|
||||
async loadData() {
|
||||
// 直接使用 this.$xxx
|
||||
const res = await this.$http.get('/api/users')
|
||||
this.$uiTool.delConfirm(() => {})
|
||||
this.$tools.formatDate(new Date())
|
||||
this.$uiTool.downloadFile(response, 'file.csv')
|
||||
}
|
||||
}
|
||||
}
|
||||
📁 文件下载功能
|
||||
使用 downloadFile 方法
|
||||
框架提供了便捷的文件下载功能,支持 CSV 等格式:
|
||||
|
||||
// 在 Vue 组件中使用
|
||||
export default {
|
||||
methods: {
|
||||
// 导出数据
|
||||
exportData() {
|
||||
// 调用 API 获取数据
|
||||
this.$http.fileExport('/api/export', params).then(res => {
|
||||
// 使用 downloadFile 下载
|
||||
this.$uiTool.downloadFile(res, '数据导出.csv')
|
||||
this.$Message.success('导出成功!')
|
||||
}).catch(error => {
|
||||
this.$Message.error('导出失败:' + error.message)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
支持的数据格式
|
||||
CSV 格式:自动处理换行符,保持表格格式
|
||||
Blob 对象:支持二进制文件下载
|
||||
文本数据:支持纯文本文件下载
|
||||
自动处理特性
|
||||
✅ 换行符保持:CSV 文件的换行符会被正确保持
|
||||
✅ 文件名处理:自动清理文件名中的特殊字符
|
||||
✅ 浏览器兼容:支持所有现代浏览器
|
||||
✅ 内存管理:自动清理临时 URL 对象
|
||||
🎨 全局组件使用
|
||||
FloatPanel - 浮动面板组件
|
||||
FloatPanel 是一个浮动在父窗体上的面板组件,类似于抽屉效果,常用于详情展示、表单编辑等场景。
|
||||
|
||||
基本使用:
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Button @click="showPanel">打开浮动面板</Button>
|
||||
|
||||
<FloatPanel
|
||||
ref="floatPanel"
|
||||
title="详情面板"
|
||||
position="right"
|
||||
:show-back="true"
|
||||
back-text="返回"
|
||||
@back="handleBack"
|
||||
>
|
||||
<div>这里是面板内容</div>
|
||||
</FloatPanel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
methods: {
|
||||
showPanel() {
|
||||
// 通过 ref 调用 show 方法显示面板
|
||||
this.$refs.floatPanel.show()
|
||||
},
|
||||
hidePanel() {
|
||||
// 通过 ref 调用 hide 方法隐藏面板
|
||||
this.$refs.floatPanel.hide()
|
||||
},
|
||||
handleBack() {
|
||||
console.log('返回按钮被点击')
|
||||
this.hidePanel()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
属性说明:
|
||||
|
||||
属性 类型 默认值 说明
|
||||
title String '' 面板标题
|
||||
width String/Number '100%' 面板宽度(字符串或数字),默认占满父容器
|
||||
height String/Number '100%' 面板高度(字符串或数字),默认占满父容器
|
||||
position String 'right' 面板位置:left、right、top、bottom、center
|
||||
showBack Boolean true 是否显示返回按钮
|
||||
showClose Boolean false 是否显示关闭按钮
|
||||
backText String '返回' 返回按钮文字
|
||||
closeOnClickBackdrop Boolean false 点击遮罩是否关闭
|
||||
mask Boolean false 是否显示遮罩(默认不显示)
|
||||
zIndex Number 1000 层级
|
||||
方法:
|
||||
|
||||
方法 说明 参数
|
||||
show(callback) 显示面板 callback: 可选的回调函数
|
||||
hide() 隐藏面板 -
|
||||
事件:
|
||||
|
||||
事件 说明 参数
|
||||
back 点击返回按钮时触发 -
|
||||
插槽:
|
||||
|
||||
插槽 说明
|
||||
default 面板主体内容
|
||||
header-right 头部右侧内容(可用于添加自定义按钮)
|
||||
位置说明:
|
||||
|
||||
left: 从左侧滑入
|
||||
right: 从右侧滑入(默认)
|
||||
top: 从顶部滑入
|
||||
bottom: 从底部滑入
|
||||
center: 居中显示,带缩放动画
|
||||
完整示例:
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Button @click="openDetailPanel">查看详情</Button>
|
||||
|
||||
<FloatPanel
|
||||
ref="detailPanel"
|
||||
title="用户详情"
|
||||
position="right"
|
||||
:show-back="true"
|
||||
:show-close="true"
|
||||
back-text="返回"
|
||||
@back="handleBack"
|
||||
>
|
||||
<template #header-right>
|
||||
<Button type="primary" @click="handleSave">保存</Button>
|
||||
</template>
|
||||
|
||||
<div class="detail-content">
|
||||
<Form :model="formData" :label-width="100">
|
||||
<FormItem label="用户名">
|
||||
<Input v-model="formData.username" />
|
||||
</FormItem>
|
||||
<FormItem label="邮箱">
|
||||
<Input v-model="formData.email" />
|
||||
</FormItem>
|
||||
</Form>
|
||||
</div>
|
||||
</FloatPanel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
formData: {
|
||||
username: '',
|
||||
email: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
openDetailPanel() {
|
||||
this.$refs.detailPanel.show()
|
||||
},
|
||||
handleBack() {
|
||||
this.$refs.detailPanel.hide()
|
||||
},
|
||||
handleSave() {
|
||||
// 保存逻辑
|
||||
console.log('保存数据', this.formData)
|
||||
this.$Message.success('保存成功')
|
||||
this.$refs.detailPanel.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
特性说明:
|
||||
|
||||
✅ 基于父元素定位,不会遮挡菜单
|
||||
✅ 宽度和高度默认 100%,占满父容器
|
||||
✅ 无遮罩背景,完全浮在父页面上
|
||||
✅ 路由切换或组件销毁时自动关闭
|
||||
✅ 支持多种位置和动画效果
|
||||
✅ 支持自定义头部右侧内容
|
||||
📝 业务开发示例
|
||||
创建业务页面
|
||||
<!-- src/views/business/product.vue -->
|
||||
<template>
|
||||
<div>
|
||||
<h1>产品管理</h1>
|
||||
<Button @click="loadData">加载数据</Button>
|
||||
<Tables :columns="columns" :data="list" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
list: [],
|
||||
columns: [
|
||||
{ title: 'ID', key: 'id' },
|
||||
{ title: '名称', key: 'name' },
|
||||
{ title: '价格', key: 'price' }
|
||||
]
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
await this.loadData()
|
||||
},
|
||||
methods: {
|
||||
async loadData() {
|
||||
// 使用框架提供的 http 工具
|
||||
const res = await this.$http.get('/product/list', { page: 1 })
|
||||
this.list = res.data
|
||||
},
|
||||
|
||||
async handleDelete(id) {
|
||||
// 使用框架提供的 UI 工具
|
||||
this.$uiTool.delConfirm(async () => {
|
||||
await this.$http.post('/product/delete', { id })
|
||||
this.$Message.success('删除成功')
|
||||
await this.loadData()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
创建业务 API
|
||||
// src/api/business/productServer.js
|
||||
// 注意:不需要 import http,直接使用 http
|
||||
|
||||
class ProductServer {
|
||||
async getList(params) {
|
||||
return await http.get('/product/list', params)
|
||||
}
|
||||
|
||||
async save(data) {
|
||||
return await http.post('/product/save', data)
|
||||
}
|
||||
|
||||
async delete(id) {
|
||||
return await http.post('/product/delete', { id })
|
||||
}
|
||||
|
||||
async exportCsv(params) {
|
||||
return await http.fileExport('/product/export', params)
|
||||
}
|
||||
}
|
||||
|
||||
export default new ProductServer()
|
||||
❓ 常见问题
|
||||
Q1: 打包后文件太大怎么办?
|
||||
A: 框架已经将 Vue、VueRouter、Vuex、ViewUI、Axios 设置为外部依赖,不会打包进去。确保在项目中单独安装这些依赖。
|
||||
|
||||
Q2: 如何只使用部分功能?
|
||||
A: 可以按需导入:
|
||||
|
||||
import { http, uiTool, tools } from './libs/admin-framework.js'
|
||||
Q3: 权限菜单中的业务页面显示 404 怎么办?
|
||||
A: 需要配置组件映射表:
|
||||
|
||||
Vue.use(AdminFramework, {
|
||||
// ... 其他配置
|
||||
componentMap: {
|
||||
'ball/games': GamesComponent,
|
||||
'order/pay_orders': PayOrdersComponent
|
||||
}
|
||||
})
|
||||
Q4: 如何自定义配置?
|
||||
A: 修改 config/index.js 文件:
|
||||
|
||||
module.exports = {
|
||||
title: '你的系统名称',
|
||||
apiUrl: 'http://your-api-url/',
|
||||
// ... 其他配置
|
||||
}
|
||||
Q5: 如何使用登录功能?
|
||||
A: 在组件中:
|
||||
|
||||
export default {
|
||||
methods: {
|
||||
async login() {
|
||||
await this.$store.dispatch('user/handleLogin', {
|
||||
userFrom: { username: 'admin', password: '123456' },
|
||||
Main: AdminFramework.Main,
|
||||
ParentView: AdminFramework.ParentView,
|
||||
Page404: AdminFramework.Page404
|
||||
})
|
||||
this.$router.push({ name: 'home' })
|
||||
}
|
||||
}
|
||||
}
|
||||
Q6: 需要单独引入样式文件吗?
|
||||
A: 不需要! 框架已内置所有样式:
|
||||
|
||||
✅ base.less - 基础样式
|
||||
✅ animate.css - 动画样式
|
||||
✅ ivewExpand.less - ViewUI 扩展样式
|
||||
✅ iconfont.css - 字体图标样式
|
||||
只需引入框架即可:
|
||||
|
||||
import AdminFramework from './libs/admin-framework.js'
|
||||
Vue.use(AdminFramework, { ... })
|
||||
📦 技术栈
|
||||
Vue 2.6+
|
||||
Vue Router 3.x
|
||||
Vuex 3.x
|
||||
View Design (iView) 4.x
|
||||
Axios
|
||||
Less
|
||||
Webpack 5
|
||||
📄 许可证
|
||||
MIT License
|
||||
|
||||
👨💻 作者
|
||||
light
|
||||
|
||||
祝开发愉快! 🎉
|
||||
|
||||
如有问题,请查看 Demo 项目示例或联系开发团队。
|
||||
|
||||
Powered by Gitea
|
||||
当前版本:
|
||||
1.24.6
|
||||
页面:
|
||||
273ms
|
||||
模板:
|
||||
13ms
|
||||
许可证
|
||||
API
|
||||
@@ -1,59 +0,0 @@
|
||||
# full_flow 删除说明
|
||||
|
||||
## ✅ 已删除的 full_flow 相关代码
|
||||
|
||||
### 1. 任务处理器注册
|
||||
- ✅ 删除了 `registerTaskHandlers()` 中的 `full_flow` 处理器注册
|
||||
- ✅ 删除了 `handleFullFlowTask()` 方法
|
||||
|
||||
### 2. 手动任务处理
|
||||
- ✅ 修改了 `handleManualJobRequest()`,移除了 `full_flow` 的特殊处理
|
||||
- ✅ 将 `manualExecuteJobFlow()` 标记为废弃,抛出错误提示
|
||||
|
||||
### 3. 定时任务
|
||||
- ✅ 修改了 `executeScheduledJobFlow()`,移除了 `full_flow` 任务添加
|
||||
- ✅ 添加了注释说明 `full_flow` 已废弃
|
||||
|
||||
### 4. 任务队列
|
||||
- ✅ 删除了 `taskQueue.js` 中 `getTaskCommands()` 的 `case 'full_flow':` 分支
|
||||
|
||||
### 5. 配置
|
||||
- ✅ 删除了 `config.js` 中 `taskTimeouts.full_flow` 配置
|
||||
- ✅ 删除了 `config.js` 中 `taskPriorities.full_flow` 配置
|
||||
|
||||
### 6. 数据模型注释
|
||||
- ✅ 更新了 `task_status.js` 模型中的 `taskType` 注释,移除了 `full_flow` 说明
|
||||
|
||||
## 📝 保留的方法(已废弃)
|
||||
|
||||
以下方法已标记为废弃,但保留在代码中以便向后兼容:
|
||||
|
||||
1. **`manualExecuteJobFlow()`**
|
||||
- 状态:已废弃
|
||||
- 行为:抛出错误,提示使用其他任务类型
|
||||
- 位置:`api/middleware/schedule/index.js:681`
|
||||
|
||||
2. **`executeScheduledJobFlow()`**
|
||||
- 状态:定时任务已注释,方法保留但不再添加 `full_flow` 任务
|
||||
- 位置:`api/middleware/schedule/index.js:578`
|
||||
|
||||
## ⚠️ 注意事项
|
||||
|
||||
1. **定时任务已禁用**
|
||||
- `executeScheduledJobFlow()` 的定时任务调用已被注释
|
||||
- 如需恢复定时任务,请使用其他任务类型(如 `auto_deliver`)
|
||||
|
||||
2. **替代方案**
|
||||
- 如需执行完整流程,请使用:
|
||||
- `auto_deliver` - 自动投递任务
|
||||
- `get_job_list` - 获取岗位列表
|
||||
- `apply_job` - 投递简历
|
||||
- 其他独立任务类型
|
||||
|
||||
|
||||
## 🔄 后续建议
|
||||
|
||||
如果不再需要以下方法,可以考虑完全删除:
|
||||
- `executeScheduledJobFlow()` - 如果定时任务不再使用
|
||||
- `manualExecuteJobFlow()` - 如果所有调用都已更新
|
||||
|
||||
24
_doc/js/app.25a32752.js
Normal file
24
_doc/js/app.25a32752.js
Normal file
File diff suppressed because one or more lines are too long
20463
_doc/js/chat.js
Normal file
20463
_doc/js/chat.js
Normal file
File diff suppressed because it is too large
Load Diff
5781
_doc/js/mqtt.js
Normal file
5781
_doc/js/mqtt.js
Normal file
File diff suppressed because it is too large
Load Diff
176
_doc/task.md
176
_doc/task.md
@@ -1,176 +0,0 @@
|
||||
# task_status_${snCode} 主题推送位置分析
|
||||
|
||||
## 一、推送位置(服务端)
|
||||
|
||||
### 1. 任务状态变更推送 (`task_status_update`)
|
||||
|
||||
**位置**: `autoAiWorkSys/api/middleware/schedule/taskQueue.js`
|
||||
|
||||
**方法**: `notifyTaskStatusChange(sn_code, taskData)`
|
||||
|
||||
**推送时机**:
|
||||
- ✅ **任务开始执行时** (第586行)
|
||||
- 状态: `running`
|
||||
- 进度: `0`
|
||||
- 触发位置: `executeTask` 方法中,任务开始执行时
|
||||
|
||||
- ✅ **任务完成时** (第631行)
|
||||
- 状态: `completed`
|
||||
- 进度: `100`
|
||||
- 触发位置: `executeTask` 方法中,任务成功完成后
|
||||
|
||||
- ✅ **任务失败时** (第675行)
|
||||
- 状态: `failed`
|
||||
- 进度: `0`
|
||||
- 错误信息: `errorMessage`
|
||||
- 触发位置: `executeTask` 方法中,任务执行失败时
|
||||
|
||||
**推送内容**:
|
||||
```javascript
|
||||
{
|
||||
action: 'task_status_update',
|
||||
data: {
|
||||
taskId: task.id,
|
||||
taskName: task.taskName,
|
||||
taskType: task.taskType,
|
||||
status: 'running' | 'completed' | 'failed',
|
||||
progress: 0 | 100,
|
||||
errorMessage?: string,
|
||||
endTime?: Date
|
||||
},
|
||||
timestamp: string
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 任务状态摘要推送 (`task_status_summary`)
|
||||
|
||||
**位置**: `autoAiWorkSys/api/middleware/schedule/taskQueue.js`
|
||||
|
||||
**方法**: `sendTaskStatusSummary(sn_code)`
|
||||
|
||||
**推送时机**:
|
||||
- ✅ **定时推送** (每10秒一次)
|
||||
- 位置: `autoAiWorkSys/api/middleware/schedule/scheduledJobs.js`
|
||||
- 方法: `syncTaskStatusSummary()`
|
||||
- 条件: 只向在线设备推送(最后心跳时间 < 3分钟)
|
||||
|
||||
**推送内容**:
|
||||
```javascript
|
||||
{
|
||||
action: 'task_status_summary',
|
||||
data: {
|
||||
sn_code: string,
|
||||
currentTask: {
|
||||
taskId: number,
|
||||
taskName: string,
|
||||
taskType: string,
|
||||
status: 'running',
|
||||
progress: number,
|
||||
currentStep: string,
|
||||
startTime: Date,
|
||||
jobTitle?: string, // 新增:职位名称
|
||||
companyName?: string // 新增:公司名称
|
||||
} | null,
|
||||
pendingTasks: Array<{
|
||||
taskId: number,
|
||||
taskName: string,
|
||||
taskType: string,
|
||||
status: 'pending',
|
||||
scheduledTime: Date,
|
||||
priority: number
|
||||
}>,
|
||||
nextTaskTime: Date | null,
|
||||
pendingCount: number, // 队列中的任务数
|
||||
totalPendingCount: number, // 总待执行数(包括当前任务的剩余步骤)
|
||||
mqttTopic: string,
|
||||
timestamp: string
|
||||
},
|
||||
timestamp: string
|
||||
}
|
||||
```
|
||||
|
||||
## 二、接收位置(客户端)
|
||||
|
||||
### 1. MQTT 订阅
|
||||
|
||||
**位置**: `boss-automation-nodejs/src/services/mqttService.js`
|
||||
|
||||
**订阅时机**: MQTT 连接成功后 (第360行)
|
||||
|
||||
**订阅代码**:
|
||||
```javascript
|
||||
const taskStatusTopic = `task_status_${this.config.snCode}`;
|
||||
this.client.subscribe(taskStatusTopic, { qos: 1 });
|
||||
```
|
||||
|
||||
### 2. 消息处理
|
||||
|
||||
**位置**: `boss-automation-nodejs/src/services/mqttService.js`
|
||||
|
||||
**处理方法**: `handleMessage` (第292行)
|
||||
|
||||
**处理逻辑**:
|
||||
- 如果 `action === 'task_status_update'`:
|
||||
- 发送到渲染进程: `task:status-update`
|
||||
|
||||
- 如果 `action === 'task_status_summary'`:
|
||||
- 发送到渲染进程: `task:status-summary`
|
||||
|
||||
### 3. 渲染进程接收
|
||||
|
||||
**位置**: `boss-automation-nodejs/app/mixins/taskMixin.js`
|
||||
|
||||
**方法**:
|
||||
- `onTaskStatusUpdate(taskData)` - 处理任务状态变更
|
||||
- `onTaskStatusSummary(summary)` - 处理任务状态摘要
|
||||
|
||||
**事件监听**: `boss-automation-nodejs/app/mixins/eventListenerMixin.js`
|
||||
|
||||
## 三、当前推送策略分析
|
||||
|
||||
### ✅ 优点
|
||||
1. **实时性**: 任务状态变更立即推送
|
||||
2. **完整性**: 定时推送完整的状态摘要
|
||||
3. **可靠性**: 只向在线设备推送,避免资源浪费
|
||||
|
||||
### ⚠️ 潜在问题
|
||||
1. **推送频率**: 定时任务每10秒推送一次,可能过于频繁
|
||||
2. **重复推送**: 任务状态变更和定时摘要可能推送重复信息
|
||||
3. **推送时机**: 某些关键状态变更可能没有及时推送(如进度更新)
|
||||
|
||||
## 四、建议的优化方案
|
||||
|
||||
### 方案1: 减少定时推送频率
|
||||
- 当前: 每10秒推送一次
|
||||
- 建议: 改为每30秒或60秒推送一次
|
||||
- 理由: 任务状态变更已实时推送,定时推送主要用于同步完整状态
|
||||
|
||||
### 方案2: 只在关键时机推送摘要
|
||||
- 任务队列变化时推送(新增/删除任务)
|
||||
- 当前任务完成/失败时推送
|
||||
- 定时推送作为兜底(频率降低)
|
||||
|
||||
### 方案3: 合并推送
|
||||
- 将任务状态变更和摘要合并为一个消息
|
||||
- 减少消息数量,提高效率
|
||||
|
||||
### 方案4: 添加进度更新推送
|
||||
- 任务执行过程中,定期推送进度更新
|
||||
- 例如:每完成10%进度推送一次
|
||||
|
||||
## 五、需要推送的场景建议
|
||||
|
||||
### ✅ 必须推送的场景
|
||||
1. **任务开始执行** - 立即推送状态变更
|
||||
2. **任务完成** - 立即推送状态变更 + 状态摘要
|
||||
3. **任务失败** - 立即推送状态变更 + 状态摘要
|
||||
4. **任务队列变化** - 新增/删除任务时推送摘要
|
||||
|
||||
### ⚠️ 可选推送的场景
|
||||
1. **进度更新** - 每10%或每完成一个步骤推送一次
|
||||
2. **定时同步** - 作为兜底,频率降低到30-60秒
|
||||
|
||||
### ❌ 不需要推送的场景
|
||||
1. **任务状态未变化** - 避免重复推送相同状态
|
||||
2. **设备离线** - 不向离线设备推送
|
||||
|
||||
453
_doc/公司xinxi.md
453
_doc/公司xinxi.md
@@ -1,453 +0,0 @@
|
||||
序号 证券代码 公司中文名称 注册地址 公司电话 公司电子邮件地址 公司网站
|
||||
1 300890.SZ 上海市翔丰华科技股份有限公司 上海市宝山区萧云路635弄11号一层 86-21-66566217 public@xfhinc.com www.xiangfenghua.com
|
||||
2 000863.SZ 三湘印象股份有限公司 上海市杨浦区逸仙路333号501室 86-21-65364018 sxgf000863@sxgf.com www.sxgf.com
|
||||
3 001266.SZ 上海宏英智能科技股份有限公司 上海市嘉定区真南路4268号2幢J11387室 86-21-37829918 info@smartsh.com;smart@smartsh.com www.smartsh.com
|
||||
4 002022.SZ 上海科华生物工程股份有限公司 上海市徐汇区钦州北路1189号 86-21-64954576 kehua@skhb.com www.skhb.com
|
||||
5 002028.SZ 思源电气股份有限公司 上海市闵行区金都路4399号 86-21-61610958 IR@SIEYUAN.COM www.sieyuan.com
|
||||
6 002058.SZ 上海威尔泰工业自动化股份有限公司 上海市闵行区虹中路263号1幢 86-21-64656828 dm@welltech.com.cn;dm@welleteh.com.cn www.welltech.cn
|
||||
7 002116.SZ 中国海诚工程科技股份有限公司 上海市徐汇区宝庆路21号 86-21-64314018 haisum@haisum.com www.haisum.com
|
||||
8 002158.SZ 上海汉钟精机股份有限公司 上海市金山区枫泾镇建贡路108号 86-21-57350280*1005,86-21-57350280*1132,86-21-57350280*1131 IR@hanbell.cn;amywu@hanbell.cn;gracechiu@hanbell.cn www.hanbell.com.cn
|
||||
9 002162.SZ 上海悦心健康集团股份有限公司 上海市闵行区浦江镇三鲁公路2121号 86-21-54333699 zqb@cimic.com www.everjoyhealth.com
|
||||
10 002178.SZ 上海延华智能科技(集团)股份有限公司 上海市普陀区西康路1255号6楼602 86-21-61818686*309 yanhua_sh@126.com www.chinaforwards.com
|
||||
11 002184.SZ 上海海得控制系统股份有限公司 上海市闵行区新骏环路777号 86-21-60572990 002184@hite.com.cn www.hite.com.cn
|
||||
12 002195.SZ 上海岩山科技股份有限公司 上海市自由贸易试验区张江路665号三层 86-21-64822345,86-21-61462195 stock@stonehill-tech.com www.stonehill-tech.com
|
||||
13 002211.SZ 上海宏达新材料股份有限公司 上海市闵行区春常路18号1幢2层A区 86-21-64036071 hongda@002211sh.com;wangyanjie@002211sh.com;huanglei@002211sh.com www.002211sh.com
|
||||
14 002252.SZ 上海莱士血液制品股份有限公司 上海市奉贤区望园路2009号 86-21-22130888*217 raas@raas-corp.com www.raas-corp.com
|
||||
15 002269.SZ 上海美特斯邦威服饰股份有限公司 上海市浦东新区康桥东路800号 86-21-38119999,86-21-68182996 Corporate@metersbonwe.com corp.metersbonwe.com
|
||||
16 002278.SZ 上海神开石油化工装备股份有限公司 上海市闵行区浦星公路1769号 86-21-64293895 skdb@shenkai.com www.shenkai.com
|
||||
17 002324.SZ 上海普利特复合材料股份有限公司 上海市青浦区赵巷镇沪青平公路2855弄1号12楼 86-21-31115910 dsh@pret.com.cn;chumh@pret.com.cn;yangfan@pret.com.cn;caiq@pret.com.cn www.pret.com.cn
|
||||
18 002328.SZ 上海新朋实业股份有限公司 上海市青浦区华新镇华隆路1698号 86-21-31166512 xinpengstock@xinpeng.com www.xinpeng.com
|
||||
19 002346.SZ 上海柘中集团股份有限公司 上海市奉贤区联合北路215号第5幢2501室 86-21-57403737 lilizhuan@zhezhong.com;yangyifei@zhezhong.com www.zhezhong.com
|
||||
20 002401.SZ 中远海运科技股份有限公司 上海市浦东新区沈家弄路738号 86-21-65969398 yu.jianzhong@coscoshipping.com;ma.chi@coscoshipping.com tech.coscoshipping.com
|
||||
21 002451.SZ 上海摩恩电气股份有限公司 上海市浦东新区江山路2829号 86-21-58979608 investor@mornelectric.com;huangsz@mornelectric.com www.mornelectric.com
|
||||
22 002454.SZ 上海加冷松芝汽车空调股份有限公司 上海市闵行区莘庄工业区华宁路4999号 86-21-52634750,86-21-52634750*1602 shstock@shsongz.com.cn;chenrui@shsongz.com www.shsongz.com.cn
|
||||
23 002486.SZ 上海嘉麟杰纺织品股份有限公司 上海市金山区亭林镇亭枫公路1918号 86-10-63541462 investor@challenge-21c.com;jljzqb@challenge-21c.com www.challenge-21c.com
|
||||
24 002506.SZ 协鑫集成科技股份有限公司 上海市奉贤区南桥镇江海经济园区 86-512-69832889 gclsizqb@gclsi.com www.gclsi.com
|
||||
25 002527.SZ 上海新时达电气股份有限公司 上海市嘉定区思义路1560号 86-21-69926000,86-21-69896737 liujing@stepelectric.com;step@stepelectric.com;wanzx@stepelectric.com www.stepelectric.com
|
||||
26 002561.SZ 上海徐家汇商城股份有限公司 上海市徐汇区肇嘉浜路1068号 86-21-64269991,86-21-64269999 xjh@xjh-sc.com;xu3958@sina.com;xyq@xjh-sc.com www.xjh-sc.com
|
||||
27 002565.SZ 上海顺灏新材料科技股份有限公司 上海市普陀区真陈路200号 86-21-66278702 investor@shunhostock.com;duyunbo@shunhostock.com;zhouxiaofeng@shunhostock.com www.shunhostock.com
|
||||
28 002568.SZ 上海百润投资控股集团股份有限公司 上海市浦东新区康桥工业区康桥东路558号 86-21-58160073 Bairun@bairun.net;Chen.wang@bairun.net;Jiajie.tang@bairun.net www.bairun.net
|
||||
29 002605.SZ 上海姚记科技股份有限公司 上海市嘉定区黄渡镇曹安路4218号 86-21-53308852,86-21-69595008 secretarybd@yaojipoker.com;ir@yaoji.cn www.yaojikeji.com
|
||||
30 002636.SZ 金安国纪集团股份有限公司 上海市松江区工业区宝胜路33号 86-21-57747138 gdmir@goldenmax.cn www.goldenmax.cn
|
||||
31 002669.SZ 康达新材料(集团)股份有限公司 上海市奉贤区雷州路169号 86-21-50770196,86-21-50779159 kdxc@shkdchem.com www.shkdchem.com
|
||||
32 002706.SZ 上海良信电器股份有限公司 上海市浦东新区申江南路2000号 86-21-68586651,86-21-68586632 wangrui22629@sh-liangxin.com;chengqiugao@sh-liangxin.com www.sh-liangxin.com
|
||||
33 002825.SZ 上海纳尔实业股份有限公司 上海市浦东新区新场镇新瀚路26号 86-21-31272888 ir@nar.com.cn;qiyan@nar.com.cn www.nar.com.cn
|
||||
34 002858.SZ 力盛云动(上海)体育科技股份有限公司 上海市松江区佘山镇沈砖公路3000号 86-21-62418755 ir@lsaisports.com;panyujie@lsaisports.com www.lsaisports.com
|
||||
35 300008.SZ 天海融合防务装备技术股份有限公司 上海市松江区莘砖公路518号10幢8层 86-21-60859800*9374,86-21-60859800*9837,86-21-60859745 public@bestwaysh.com;luyingying@bestwaysh.com;dongwenjie@bestwaysh.com www.bestwaysh.com
|
||||
36 300017.SZ 网宿科技股份有限公司 上海市嘉定区环城路200号 86-21-64685982 wangsudmb@wangsu.com www.wangsu.com
|
||||
37 300039.SZ 上海凯宝药业股份有限公司 上海市奉贤区工业综合开发区程普路88号 86-21-37572069 kbyydmb@126.com www.xykb.com
|
||||
38 300059.SZ 东方财富信息股份有限公司 上海市嘉定区宝安公路2999号1幢 86-21-54660526 dongmi@eastmoney.com www.eastmoney.com
|
||||
39 300061.SZ 旗天科技集团股份有限公司 上海市金山区山阳镇亭卫公路1000号2幢203室 86-21-60975620 investor@qt300061.com www.qt300061.com
|
||||
40 300067.SZ 上海安诺其集团股份有限公司 上海市青浦区工业园区菘华路881号 86-21-59867500 investor@anoky.com.cn www.anoky.com.cn
|
||||
41 300074.SZ 华平信息技术股份有限公司 上海市杨浦区国权北路1688弄A6座 86-21-65650210 ir@avcon.com.cn www.avcon.com.cn
|
||||
42 300126.SZ 锐奇控股股份有限公司 上海市松江区新桥镇新茸路5号 86-21-57825832 300126@china-ken.com www.ken-tools.com
|
||||
43 300129.SZ 泰胜风能集团股份有限公司 上海市金山区卫清东路1988号 86-21-57243692 liweitao@shtsp.com;ir@shtsp.com;chenjie@shtsp.com;chenyiyao@shtsp.com www.shtsp.com
|
||||
44 300153.SZ 上海科泰电源股份有限公司 上海市青浦区张江高新区青浦园天辰路1633号 86-21-69758019,86-21-69758012 irm@cooltechsh.com;xukun@cooltechsh.com;dengjie@cooltechsh.com www.cooltechsh.com
|
||||
45 300168.SZ 万达信息股份有限公司 上海市徐汇区桂平路481号20号楼5层 86-21-62489636 invest@wondersgroup.com www.wondersgroup.com
|
||||
46 300170.SZ 上海汉得信息技术股份有限公司 上海市青浦区汇联路33号 86-21-50177372,86-21-67002300 investors@vip.hand-china.com www.hand-china.com
|
||||
47 300171.SZ 东富龙科技集团股份有限公司 上海市闵行区都会路1509号4幢 86-21-64909699 dfl@tofflon.com www.tofflon.com
|
||||
48 300180.SZ 上海华峰超纤科技股份有限公司 上海市金山区亭卫南路888号 86-21-57243140 fu.juan@huafeng.com;chu.yuxi@huafeng.com microfibre.huafeng.com
|
||||
49 300222.SZ 科大智能科技股份有限公司 上海市松江区泗砖路777号1幢503室 86-21-50804882 kdzn@csg.com.cn;mjb@csg.com.cn www.csg.com.cn
|
||||
50 300225.SZ 上海金力泰化工股份有限公司 上海市奉贤区楚工路139号 86-21-31156097 knttzxx@knt.cn www.knt.cn
|
||||
51 300226.SZ 上海钢联电子商务股份有限公司 上海市宝山区园丰路68号 86-21-26093997 public@mysteel.com about.mysteel.com
|
||||
52 300230.SZ 上海永利带业股份有限公司 上海市青浦区徐泾镇徐旺路58号 86-21-59884061 yongli@yonglibelt.com;ycl@yonglibelt.com;zhongmm@yonglibelt.com www.yonglibelt.com
|
||||
53 300236.SZ 上海新阳半导体材料股份有限公司 上海市松江区思贤路3600号 86-21-57850066 info@sinyang.com.cn www.sinyang.com.cn
|
||||
54 300245.SZ 上海天玑科技股份有限公司 上海市闵行区田林路1016号6号楼 86-21-54278888 public@dnt.com.cn www.dnt.com.cn
|
||||
55 300253.SZ 卫宁健康科技集团股份有限公司 上海市浦东新区东育路255弄4号3楼B29 86-21-80331033 wndsh@winning.com.cn www.winning.com.cn
|
||||
56 300257.SZ 开山集团股份有限公司 上海市自由贸易试验区临港新片区飞渡路851号 86-570-3662177,86-21-62261893 zqtzb@kaishangroup.com;yang.jianjun@kaishangroup.com;wan.shiqi@kaishangroup.com www.kaishancomp.com
|
||||
57 300272.SZ 开能健康科技集团股份有限公司 上海市浦东新区川沙镇川大路508,518号 86-21-58599901 dongmiban@canature.com www.canature.com
|
||||
58 300286.SZ 安科瑞电气股份有限公司 上海市嘉定区育绿路253号 86-21-69158331 acrel@acrel.cn www.acrel.cn
|
||||
59 300326.SZ 上海凯利泰医疗科技股份有限公司 上海市浦东新区张江高科技园东区瑞庆路528号23幢 86-21-50728758 KMC@shkmc.com.cn;mengchensun@shkmc.com.cn www.kineticmedinc.com.cn
|
||||
60 300327.SZ 中颖电子股份有限公司 上海市长宁区金钟路767弄3号 86-21-61219988,86-21-61219988*1688 ir@sinowealth.com;dpsino168@126.com;jxsino327@126.com www.sinowealth.com
|
||||
61 300378.SZ 鼎捷数智股份有限公司 上海市静安区江场路1377弄绿地中央广场7号20层 86-21-51791699 digiwin-zhengquan@digiwin.com www.digiwin.com
|
||||
62 300380.SZ 上海安硕信息技术股份有限公司 上海市杨浦区国泰路11号2308室 86-21-55137223 ir@amarsoft.com www.amarsoft.com
|
||||
63 300398.SZ 上海飞凯材料科技股份有限公司 上海市宝山区潘泾路2999号 86-21-50322662 investor@phichem.com.cn www.phichem.com.cn
|
||||
64 300462.SZ 上海华铭智能终端设备股份有限公司 上海市松江区茸梅路895号 86-21-57784382,86-21-57784382*288 hmzn300462@hmmachine.com www.hmmachine.com
|
||||
65 300483.SZ 首华燃气科技(上海)股份有限公司 上海市闵行区元江路5000号 86-21-58831588,86-10-52253050 db@primagas.com.cn;qian.zhang@primagas.com.cn;zhulin.wang@primagas.com.cn www.primagas.com.cn
|
||||
66 300493.SZ 上海润欣科技股份有限公司 上海市徐汇区田林路200号A号楼301室 86-21-54264260 investment@fortune-co.com www.fortune-co.com
|
||||
67 300501.SZ 上海海顺新型药用包装材料股份有限公司 上海市松江区莘砖公路3456弄 86-21-37017626,86-21-37667766 ir@hysum.com;zhengquan@haishunpackaging.com;zhengquan@hysum.com www.hysum.com
|
||||
68 300508.SZ 上海维宏电子科技股份有限公司 上海市闵行区都会路975弄119号1幢501室 86-21-33587515 weihongzq@weihong.com.cn www.weihong.com.cn
|
||||
69 300511.SZ 上海雪榕生物科技股份有限公司 上海市奉贤区汇丰西路1487号 86-21-37198681 xrtz@xuerong.com www.xuerong.com
|
||||
70 300551.SZ 上海古鳌电子科技股份有限公司 上海市普陀区同普路1225弄6号 86-21-22252595 ir@gooao.cn www.gooao.cn
|
||||
71 300578.SZ 上海会畅通讯股份有限公司 上海市金山区吕巷镇红光路4200-4201号2757室 86-21-61321868,86-21-60716636,86-21-60716686 BDoffice@bizconf.cn www.bizconf.cn
|
||||
72 300590.SZ 上海移为通信技术股份有限公司 上海市闵行区新龙路500弄30号 86-21-54450318 stock@queclink.com www.queclink.com
|
||||
73 300609.SZ 汇纳科技股份有限公司 上海市浦东新区中国(上海)自由贸易试验区川和路55弄6号 86-21-60220636,86-21-31759693 sadep@winnerinf.com www.winnerinf.com
|
||||
74 300613.SZ 上海富瀚微电子股份有限公司 上海市徐汇区宜山路717号6楼 86-21-64066786,86-21-61121558,86-21-64066785 stock@fullhan.com www.fullhan.com
|
||||
75 300627.SZ 上海华测导航技术股份有限公司 上海市青浦区徐泾镇高泾路599号C座(一照多址企业) 86-21-64950939 huace@huace.cn www.huace.cn
|
||||
76 300642.SZ 上海透景生命科技股份有限公司 上海市浦东新区汇庆路412号 86-21-50495115 info@tellgen.com www.tellgen.com
|
||||
77 300762.SZ 上海瀚讯信息技术股份有限公司 上海市嘉定区鹤友路258号 86-21-62386622,86-21-32510369 Info_disclosure@jushri.com www.jushri.com
|
||||
78 300802.SZ 上海矩子科技股份有限公司 上海市徐汇区云锦路701号33层3301单元(实际29层) 86-21-64969730 investors@jutze.com.cn www.jutze.com.cn
|
||||
79 300892.SZ 品渥食品股份有限公司 上海市松江区佘山镇新宅路777弄3号 86-21-51863006 securities@pinlive.com www.pinlive.com
|
||||
80 300899.SZ 上海凯鑫分离技术股份有限公司 上海市浦东新区中国(上海)自由贸易试验区张江路665号3层 86-21-58988820 shkx@keysino.cn www.keysinosep.com
|
||||
81 300915.SZ 上海海融食品科技股份有限公司 上海市奉贤区金汇镇金斗路666号 86-21-37560135 hrkj@hiroad.sh.cn www.hiroad.sh.cn
|
||||
82 300947.SZ 上海德必文化创意产业发展(集团)股份有限公司 上海市长宁区安化路492号1幢707室 86-21-60701389 sec@dobechina.com www.dobechina.com
|
||||
83 300963.SZ 上海中洲特种合金材料股份有限公司 上海市嘉定工业区世盛路580号 86-21-59966058 zhz@shzztc.com;wyj@shzztc.com;gjy@shzztc.com www.alloy-china.com
|
||||
84 300983.SZ 上海尤安建筑设计股份有限公司 上海市宝山区一二八纪念路968号1618室 86-21-35324001 uachina@uachina.com.cn www.uachina.com.cn
|
||||
85 300999.SZ 益海嘉里金龙鱼食品集团股份有限公司 上海市浦东新区中国(上海)自由贸易试验区博成路1379号15层 86-21-31823188 jinlongyu_ir@cn.wilmar-intl.com www.yihaikerry.net.cn
|
||||
86 301000.SZ 上海肇民新材料科技股份有限公司 上海市金山区金山卫镇秦弯路633号 86-21-57930288 stock@hps-sh.com www.hps-sh.com
|
||||
87 301001.SZ 上海凯淳实业股份有限公司 上海市金山工业区天工路857号2幢2401室 86-21-55080030 IR@kaytune.com www.kaytune.com
|
||||
88 301005.SZ 超捷紧固系统(上海)股份有限公司 上海市嘉定区丰硕路100弄39号 86-21-59907242 lihongtao@shchaojie.com.cn;liss@shchaojie.com.cn www.shchaojie.com.cn
|
||||
89 301024.SZ 上海霍普建筑设计事务所股份有限公司 上海市浦东新区中国(上海)自由贸易试验区滨江大道469号中企财富世纪大厦5层,10层(实际楼层4层,9层) 86-21-58783137 ir@hyp-arch.com www.hyp-arch.com
|
||||
90 301025.SZ 读客文化股份有限公司 上海市金山区枫泾镇枫湾路531,535号1幢1层103室 86-21-33608311 stock@dookbook.com;wangkun@dookbook.com;liubaorui@dookbook.com www.dookbook.com
|
||||
91 301037.SZ 上海保立佳化工股份有限公司 上海市奉贤区泰日镇大叶公路6828号 86-21-31167902 dongban@baolijia.com.cn www.baolijia.com.cn
|
||||
92 301046.SZ 上海能辉科技股份有限公司 上海市普陀区金通路799,899,999号17幢3层307室 86-21-50896255 nenghui@nhet.com.cn;luolianming@nhet.com.cn;yangjing@nhet.com.cn www.nhet.com.cn
|
||||
93 301060.SZ 上海兰卫医学检验所股份有限公司 上海市长宁区临新路268弄1号楼4-9层 86-21-31778162 labway@labway.cn www.labway.cn
|
||||
94 301062.SZ 上海艾录包装股份有限公司 上海市金山区山阳镇阳乐路88号 86-21-57293030*6507 info@ailugroup.com www.ailugroup.com
|
||||
95 301070.SZ 开勒环境科技(上海)股份有限公司 上海市松江区新桥镇新格路505号3幢2楼南5区 86-21-57685221 kaledongmi@kalefans.com www.kalefans.com
|
||||
96 301099.SZ 上海雅创电子集团股份有限公司 上海市闵行区春光路99弄62号2-3楼及402-405室 86-21-51866509,86-21-51516111*8033 security@yctexin.com www.yctexin.com
|
||||
97 301151.SZ 上海冠龙阀门节能设备股份有限公司 上海市嘉定区南翔镇德园路815号 86-21-31229378 investor@karon-valve.com www.karon-valve.com
|
||||
98 301156.SZ 上海美农生物科技股份有限公司 上海市嘉定区沥红路151号 86-21-59546881 mnsw@sinomenon.com www.sinomenon.com
|
||||
99 301161.SZ 上海唯万密封科技股份有限公司 上海市浦东新区龙东大道6111号1幢2层B216室 86-21-68184680 voneseals@voneseals.com;yuxin_wang@voneseals.com www.voneseals.com
|
||||
100 301166.SZ 上海优宁维生物科技股份有限公司 上海市杨浦区控江路1690号1505室 86-21-38939097,86-21-38939070 ir@univ-bio.com www.univ-bio.com
|
||||
101 301228.SZ 实朴检测技术(上海)股份有限公司 上海市闵行区中春路1288号34幢3层301室,4层401室 86-21-64881367 IR@sepchina.cn www.sepchina.cn
|
||||
102 301230.SZ 上海泓博智源医药股份有限公司 上海市浦东新区庆达路315号23幢 86-21-50720100 info@pharmaresources.cn www.pharmaresources.cn
|
||||
103 301257.SZ 普蕊斯(上海)医药科技开发股份有限公司 上海市黄浦区思南路105号1号楼108室 86-21-60755800 IR@smo-clinplus.com www.smo-clinplus.com
|
||||
104 301273.SZ 上海瑞晨环保科技股份有限公司 上海市嘉定区申霞路358号3幢C区 86-21-55789678 wanghanze@richenenergy.com;wenya@richenenergy.com www.richenenergy.com.cn
|
||||
105 301289.SZ 上海国缆检测股份有限公司 上海市宝山区真陈路888号 86-21-65493333*2201,86-21-65493333*2612 guolandb@ticw.com.cn www.ticw.com.cn
|
||||
106 301303.SZ 上海真兰仪表科技股份有限公司 上海市青浦区崧达路800号 86-21-31166688,86-21-31167958 info@zenner-metering.com www.zenner-metering.com
|
||||
107 301419.SZ 上海阿莱德实业集团股份有限公司 上海市奉贤区奉炮公路1368号6栋 86-21-56480200 allied@allied-corp.com;zoe.zhou@allied-corp.com;xiafan.li@allied-corp.com www.allied-corp.com
|
||||
108 600000.SH 上海浦东发展银行股份有限公司 上海市黄浦区中山东一路12号 86-21-63611226,86-21-61618888 ligm-hhht@spdb.com.cn;wur2@spdb.com.cn;bdo@spdb.com.cn;zhangj8@spdb.com.cn www.spdb.com.cn
|
||||
109 600009.SH 上海国际机场股份有限公司 上海市浦东新区启航路900号 86-21-68341609 ir@shairport.com www.avinex.com
|
||||
110 600018.SH 上海国际港务(集团)股份有限公司 上海市自由贸易试验区临港新片区同汇路1号综合大楼A区4楼 86-21-55333388 dongmi@portshanghai.com.cn;600018@portshanghai.com.cn www.portshanghai.com.cn
|
||||
111 600019.SH 宝山钢铁股份有限公司 上海市宝山区富锦路885号宝钢指挥中心 86-21-26647000 ir@baosteel.com www.baosteel.com
|
||||
112 600021.SH 上海电力股份有限公司 上海市浦东新区高科西路1号上电大厦 86-21-23108718,86-21-23108800 sepco@shanghaipower.com;shanghaipower@spic.com.cn www.shanghaipower.com
|
||||
113 600026.SH 中远海运能源运输股份有限公司 上海市中国(上海)自由贸易试验区临港新片区业盛路188号A-1015室 86-21-65967678 ir.energy@coscoshipping.com energy.coscoshipping.com
|
||||
114 600061.SH 国投资本股份有限公司 上海市中国(上海)自由贸易试验区北张家浜路128号204-3,204-4,204-5室 86-10-83325163 600061@sdic.com.cn www.sdiccapital.com
|
||||
115 600072.SH 中船科技股份有限公司 上海市杨浦区周家嘴路3255号10楼 86-21-63022385 mail@cssckj.com www.cssckj.com
|
||||
116 600073.SH 上海光明肉业集团股份有限公司 上海市浦东新区川桥路1501号7幢101室 86-21-22866016,86-21-55669312 ml@shanghaimaling.com;ir@shanghaimaling.com;ir@brightmeat.com www.brightmeat.com
|
||||
117 600081.SH 东风电子科技股份有限公司 上海市闵行区浦江镇新骏环路88号13幢203室 86-21-52917811 postmaster@detc.com.cn;zhengming@detc.com.cn;lifei@detc.com.cn;luww@detc.com.cn www.detc.com.cn
|
||||
118 600088.SH 中视传媒股份有限公司 上海市浦东新区福山路450号新天国际大厦17层A座 86-21-68765168 irmanager@ctv-media.com.cn www.ctv-media.com.cn
|
||||
119 600094.SH 上海大名城企业股份有限公司 上海市闵行区红松东路1116号1幢5楼A区 86-21-62478900,86-21-62470088 dmc@greattown.cn;chizhiqiang@greattown.cn;zhangyanqi@greattown.cn www.greattown.cn
|
||||
120 600097.SH 上海开创国际海洋资源股份有限公司 上海市浦东新区外高桥保税区新灵路118号1201A室 86-21-65686875,86-21-65690310 ir@skmic.sh.cn www.skmic.sh.cn
|
||||
121 600104.SH 上海汽车集团股份有限公司 上海市浦东新区中国(上海)自由贸易试验区松涛路563号1号楼509室 86-21-22011138 saicmotor@saic.com.cn www.saicmotor.com
|
||||
122 600115.SH 中国东方航空股份有限公司 上海市浦东新区国际机场机场大道66号 86-21-22330932 ir@ceair.com www.ceair.com
|
||||
123 600119.SH 长发集团长江投资实业股份有限公司 上海市浦东新区中国(上海)自由贸易试验区世纪大道1500号 86-21-66601817,86-21-66601819 cjtzdb@cjtz.cn;shiqinyu@cjtz.cn www.cjtz.cn
|
||||
124 600150.SH 中国船舶工业股份有限公司 上海市浦东新区自由贸易试验区浦东大道1号 86-21-68860618 stock@csscholdings.com;zhangdb@csscholdings.com csscholdings.cssc.net.cn
|
||||
125 600151.SH 上海航天汽车机电股份有限公司 上海市中国(上海)自由贸易试验区浦东新区榕桥路661号 86-21-64827176 saae@ht-saae.com www.ht-saae.com
|
||||
126 600170.SH 上海建工集团股份有限公司 上海市浦东新区中国(上海)自由贸易试验区福山路33号 86-21-35100838,86-21-35318170 sc@china-scg.cn;ir@scg.com.cn www.scg.com.cn
|
||||
127 600171.SH 上海贝岭股份有限公司 上海市徐汇区漕河泾开发区宜山路810号 86-21-24261157 bloffice@belling.com.cn www.belling.com.cn
|
||||
128 600193.SH 上海创兴资源开发股份有限公司 上海市浦东新区康桥路1388号三楼A 86-21-58125999,86-4000960980 cxzy@shprd.cn
|
||||
129 600196.SH 上海复星医药(集团)股份有限公司 上海市普陀区曹杨路510号9楼 86-21-33987870 ir@fosunpharma.com www.fosunpharma.com
|
||||
130 600210.SH 上海紫江企业集团股份有限公司 上海市闵行区莘庄工业区申富路618号 86-21-62377118 zijiangqy@zijiangqy.com;zjqy@zijiangqy.com www.zijiangqy.com
|
||||
131 600272.SH 上海开开实业股份有限公司 上海市静安区新闸路921号201室K02 86-21-62712002 dm@chinesekk.com www.chinesekk.com
|
||||
132 600278.SH 东方国际创业股份有限公司 上海市浦东新区自由贸易试验区张杨路707号2221室 86-21-62789999,86-21-52291197,86-21-52291198,86-21-62785521 oiehq@oie.com.cn www.oie.com.cn
|
||||
133 600284.SH 上海浦东建设股份有限公司 上海市浦东新区自由贸易试验区川桥路701弄3号7楼 86-21-58206677 pdjs600284@pdjs.com.cn;dongmi@pdjs.com.cn;zhengdai@pdjs.com.cn www.pdjs.com.cn
|
||||
134 600315.SH 上海家化联合股份有限公司 上海市虹口区保定路527号 86-21-35907000,86-21-35907666 ir@jahwa.com.cn www.jahwa.com.cn
|
||||
135 600320.SH 上海振华重工(集团)股份有限公司 上海市浦东新区浦东南路3470号 86-21-50390727 IR@ZPMC.COM www.zpmc.com
|
||||
136 600420.SH 上海现代制药股份有限公司 上海市浦东新区建陆路378号 86-21-52372865,86-21-62510990 xd_zhengquanban@sinopharm.com;shyndec@sinopharm.com www.shyndec.com
|
||||
137 600490.SH 鹏欣环球资源股份有限公司 上海市普陀区曹杨路1888弄11号11楼1102室-70 86-21-61679636 600490@pengxinzy.com.cn www.pengxinzy.com.cn
|
||||
138 600500.SH 中化国际(控股)股份有限公司 上海市中国(上海)自由贸易试验区长清北路233号12层 86-21-68373780 600500@sinochem.com www.sinochemintl.com
|
||||
139 600503.SH 华丽家族股份有限公司 上海市奉贤区星火开发区阳明路1号8幢1层105室 86-21-62376199 dmb@deluxe-family.com www.deluxe-family.com
|
||||
140 600508.SH 上海大屯能源股份有限公司 上海市浦东新区中国(上海)自由贸易试验区浦东南路256号 86-21-68864621 sh600508@263.net www.sdtny.com
|
||||
141 600517.SH 国网英大股份有限公司 上海市浦东新区自由贸易试验区国耀路211号C座9层 86-21-51796818 600517@sgcc.com.cn www.gwydgf.com
|
||||
142 600530.SH 上海交大昂立股份有限公司 上海市松江区环城路666号 86-21-54277820,86-21-54277865 stock@mail.onlly.com.cn www.onlly.com.cn
|
||||
143 600597.SH 光明乳业股份有限公司 上海市闵行区吴中路578号 86-21-54584520,86-21-64655801,86-21-64307739 chenzhongjie@brightdairy.com;600597@brightdairy.com www.brightdairy.com
|
||||
144 600601.SH 方正科技集团股份有限公司 上海市南京西路1515号嘉里商务中心9楼 86-21-58400030 IR@founder.com www.foundertech.com
|
||||
145 600602.SH 云赛智联股份有限公司 上海市浦东新区张江高科技园区张衡路200号1号楼2楼 86-21-62980202 webmaster@inesa-it.com;stock@inesa-it.com www.inesa-it.com
|
||||
146 600604.SH 上海市北高新股份有限公司 上海市静安区共和新路3088弄2号1008室 86-21-66528130 zhengquan@shibei.com www.shibeiht.com
|
||||
147 600605.SH 上海汇通能源股份有限公司 上海市浦东新区康桥路1100号 86-21-62560000 securities@huitong-sh.com www.huitongenergy.com
|
||||
148 600606.SH 绿地控股集团股份有限公司 上海市黄浦区打浦路700号 86-21-63600606,86-21-23296400,86-21-23296512 ir@ldjt.com.cn www.ldjt.com.cn
|
||||
149 600608.SH 上海宽频科技股份有限公司 上海市闵行区东川路555号丙楼5110室 86-21-62317066,86-871-64646840 invest@600608.net;yunfeng@600608.net;Zhao_z@600608.net www.600608.net
|
||||
150 600611.SH 大众交通(集团)股份有限公司 上海市徐汇区中山西路1515号大众大厦12楼 86-21-64289122 DZJT@96822.COM;fwj@96822.com WWW.96822.COM
|
||||
151 600612.SH 老凤祥股份有限公司 上海市黄浦区南京西路190号四层,五层 86-21-54480605,86-21-64833388*608 lfx600612@lfxgf.com;legal@lfxgf.com www.laofengxiang.com
|
||||
152 600613.SH 上海神奇制药投资管理股份有限公司 上海市浦东新区上川路995号 86-21-53750009 shanghaiys@126.com www.gzsq.com
|
||||
153 600616.SH 上海金枫酒业股份有限公司 上海市浦东新区张杨路579号(三鑫大厦内) 86-21-58352625,86-21-50812727*908 lily@jinfengwine.com;lqc@jinfengwine.com;jfjy@jinfengwine.com www.jinfengwine.com
|
||||
154 600618.SH 上海氯碱化工股份有限公司 上海市金山区化学工业区神工路200号 86-21-23536618 dshmss@scacc.com;Chenlihua_lj@shhuayi.com;yubin@shhuayi.com www.scacc.com
|
||||
155 600619.SH 上海海立(集团)股份有限公司 上海市中国(上海)自由贸易试验区宁桥路888号 86-21-58547777,86-21-58547618 luomin@highly.cc;yanghh@highly.cc;heartfelt@highly.cc www.highly.cc
|
||||
156 600620.SH 上海市天宸股份有限公司 上海市长宁区延安西路2067号29楼 86-21-62782233 tc@shstc.com;fuyunfei@shstc.com;xuxuyu@shstc.com www.shstc.com
|
||||
157 600621.SH 上海华鑫股份有限公司 上海市徐汇区云锦路277号20层 86-21-54967663,86-21-54967667 huzk@shchinafortune.com;shfc@shchinafortune.com;zhangjt@shchinafortune.com www.shchinafortune.com
|
||||
158 600622.SH 光大嘉宝股份有限公司 上海市嘉定区清河路55号6-7F 86-21-59529711 600622@ebjb.com www.ebjb.com
|
||||
159 600623.SH 上海华谊集团股份有限公司 上海市静安区常德路809号 86-21-23530152 IR@shhuayi.com;jupei@shhuayi.com www.doublecoinholdings.com
|
||||
160 600624.SH 上海复旦复华科技股份有限公司 上海市奉贤区汇丰北路1515弄1号2幢107室 86-21-63872288 forward@forwardgroup.com www.forwardgroup.com
|
||||
161 600626.SH 上海申达股份有限公司 上海市中国(上海)自由贸易试验区世博村路231号2单元3层328室 86-21-62328282 600626@sh-shenda.com www.sh-shenda.com
|
||||
162 600628.SH 上海新世界股份有限公司 上海市黄浦区南京西路2-88号 86-21-63871786 xsjhuyi@163.com;newworld@newworld-china.com;wwt26@163.com www.newworld-china.com
|
||||
163 600629.SH 华东建筑集团股份有限公司 上海市黄浦区汉口路151号 86-21-62464018,86-21-52524567 ir@arcplus.com.cn www.arcplus.com.cn
|
||||
164 600630.SH 上海龙头(集团)股份有限公司 上海市黄浦区制造局路584号8号楼2楼201室 86-21-58128888,86-21-63159108,86-21-34061116 ltdsh@shanghaidragon.com.cn;longtou@shanghaidragon.com.cn www.shanghaidragon.com.cn
|
||||
165 600635.SH 上海大众公用事业(集团)股份有限公司 上海市中国(上海)自由贸易试验区浦东新区商城路518号 86-21-64280679,86-21-64288698 dmbstock@dzug.cn www.dzug.cn
|
||||
166 600636.SH 国新文化控股股份有限公司 上海市闵行区龙吴路4411号 86-10-68313202 bod@crhc.cn www.crhc-culture.com
|
||||
167 600637.SH 东方明珠新媒体股份有限公司 上海市徐汇区宜山路757号 86-21-33396637 dongban@opg.cn www.opg.cn
|
||||
168 600638.SH 上海新黄浦实业集团股份有限公司 上海市黄浦区北京东路668号东楼32层 86-21-63238888 stock@600638.com;600638@600638.com www.600638.com
|
||||
169 600639.SH 上海金桥出口加工区开发股份有限公司 上海市自由贸易试验区新金桥路28号 86-21-50307702 jqir@shpdjq.com www.shpdjq.com
|
||||
170 600640.SH 新国脉数字文化股份有限公司 上海市普陀区江宁路1207号4,18,20-21楼 86-21-62762171 600640@chinatelecom.cn www.new-gm.cn
|
||||
171 600641.SH 上海先导基电科技股份有限公司 中国(上海)自由贸易试验区浦明路1500号12层(名义楼层15层) 86-21-50367718 info@600641.com.cn www.600641.com.cn
|
||||
172 600642.SH 申能股份有限公司 上海市闵行区虹井路159号5楼 86-21-33570888,86-21-63900642 zhengquan@shenergy.com.cn www.shenergy.net.cn
|
||||
173 600643.SH 上海爱建集团股份有限公司 上海市浦东新区泰谷路168号 86-21-64396600 dongmi@aj.com.cn;xinfang@aj.com.cn www.aj.com.cn
|
||||
174 600648.SH 上海外高桥集团股份有限公司 上海市浦东新区杨高北路889号 86-21-51980848,86-21-51980806 gudong@shwgq.cn;gudong@wgq.cn www.china-ftz.com
|
||||
175 600649.SH 上海城投控股股份有限公司 上海市浦东新区北艾路1540号 86-21-66981171,86-21-66981376 ctkg@600649sh.com www.sh600649.com
|
||||
176 600650.SH 上海锦江在线网络服务股份有限公司 上海市中国(上海)自由贸易试验区浦东大道1号 86-21-63218800 shenyun@jinjiangonline.com;IR@jinjiangonline.com www.jinjiangonline.com
|
||||
177 600651.SH 上海飞乐音响股份有限公司 上海市嘉定区嘉新公路1001号第七幢 86-21-61549299 office@facs.com.cn www.facs.com.cn
|
||||
178 600655.SH 上海豫园旅游商城(集团)股份有限公司 上海市黄浦区复兴东路2号1幢1111室 86-21-23029999,86-21-23028571 obd@yuyuantm.com.cn www.yuyuantm.com.cn
|
||||
179 600661.SH 上海新南洋昂立教育科技股份有限公司 上海市徐汇区淮海西路55号11C 86-21-62818544,86-21-62811383 yangxiaoling@onlyedu.com;tzzrx@onlyedu.com;xujingyun@onlyedu.com www.onlyedu.com
|
||||
180 600662.SH 上海外服控股集团股份有限公司 上海市中国(上海)自由贸易试验区张杨路655号707室 86-21-65670587 ir@fsg.com.cn;Jenny.yu@fsg.com.cn;haiyuan.zhu@fsg.com.cn www.fsg.com.cn
|
||||
181 600663.SH 上海陆家嘴金融贸易区开发股份有限公司 上海市中国(上海)自由贸易试验区浦东大道981号 86-21-33848801 invest@ljz.com.cn www.ljz.com.cn
|
||||
182 600675.SH 中华企业股份有限公司 上海市静安区华山路2号 86-21-20772222,86-21-20772700,86-21-20770176 zhqydm@cecl.com.cn;zhqy@cecl.com.cn www.cecl.com.cn
|
||||
183 600676.SH 上海交运集团股份有限公司 上海市浦东新区曹路工业园区民冬路239号 86-21-63172168,86-21-63178257 jygf@sh163.net www.cnsjy.com
|
||||
184 600679.SH 上海凤凰企业(集团)股份有限公司 上海市金山区金山工业区开乐大街158号6号楼 86-21-32795679,86-21-32795689 master@phoenix.com.cn;zpc@phoenix.com.cn;mw@phoenix.com.cn www.phoenix.com.cn
|
||||
185 600688.SH 中国石化上海石油化工股份有限公司 上海市金山区金一路48号 86-21-57943143,86-21-57933728 spc@spc.com.cn;yuguangxian@spc.com.cn;liugang@spc.com.cn;huanglixin@spc.com.cn www.spc.com.cn
|
||||
186 600689.SH 上海三毛企业(集团)股份有限公司 上海市浦东新区自由贸易试验区浦东大道1476号,1482号1401-1415室 86-21-63059496,86-21-63028180 wuxy@600689.com;sanmaogroup@600689.com;zhouzy@600689.com;hegy@600689.com www.600689.com
|
||||
187 600692.SH 上海亚通股份有限公司 上海市崇明区城桥镇八一路1号 86-21-69695918,86-21-69692618 leibnize@126.com;yt69692618@126.com;ytgfwhl@163.com www.shanghaiyatong.com
|
||||
188 600696.SH 上海贵酒股份有限公司 上海市奉贤区南桥镇沪发路65弄1号 86-851-22292688,86-851-22276618 IRM600696@163.com www.sh600696.com
|
||||
189 600708.SH 光明房地产集团股份有限公司 上海市中国(上海)自由贸易试验区临港新片区丽正路1628号9幢2层A-75室 86-21-32211128,86-21-61102888 zhengchao@bre600708.com;tzzrx@bre600708.com;supengcheng@bre600708.com www.bre600708.com
|
||||
190 600732.SH 上海爱旭新能源股份有限公司 上海市浦东新区秋月路26号4幢201-1室 86-579-85912509 IR@aikosolar.com www.aikosolar.com
|
||||
191 600741.SH 华域汽车系统股份有限公司 上海市静安区威海路489号 86-21-23102080 huayuqiche@hasco-group.com www.hasco-group.com
|
||||
192 600748.SH 上海实业发展股份有限公司 上海市中国(上海)自由贸易试验区浦东南路1085号华申大厦六楼 86-21-53858686 sid748@sidlgroup.com www.sidlgroup.com
|
||||
193 600754.SH 上海锦江国际酒店股份有限公司 中国(上海)自由贸易试验区杨高南路889号东锦江大酒店商住楼四层(B区域) 86-21-63217132 JJIR@jinjianghotels.com www.jinjianghotels.sh.cn
|
||||
194 600816.SH 建元信托股份有限公司 上海市杨浦区控江路1553号-1555号A座301室 86-21-63410710 600816@j-yuantrust.com www.j-yuantrust.com
|
||||
195 600818.SH 中路股份有限公司 上海市浦东新区南六公路818号 86-21-52860258 600818@zhonglu.com.cn www.zhonglu.com.cn
|
||||
196 600819.SH 上海耀皮玻璃集团股份有限公司 上海市中国(上海)自由贸易试验区张东路1388号4-5幢 86-21-61633599*2041,86-21-61633599*2237 stock@sypglass.com www.sypglass.com
|
||||
197 600820.SH 上海隧道工程股份有限公司 上海市徐汇区宛平南路1099号 86-21-65419590 stecodd@stec.net;600820@stec.net www.stec.net
|
||||
198 600822.SH 上海物资贸易股份有限公司 上海市黄浦区南苏州路325号7楼 86-21-63231818 600822@shwuzi.com;600822@shwmgf.com www.600822sh.com
|
||||
199 600824.SH 上海益民商业集团股份有限公司 上海市黄浦区淮海中路809号甲七楼 86-21-64339888 yimin@yimingroup.com www.yimingroup.com
|
||||
200 600825.SH 上海新华传媒股份有限公司 上海市闵行区剑川路951号5号楼1层西侧 86-21-60376284 xhcmpublic@xhmedia.com www.xhmedia.com
|
||||
201 600826.SH 东浩兰生会展集团股份有限公司 上海市浦东新区陆家嘴东路161号2602室 86-21-63366287,86-21-63366275,86-21-63366298 stock@dlg-expo.com;zhangrongjian@dlg-expo.com;yujingyun@dlg-expo.com www.dlg-expo.com
|
||||
202 600827.SH 上海百联集团股份有限公司 上海市浦东新区张杨路501号11楼1101室 86-21-63223344,86-21-63229537 blgf600827@bl.com www.bailian.sh.cn
|
||||
203 600833.SH 上海第一医药股份有限公司 上海市黄浦区南京东路616号 86-21-64337282 shcred@online.sh.cn shdyyy.com.cn
|
||||
204 600834.SH 上海申通地铁股份有限公司 上海市中国(上海)自由贸易试验区浦电路489号 86-21-54259953,86-21-54259971 600834@shtmetro.com;sunsihui@shtmetro.com;zhuying@shtmetro.com www.shtmetro.com
|
||||
205 600835.SH 上海机电股份有限公司 上海市浦东新区北张家浜路128号 86-21-68547168 shjddm@chinasec.cn;xhh@chinasec.cn;sec@chinasec.cn www.chinasec.cn
|
||||
206 600838.SH 上海九百股份有限公司 上海市静安区愚园路300号6楼D室 86-21-62569866,86-21-62569829,86-21-62729898*838 shjb838@shjb600838.com www.shjb600838.com
|
||||
207 600841.SH 上海新动力汽车科技股份有限公司 上海市杨浦区军工路2636号 86-21-60652207 snatdsh@snat.com www.snat.com
|
||||
208 600843.SH 上工申贝(集团)股份有限公司 上海市中国(上海)自由贸易试验区世纪大道1500号东方大厦12楼A-D室 86-21-68407515 600843@sgsbgroup.com;wuwj@sgsbgroup.com;shenlj@sgsbgroup.com www.sgsbgroup.com
|
||||
209 600845.SH 上海宝信软件股份有限公司 上海市浦东新区自由贸易试验区郭守敬路515号 86-21-20378893 investor@baosight.com www.baosight.com
|
||||
210 600846.SH 上海同济科技实业股份有限公司 上海市中国(上海)自由贸易试验区栖山路33号 86-21-65985860 tjkjsy@tjkjsy.com.cn www.tjkjsy.com.cn
|
||||
211 600848.SH 上海临港控股股份有限公司 上海市松江区莘砖公路668号3层 86-21-64855827 ir@shlingang.com www.lingangholding.com
|
||||
212 600850.SH 中电科数字技术股份有限公司 上海市嘉定区城北路378号1605室 86-21-33390000,86-21-33390288 ecczb@shecc.com;dm@shecc.com www.shecc.com
|
||||
213 600851.SH 上海海欣集团股份有限公司 上海市松江区洞泾镇长兴路688号 86-21-57698100,86-21-63917000,86-21-57698031 600851@haixin.com;haq@haixin.com;zg@haixin.com www.haixin.com
|
||||
214 600882.SH 上海妙可蓝多食品科技股份有限公司 上海市奉贤区金汇镇工业路899号8幢 86-21-50188700 ir@milkland.com.cn www.milkground.cn
|
||||
215 600895.SH 上海张江高科技园区开发股份有限公司 中国(上海)自由贸易试验区春晓路289号802室 86-21-38959000 investors@600895.com www.600895.com;www.600895.cn
|
||||
216 600958.SH 东方证券股份有限公司 上海市黄浦区中山南路119号东方证券大厦 86-21-63325888,86-21-63326373 wangrf@orientsec.com.cn;litingting@orientsec.com.cn;ir@orientsec.com.cn www.dfzq.com.cn
|
||||
217 601021.SH 春秋航空股份有限公司 上海市长宁区虹桥路2599号 86-21-32315288 ir@ch.com www.ch.com
|
||||
218 601156.SH 东方航空物流股份有限公司 上海市浦东机场机场大道66号 86-21-22365112 EAL-IR@ceair.com www.eal-ceair.com
|
||||
219 601200.SH 上海环境集团股份有限公司 上海市长宁区虹桥路1881号 86-21-63901005,86-21-68907088,86-21-52564780 shhj@shenvir.com www.sh601200.com
|
||||
220 601211.SH 国泰海通证券股份有限公司 上海市浦东新区自由贸易试验区商城路618号 86-21-38676798 dshbgs@gtht.com www.gtht.com
|
||||
221 601229.SH 上海银行股份有限公司 上海市黄浦区中山南路688号 86-21-68475888,86-21-68476988 ir@bosc.cn;webmaster@bosc.cn www.bosc.cn
|
||||
222 601231.SH 环旭电子股份有限公司 上海市浦东新区张江高科技园区集成电路产业区张东路1558号 86-21-58968418 Public@usiglobal.com www.usiglobal.com
|
||||
223 601328.SH 交通银行股份有限公司 上海市浦东新区银城中路188号 86-21-58766688 investor@bankcomm.com www.bankcomm.com;www.bankcomm.cn
|
||||
224 601519.SH 上海大智慧股份有限公司 上海市中国(上海)自由贸易试验区郭守敬路498号浦东软件园14幢22301-130座 86-21-20219988*39117 Ir@gw.com.cn www.gw.com.cn
|
||||
225 601595.SH 上海电影股份有限公司 上海市徐汇区漕溪北路595号 86-21-33391000,86-21-33391188 huayifan@sh-sfc.com;sygf@sh-sfc.com;ir@sh-sfc.com www.sh-sfc.com
|
||||
226 601601.SH 中国太平洋保险(集团)股份有限公司 上海市黄浦区中山南路1号 86-21-58767282 ir@cpic.com.cn www.cpic.com.cn
|
||||
227 601607.SH 上海医药集团股份有限公司 上海市浦东新区张江路92号 86-21-63730908 pharm@sphchina.com;boardoffice@sphchina.com www.sphchina.com
|
||||
228 601611.SH 中国核工业建设股份有限公司 上海市青浦区西虹桥商务区蟠龙路500号 86-21-31858805,86-21-31858801,86-21-31858860 dong_sh@cnecc.com;zhangyunpu@cnecc.com www.cnecc.com
|
||||
229 601616.SH 上海广电电气(集团)股份有限公司 上海市奉贤区环城东路123弄1号4幢三层 86-21-67101661 office@csge.com www.sgeg.cn
|
||||
230 601696.SH 中银国际证券股份有限公司 上海市浦东新区银城中路200号中银大厦39层 86-21-20328000 webmaster@bocichina.com;IR@bocichina.com www.bocichina.com
|
||||
231 601702.SH 上海华峰铝业股份有限公司 上海市金山区月工路1111号 86-21-67276833,86-21-67276853 hfly@huafeng.com www.huafonal.com
|
||||
232 601727.SH 上海电气集团股份有限公司 上海市长宁区华山路1100弄16号 86-21-33261888 service@shanghai-electric.com;ir@shanghai-electric.com www.shanghai-electric.com
|
||||
233 601788.SH 光大证券股份有限公司 上海市静安区新闸路1508号 86-21-22169914 ebs@ebscn.com;independentdirector@ebscn.com www.ebscn.com
|
||||
234 601825.SH 上海农村商业银行股份有限公司 上海市黄浦区中山东二路70号 86-21-61899333,86-21-962999 ir@shrcb.com www.shrcb.com
|
||||
235 601828.SH 红星美凯龙家居集团股份有限公司 上海市浦东新区临御路518号6楼F801室 86-21-52820220 ir@chinaredstar.com www.chinaredstar.com
|
||||
236 601866.SH 中远海运发展股份有限公司 上海市浦东新区自由贸易试验区国贸大厦A-538室 86-21-65967333,86-21-65966105 ir@coscoshipping.com development.coscoshipping.com
|
||||
237 601872.SH 招商局能源运输股份有限公司 上海市浦东新区自由贸易试验区西里路55号9楼912A室 86-852-28597361,86-21-63361872 IR@cmhk.com www.cmenergyshipping.com
|
||||
238 601968.SH 上海宝钢包装股份有限公司 上海市宝山区罗东路1818号 86-21-56766307 ir601968@baosteel.com www.baosteelpackaging.com
|
||||
239 603006.SH 上海联明机械股份有限公司 上海市浦东新区川沙路905号 86-21-58560017 yangmingmin@shanghailm.com;ir@shanghailm.com;duanyinyu@shanghailm.com www.shanghailmjx.com
|
||||
240 603009.SH 上海北特科技集团股份有限公司 上海市嘉定区华亭镇高石路(北新村内)(一照多址企业) 86-21-62190266*666 touzizhe@beite.net.cn www.beite.net.cn
|
||||
241 603012.SH 上海创力集团股份有限公司 上海市青浦区新康路889号 86-21-59869117 shcl@shclkj.com www.shclkj.com
|
||||
242 603020.SH 爱普香料集团股份有限公司 上海市嘉定区曹新公路33号 86-21-66523100 jye@cnaff.com;hanqing.qin@cnaff.com www.cnaff.com
|
||||
243 603022.SH 上海新通联包装股份有限公司 上海市宝山区罗北路1238号 86-21-36535008 zqb@xtl.sh.cn www.xtlpacking.com
|
||||
244 603030.SH 上海全筑控股集团股份有限公司 上海市青浦区朱家角镇沪青平公路6335号7幢461 86-21-33372630 ir@trendzone.com.cn;Sunhaijun@trendzone.com www.trendzone.com.cn
|
||||
245 603037.SH 上海凯众材料科技股份有限公司 上海市浦东新区建业路813号 86-21-58388958 kaizhongdm@carthane.com www.carthane.com
|
||||
246 603039.SH 泛微网络科技股份有限公司 上海市奉贤区环城西路3006号 86-21-68869298*6109,86-21-68869298*2032 weaver@weaver.com.cn;jolin.zhou@weaver.com.cn;Haohan.gu@weaver.com.cn www.weaver.com.cn
|
||||
247 603056.SH 德邦物流股份有限公司 上海市青浦区徐泾镇徐祥路316号1幢 86-21-39288106 ir@deppon.com www.deppon.com
|
||||
248 603057.SH 紫燕食品集团股份有限公司 上海市闵行区申南路215号 86-21-52969658 ziyan@ziyanfoods.com www.ziyanfoods.com
|
||||
249 603068.SH 博通集成电路(上海)股份有限公司 上海市中国(上海)自由贸易试验区张东路1387号41幢101(复式)室2F-3F/102(复式)室 86-21-51086811*8899 IR@bekencorp.com www.bekencorp.com
|
||||
250 603083.SH 上海剑桥科技股份有限公司 上海市闵行区陈行公路2388号8幢501室 86-21-60904272 investor@cigtech.com www.cigtech.com
|
||||
251 603108.SH 上海润达医疗科技股份有限公司 上海市金山区卫昌路1018号1号楼201室 86-21-68406213 board@rundamedical.com www.rundamedical.com
|
||||
252 603121.SH 上海华培数能科技(集团)股份有限公司 上海市青浦区崧秀路218号3幢厂房 86-21-31838505 board@sinotec.cn www.sinotec.cn
|
||||
253 603122.SH 合富(中国)医疗科技股份有限公司 中国(上海)自由贸易试验区新灵路118号606B室 86-21-60378999,86-21-60378999*8668 ir_cowealth@cowealth.com ch.cowealth.com
|
||||
254 603128.SH 港中旅华贸国际物流股份有限公司 上海市黄浦区南京西路338号20楼 86-21-63588811 ird@ctsfreight.com www.ctsfreight.com
|
||||
255 603131.SH 上海沪工焊接集团股份有限公司 上海市青浦区外青松公路7177号 86-21-59715700 hggf@hugong.com www.hugong.com
|
||||
256 603153.SH 上海建科咨询集团股份有限公司 上海市徐汇区宛平南路75号 86-21-64390809,86-21-31655960 ir@sribs.com www.sribs.com
|
||||
257 603159.SH 上海亚虹模具股份有限公司 上海市奉贤区沪杭公路732号 86-21-37596575 yahong@xxyhmj.com.cn;sumy@xxyhmj.com.cn;baohan@xxyhmj.com.cn www.yahong-mold.com
|
||||
258 603170.SH 上海宝立食品科技股份有限公司 上海市松江茸北工业区茸兴路433号 86-21-31823950 bolex_office@bolexfoods.com www.bolexfoods.com
|
||||
259 603189.SH 上海网达软件股份有限公司 上海市浦东新区自由贸易试验区川桥路409号 86-21-50306629 sunlin@wondertek.com.cn;xuwen@wondertek.com.cn www.wondertek.com.cn
|
||||
260 603192.SH 上海汇得科技股份有限公司 上海市金山区金山卫镇春华路180号 86-21-37285599,86-21-37285501 hdkj@shhdsz.com www.shhdsz.com
|
||||
261 603196.SH 日播时尚集团股份有限公司 上海市松江区中山街道茸阳路98号1幢2层 86-21-80104103 ir@ribo.com.cn www.ribo-group.com
|
||||
262 603197.SH 上海保隆汽车科技股份有限公司 上海市松江区沈砖公路5500号 86-21-31273333 sbac@baolong.biz;wenjianfeng@chinabaolong.net;zhanghongmei@chinabaolong.net www.baolong.biz
|
||||
263 603200.SH 上海洗霸科技股份有限公司 上海市嘉定区博学路138号6幢,7幢 86-21-55384785,86-21-65424668 shech@china-xiba.com;lchen@china-xiba.com www.china-xiba.com
|
||||
264 603211.SH 晋拓科技股份有限公司 上海市松江区新浜工业园区胡甪路368号 86-21-57898686 webmaster@jintuo.com.cn www.sh-jintuo.com
|
||||
265 603214.SH 上海爱婴室商务服务股份有限公司 上海市浦东新区浦东大道2123号3E-1157室 86-21-68470177 investor.list@aiyingshi.com www.aiyingshi.com
|
||||
266 603226.SH 菲林格尔家居科技股份有限公司 上海市奉贤区林海公路7001号 86-21-67192899 zqswb@vohringer.com www.vohringer.com
|
||||
267 603232.SH 格尔软件股份有限公司 上海市静安区江场西路299弄5号601室 86-21-62327028 stock@koal.com www.koal.com
|
||||
268 603236.SH 上海移远通信技术股份有限公司 上海市松江区泗泾镇高技路205弄6号5层513室 86-21-51086236*6778 yiyuan@quectel.com www.quectel.com
|
||||
269 603256.SH 宏和电子材料科技股份有限公司 上海市浦东康桥工业区秀沿路123号 86-21-38299688*6666 Honghe_news@gracefabric.com www.gracefabric.com
|
||||
270 603324.SH 上海盛剑科技股份有限公司 上海市嘉定区汇发路301号 86-21-60712858 ir@sheng-jian.com www.sheng-jian.com
|
||||
271 603329.SH 上海雅仕投资发展股份有限公司 上海市浦东新区自由贸易试验区浦东南路855号33H室 86-21-68596223 info@ace-sulfert.com www.aceonline.cn
|
||||
272 603330.SH 天洋新材(上海)科技股份有限公司 上海市嘉定区南翔镇惠平路505号 86-21-69122665 wenliang.geng@hotmelt.com.cn;zhijun.lu@hotmelt.com.cn;IR@hotmelt.com.cn www.hotmelt.com.cn
|
||||
273 603365.SH 上海水星家用纺织品股份有限公司 上海市奉贤区沪杭公路1487号 86-21-57435982 sxjf@shuixing.com www.shuixing.com
|
||||
274 603378.SH 亚士创能科技(上海)股份有限公司 上海市青浦区工业园区新涛路28号综合楼三层,四层 86-21-59705888,86-21-59705888*8393 dmb@cuanon.com www.cuanon.com
|
||||
275 603466.SH 上海风语筑文化科技股份有限公司 上海市静安区江场三路191号 86-21-56206468 licheng@fengyuzhu.com;ir@fengyuzhu.com;linshijing@fengyuzhu.com www.fengyuzhu.com
|
||||
276 603496.SH 恒为科技(上海)股份有限公司 上海市徐汇区乐山路33号103室 86-21-61002983 securities.affairs@embedway.com www.embedway.com
|
||||
277 603499.SH 上海翔港包装科技股份有限公司 上海市浦东新区泥城镇翠波路299号 86-21-58075851 tangjun@sunglow-tec.com;xg@sunglow-tec.com;lidanqing@sunglow-tec.com www.sunglow-tec.com
|
||||
278 603501.SH 豪威集成电路(集团)股份有限公司 上海市浦东新区自由贸易试验区龙东大道3000号1幢C楼7层 86-21-50805043 will_stock@corp.ovt.com www.omnivision-group.com
|
||||
279 603515.SH 欧普照明股份有限公司 上海市浦东新区龙东大道6111号1幢411室 86-21-38550000*6720 Public@opple.com www.opple.com.cn
|
||||
280 603565.SH 上海中谷物流股份有限公司 上海市浦东新区双惠路99号综合楼106室 86-21-31761722 ir@zhonggu56.com;daixin@zhonggu56.com;liqiqi@zhonggu56.com www.zhonggu56.com
|
||||
281 603579.SH 上海荣泰健康科技股份有限公司 上海市青浦区朱枫公路1226号 86-21-59833669 Public@rotai.com www.rotai.com
|
||||
282 603580.SH 艾艾精密工业输送系统(上海)股份有限公司 上海市静安区万荣路700号7幢A240室 86-21-65305237 zhengquanbu@aabelt.com.cn www.aabelt.com.cn
|
||||
283 603587.SH 地素时尚股份有限公司 上海市长宁区仙霞路579弄38号第2幢103室 86-21-31085111,86-21-31085300 info@dazzle-fashion.com www.dazzle-fashion.com
|
||||
284 603619.SH 中曼石油天然气集团股份有限公司 上海市中国(上海)自由贸易试验区临港新片区南汇新城镇飞渡路2099号1幢1层 86-21-61048060 ssbgs@zpec.com;office@zpec.com www.zpec.com
|
||||
285 603633.SH 上海徕木电子股份有限公司 上海市闵行区中春路7319号 86-21-67679072 ir@laimu.com.cn www.laimu.com.cn
|
||||
286 603648.SH 上海畅联国际物流股份有限公司 上海市中国(上海)自由贸易试验区冰克路500号5-6幢 86-21-20895888 investor-relations@chinaslc.com www.chinaslc.com
|
||||
287 603650.SH 彤程新材料集团股份有限公司 上海市浦东新区自由贸易试验区银城中路501号上海中心25层2501室 86-21-62109966 securities@rachem.com www.rachem.com
|
||||
288 603659.SH 上海璞泰来新能源科技集团股份有限公司 上海市浦东新区自由贸易试验区芳春路400号1幢301-96室 86-21-61902930 IR@putailai.com www.putailai.com
|
||||
289 603681.SH 上海永冠众诚新材料科技(集团)股份有限公司 上海市青浦区朱家角工业园区康工路15号 86-21-59830677 ir@ygtape.com;sq@ygtape.com www.ygtape.com
|
||||
290 603682.SH 上海锦和商业经营管理(集团)股份有限公司 上海市徐汇区虹漕路68号43幢18楼 86-21-52399283 dongban@jinhe.sh.cn www.iyuejie.com
|
||||
291 603683.SH 上海晶华胶粘新材料股份有限公司 上海市松江区永丰街道大江路89号 86-21-31167522,86-512-80179506 xiaochan.pan@smithcn.com;zijie.chen@smithcn.com;jhxc@smithcn.com www.smithcn.com
|
||||
292 603690.SH 上海至纯洁净系统科技股份有限公司 上海市闵行区紫海路170号 86-21-80238290 dong_ban@pncs.cn www.pncs.cn
|
||||
293 603713.SH 密尔克卫智能供应链服务集团股份有限公司 上海市浦东新区中国(上海)自由贸易试验区金葵路158号4-11层 86-21-80228498 ir@mwclg.com www.mwclg.com
|
||||
294 603718.SH 上海海利生物技术股份有限公司 上海市奉贤区自由贸易试验区临港新片区正博路1881号19幢一层1002室 86-21-60890892,86-21-60890888 ir@hile-bio.com;china@hile-bio.com www.hile-bio.com
|
||||
295 603728.SH 上海鸣志电器股份有限公司 上海市闵行区闵北路88弄1-30号109幢1层7101室 86-21-52634688 dm@moons.com.cn www.moons.com.cn
|
||||
296 603729.SH 上海龙韵文创科技集团股份有限公司 上海市松江区佘山三角街9号 86-21-58823977 longyuntzz@obm.com.cn www.obm.com.cn
|
||||
297 603730.SH 上海岱美汽车内饰件股份有限公司 上海市浦东新区北蔡镇莲溪路1299号 86-21-68945881 IR@daimay.com www.daimay.com
|
||||
298 603777.SH 上海来伊份股份有限公司 上海市松江区九亭镇久富路300号 86-21-51760952 corporate@laiyifen.com;linyun1@laiyifen.com www.laiyifen.com
|
||||
299 603786.SH 科博达技术股份有限公司 上海市浦东新区自由贸易试验区祖冲之路2388号1-2幢 86-21-60978935 keboda@keboda.com www.keboda.com
|
||||
300 603790.SH 上海雅运纺织化工股份有限公司 上海市徐汇区银都路388号16幢275-278室 86-21-69136448 ir@argus.net.cn www.argus.net.cn
|
||||
301 603855.SH 华荣科技股份有限公司 上海市嘉定区宝钱公路555号 86-21-59999999 warom@warom.com;zxr@warom.com;hrzhd@warom.com www.warom.com
|
||||
302 603868.SH 上海飞科电器股份有限公司 上海市松江区广富林东路555号 86-21-52858888*839 flyco@flyco.com www.flyco.com
|
||||
303 603881.SH 上海数据港股份有限公司 上海市静安区江场路1401弄14号1601室 86-21-31762186 ir@athub.com www.athub.com
|
||||
304 603885.SH 上海吉祥航空股份有限公司 上海市浦东新区自由贸易试验区康桥东路8号 86-21-61988832 ir@juneyaoair.com www.juneyaoair.com
|
||||
305 603886.SH 上海元祖梦果子股份有限公司 上海市青浦区赵巷镇嘉松中路6088号 86-21-59755678*6800 gansoinfo@ganso.net www.ganso.com.cn
|
||||
306 603887.SH 上海城地香江数据科技股份有限公司 上海市嘉定区恒永路518弄1号B区502-1 86-21-52806755 shchengdi@163.com www.shcd.cc
|
||||
307 603895.SH 上海天永智能装备股份有限公司 上海市嘉定区外冈镇汇宝路555号3幢2层A区 86-21-50675528,86-21-69920928 lvaihua@ty-industries.com;943731796@qq.com www.ty-industries.com
|
||||
308 603899.SH 上海晨光文具股份有限公司 上海市奉贤区金钱公路3469号3号楼 86-21-57475621 ir@mg-pen.com www.mg-pen.com
|
||||
309 603918.SH 上海金桥信息股份有限公司 上海市浦东新区郭守敬路498号12幢21302;21319室 86-21-33674997,86-21-33674396 shaole@shgbit.com;gaodd@shgbit.com;yaoming@shgbit.com;shgbit@shgbit.com www.shgbit.com
|
||||
310 603956.SH 上海威派格智慧水务股份有限公司 上海市嘉定区恒定路1号 86-21-69080885 zqswb@shwpg.com www.shwpg.com
|
||||
311 603960.SH 上海克来机电自动化工程股份有限公司 上海市宝山区罗东路1555号4幢 86-21-33850028 kelai.jidian@sh-kelai.com www.sh-kelai.com
|
||||
312 603987.SH 上海康德莱企业发展集团股份有限公司 上海市嘉定区高潮路658号1幢2楼 86-21-69113502 kdl@kdlchina.net;dm@kdlchina.net;oult@kdlchina.com www.kdlchina.cn
|
||||
313 605050.SH 福然德股份有限公司 上海市宝山区潘泾路3759号(宝山工业园区) 86-21-66898585,86-21-66898558 shcq@scmfriend.com;zqb@scmfriend.com www.scmfriend.com
|
||||
314 605081.SH 上海太和水科技发展股份有限公司 上海市金山区枫泾镇曹黎路38弄19号1957室 86-21-65661627 dongmiban@shtaihe.net www.shtaihe.net
|
||||
315 605098.SH 上海行动教育科技股份有限公司 上海市闵行区兴虹路168弄3号201室B区 86-21-33535658 lindayang@xdjy100.com;dongban@xdjy100.com www.xdjy100.com
|
||||
316 605128.SH 上海沿浦精工科技(集团)股份有限公司 上海市闵行区浦江镇江凯路128号 86-21-64918973*8101 ypgf@shyanpu.com www.shyanpu.com
|
||||
317 605136.SH 上海丽人丽妆化妆品股份有限公司 上海市松江区乐都西路825弄89,90号6层618室 86-21-64663911 shlrlz@lrlz.com www.lrlz.com
|
||||
318 605151.SH 西上海汽车服务股份有限公司 上海市嘉定区恒裕路517号 86-21-59573618 servicesh@wsasc.com.cn www.wsasc.com.cn
|
||||
319 605186.SH 上海健麾信息技术股份有限公司 上海市松江区中辰路299号1幢104室 86-21-58380355 stock@g-healthy.com www.g-healthy.com
|
||||
320 605208.SH 上海永茂泰汽车科技股份有限公司 上海市青浦区练塘镇章练塘路577号 86-21-59815266 ymtauto@ymtauto.com www.ymtauto.com
|
||||
321 605222.SH 上海起帆电缆股份有限公司 上海市金山区张堰镇振康路238号 86-21-37217999 qifancable@188.com www.qifancable.com
|
||||
322 605289.SH 上海罗曼科技股份有限公司 上海市杨浦区杨树浦路1196号5层 86-21-65031217,86-21-65031217*208,86-21-65031217*222 IRmanager@luoman.com.cn;Zhangzhengyu@luoman.com.cn;shlm@shluoman.cn www.shluoman.cn;www.luoman.com.cn
|
||||
323 605338.SH 中饮巴比食品股份有限公司 上海市松江区车墩镇茸江路785号 86-21-57797068 ir@babifood.com www.babifood.com
|
||||
324 605339.SH 南侨食品集团(上海)股份有限公司 上海市徐汇区宜山路1397号A栋12层 86-21-61955678 ncfgs@ncbakery.com www.ncbakery.com
|
||||
325 605398.SH 上海新炬网络信息技术股份有限公司 上海市青浦区外青松公路7548弄588号1幢1层R区113室 86-21-52908588 IR@shsnc.com www.shsnc.com
|
||||
326 605598.SH 上海港湾基础建设(集团)股份有限公司 上海市金山区漕泾镇亭卫公路3316号1幢二层207室 86-21-65638550 ir@geoharbour.com www.geoharbour.com
|
||||
327 688008.SH 澜起科技股份有限公司 上海市徐汇区漕宝路181号1幢15层 86-21-54679039 ir@montage-tech.com www.montage-tech.com/cn
|
||||
328 688012.SH 中微半导体设备(上海)股份有限公司 上海市浦东新区金桥出口加工区(南区)泰华路188号 86-21-61001199 IR@amecnsh.com www.amec-inc.com
|
||||
329 688016.SH 上海微创心脉医疗科技(集团)股份有限公司 上海市浦东新区康新公路3399弄1号 86-21-38139300 irm@endovastec.com www.endovastec.com
|
||||
330 688018.SH 乐鑫信息科技(上海)股份有限公司 上海市浦东新区御北路235弄3号楼1-7层 86-21-61065218 ir@espressif.com www.espressif.com
|
||||
331 688019.SH 安集微电子科技(上海)股份有限公司 上海市浦东新区华东路5001号金桥出口加工区(南区)T6-9幢底层 86-21-20693346 IR@anjimicro.com www.anjimicro.com
|
||||
332 688031.SH 星环信息科技(上海)股份有限公司 上海市徐汇区虹漕路88号3楼,B栋11楼 86-21-61761338 ir@transwarp.io www.transwarp.cn
|
||||
333 688061.SH 上海灿瑞科技股份有限公司 上海市静安区延长路149号科技楼308室 86-21-56387201,86-21-36399007 ocsir@orient-chip.com www.orient-chip.com
|
||||
334 688062.SH 迈威(上海)生物科技股份有限公司 上海市浦东新区自由贸易试验区蔡伦路230号2幢105室 86-21-58332260 ir@mabwell.com www.mabwell.com
|
||||
335 688063.SH 上海派能能源科技股份有限公司 上海市浦东新区康桥镇苗桥路300号 86-21-31590029 ir@pylontech.com.cn www.pylontech.com.cn
|
||||
336 688065.SH 上海凯赛生物技术股份有限公司 上海市浦东新区蔡伦路1690号5幢4楼 86-21-50801916 cathaybiotech_info@cathaybiotech.com www.cathaybiotech.com
|
||||
337 688071.SH 上海华依科技集团股份有限公司 上海市浦东新区张东路1388号13幢101室 86-21-61051366 investor@w-ibeda.com www.w-ibeda.com
|
||||
338 688073.SH 上海毕得医药科技股份有限公司 上海市杨浦区翔殷路128号11号楼A座101室 86-21-61601560 ir@bidepharmatech.com www.bidepharmatech.com
|
||||
339 688082.SH 盛美半导体设备(上海)股份有限公司 上海市浦东新区自由贸易试验区丹桂路999弄5,6,7,8号全幢 86-21-50276506 ir@acmrcsh.com www.acmrcsh.com.cn
|
||||
340 688085.SH 上海三友医疗器械股份有限公司 上海市嘉定区嘉定工业区汇荣路385号 86-21-58266088 ir@sanyou-medical.com www.sanyoumed.com
|
||||
341 688091.SH 上海谊众药业股份有限公司 上海市奉贤区仁齐路79号 86-21-37190005 info@yizhongpharma.com www.yizhongpharma.com
|
||||
342 688098.SH 申联生物医药(上海)股份有限公司 上海市闵行区江川东路48号 86-21-61255101 slsw@slbio.com.cn www.slbio.com.cn
|
||||
343 688099.SH 晶晨半导体(上海)股份有限公司 上海市浦东新区自由贸易试验区春晓路350号南楼406室 86-21-38165066 IR@amlogic.com www.amlogic.com;www.Amlogic.cn
|
||||
344 688107.SH 上海安路信息科技股份有限公司 上海市虹口区纪念路500号5幢202室 86-21-61633787 public@anlogic.com www.anlogic.com
|
||||
345 688110.SH 东芯半导体股份有限公司 上海市青浦区赵巷镇沪青平公路2875号3幢13层B区1336室 86-21-61369022 contact@dosilicon.com www.dosilicon.com
|
||||
346 688118.SH 普元信息技术股份有限公司 上海市浦东新区自由贸易试验区学林路36弄26号 86-21-58331900 info@primeton.com www.primeton.com
|
||||
347 688121.SH 上海卓然工程技术股份有限公司 上海市闵行区闵北路88弄1-30号104幢4楼D座 86-21-68815818 supezet@supezet.com www.supezet.com
|
||||
348 688123.SH 聚辰半导体股份有限公司 上海市浦东新区张东路1761号10幢 86-21-50802035 investors@giantec-semi.com www.giantec-semi.com
|
||||
349 688126.SH 上海硅产业集团股份有限公司 上海市嘉定区兴邦路755号3幢 86-21-52589038 pr@sh-nsig.com www.nsig.com
|
||||
350 688129.SH 东来涂料技术(上海)股份有限公司 上海市嘉定区工业区新和路1221号 86-21-39538548 IR@onwings.com.cn www.onwings.com.cn
|
||||
351 688131.SH 上海皓元医药股份有限公司 上海市浦东新区中国(上海)自由贸易试验区蔡伦路720弄2号501室 86-21-58338205 hy@chemexpress.com.cn www.chemexpress.com.cn
|
||||
352 688133.SH 上海泰坦科技股份有限公司 上海市徐汇区钦州路100号一号楼1110室 86-21-60878330 contact@titansci.com www.titansci.com
|
||||
353 688155.SH 上海先惠自动化技术股份有限公司 上海市松江区小昆山镇思贤路4800号 86-21-57858808 info@sk1.net.cn www.sk1.net.cn
|
||||
354 688158.SH 优刻得科技股份有限公司 上海市杨浦区隆昌路619号10#B号楼201室 86-21-55509888*8188 ir@ucloud.cn www.ucloud.cn
|
||||
355 688160.SH 上海步科自动化股份有限公司 上海市浦东新区自由贸易试验区申江路5709号,秋月路26号3幢北侧三楼 86-755-86336477 sec@kinco.cn www.kinco.cn
|
||||
356 688163.SH 上海赛伦生物技术股份有限公司 上海市青浦区华青路1288号 86-21-64959122 dmb@serum-china.com www.serum-china.com.cn
|
||||
357 688179.SH 上海阿拉丁生化科技股份有限公司 上海市奉贤区楚华支路809号 86-21-50560989 aladdindmb@163.com www.aladdin-e.com
|
||||
358 688180.SH 上海君实生物医药科技股份有限公司 上海市浦东新区中国(上海)自由贸易试验区蔡伦路987号4层 86-21-61058800*1153 info@junshipharma.com www.junshipharma.com
|
||||
359 688188.SH 上海柏楚电子科技股份有限公司 上海市闵行区兰香湖南路1000号 86-21-64306968 bochu@fscut.com;bochu@bochu.com www.fscut.com,www.bochu.com
|
||||
360 688193.SH 上海仁度生物科技股份有限公司 上海市浦东新区张江高科技园区东区瑞庆路528号15幢乙号 86-21-50720069 ir@rdbio.com www.rdbio.com
|
||||
361 688202.SH 上海美迪西生物医药股份有限公司 上海市浦东新区自由贸易试验区李冰路67弄5号楼 86-21-58591500 IR@medicilon.com.cn www.medicilon.com.cn
|
||||
362 688206.SH 上海概伦电子股份有限公司 上海市浦东新区自由贸易试验区临港新片区环湖西二路888号C楼 86-21-61640095 IR@primarius-tech.com www.primarius-tech.com
|
||||
363 688212.SH 上海澳华内镜股份有限公司 上海市闵行区光中路133弄66号 86-21-54303731 ir@aohua.com www.aohua.com
|
||||
364 688213.SH 思特威(上海)电子科技股份有限公司 上海市浦东新区中国(上海)自由贸易试验区祥科路111号3号楼6楼612室 86-21-64853572 ir@smartsenstech.com www.smartsenstech.com
|
||||
365 688217.SH 上海睿昂基因科技股份有限公司 上海市奉贤区金海公路6055号3幢 86-21-33282601 zqswb@rightongene.com www.rightongene.com
|
||||
366 688220.SH 翱捷科技股份有限公司 上海市浦东新区自由贸易试验区科苑路399号10幢8层(名义楼层9层) 86-21-60336588*1188 ir@asrmicro.com www.asrmicro.com
|
||||
367 688230.SH 上海芯导电子科技股份有限公司 上海市浦东新区自由贸易试验区祖冲之路2277弄7号 86-21-60753051 investor@prisemi.com www.prisemi.com
|
||||
368 688238.SH 和元生物技术(上海)股份有限公司 上海市浦东新区国际医学园区紫萍路908弄19号楼 86-21-58180909 zhengquanbu@obiosh.com www.obiosh.com
|
||||
369 688247.SH 上海宣泰医药科技股份有限公司 上海市浦东新区中国(上海)自由贸易试验区蔡伦路780号7层709室 86-21-68819009*657 info@sinotph.com www.sinotph.com
|
||||
370 688265.SH 上海南方模式生物科技股份有限公司 上海市浦东新区琥珀路63弄1号6层 86-21-58120591 ir@modelorg.com www.modelorg.com
|
||||
371 688271.SH 上海联影医疗科技股份有限公司 上海市嘉定区城北路2258号 86-21-67076658 IR@united-imaging.com www.united-imaging.com
|
||||
372 688293.SH 上海奥浦迈生物科技股份有限公司 上海市浦东新区紫萍路908弄28号 86-21-20780178 ir@opmbiosciences.com;IR@opmbiosciences.com www.opmbiosciences.com
|
||||
373 688301.SH 奕瑞电子科技集团股份有限公司 上海市浦东新区环桥路999号 86-21-50720560,86-21-50720560*8311 ir@iraygroup.com www.iraygroup.com
|
||||
374 688317.SH 上海之江生物科技股份有限公司 上海市浦东新区张江高科技产业东区瑞庆路528号20幢乙号1层,21幢甲号1层 86-21-34635507 info@liferiver.com.cn www.liferiver.com.cn
|
||||
375 688330.SH 上海宏力达信息技术股份有限公司 上海市松江区九亭中心路1158号11幢101,401室(一照多址企业) 86-21-64180758 hld.mail@holystar.com.cn www.holystar.com.cn
|
||||
376 688335.SH 上海复洁科技股份有限公司 上海市杨浦区国权北路1688弄A7幢801室 86-21-55081682 ir@ceo.sh.cn www.ceo.sh.cn
|
||||
377 688336.SH 三生国健药业(上海)股份有限公司 上海市浦东新区中国(上海)自由贸易试验区李冰路399号 86-21-80297676 ir@3s-guojian.com www.3s-guojian.com
|
||||
378 688351.SH 上海微创电生理医疗科技股份有限公司 上海市浦东新区周浦镇天雄路588弄1-28号第28幢 86-21-60969600*53608 investors@everpace.com www.everpace.com
|
||||
379 688366.SH 上海昊海生物科技股份有限公司 上海市松江区工业区洞泾路5号 86-21-52293555 info@3healthcare.com www.3healthcare.com
|
||||
380 688368.SH 上海晶丰明源半导体股份有限公司 上海市浦东新区中国(上海)自由贸易试验区申江路5005弄3号9-11层,2号102单元 86-21-50278297 IR@bpsemi.com www.bpsemi.com
|
||||
381 688370.SH 上海丛麟环保科技股份有限公司 上海市闵行区闵虹路166弄3号2808室 86-21-60713846 ir@cn-conglin.com www.cn-conglin.com
|
||||
382 688372.SH 上海伟测半导体科技股份有限公司 上海市浦东新区东胜路38号A区2栋2F 86-21-58958216 ir@v-test.com.cn www.v-test.com.cn
|
||||
383 688373.SH 上海盟科药业股份有限公司 上海市浦东新区中国(上海)自由贸易试验区爱迪生路53号1幢1-4层101,2幢 86-21-50900550,86-21-50900503 info@micurxchina.com;688373@micurxchina.com micurxchina.com
|
||||
384 688382.SH 益方生物科技(上海)股份有限公司 上海市浦东新区自由贸易试验区李冰路67弄4号210室 86-21-50778527 ir@inventisbio.com www.inventisbio.com
|
||||
385 688385.SH 上海复旦微电子集团股份有限公司 上海市杨浦区邯郸路220号 86-21-65659109 IR@fmsh.com.cn www.fmsh.com
|
||||
386 688391.SH 钜泉光电科技(上海)股份有限公司 上海市浦东新区中国(上海)自由贸易试验区张东路1388号16幢101室 86-21-51035886,86-21-50277832 shareholders@hitrendtech.com www.hitrendtech.com
|
||||
387 688392.SH 上海骄成超声波技术股份有限公司 上海市闵行区沧源路1488号2幢三层 86-21-34668757 ir@sbt-sh.com www.sbt-sh.com
|
||||
388 688435.SH 上海英方软件股份有限公司 上海市黄浦区制造局路787号二幢151A室 86-21-61735888 investor@info2soft.com www.info2soft.com;www.info2soft.cn
|
||||
389 688505.SH 上海复旦张江生物医药股份有限公司 上海市浦东新区张江高科技园区蔡伦路308号 86-21-58953355,86-21-58553583 ir@fd-zj.com;fd-zj@fd-zj.com www.fd-zj.com
|
||||
390 688519.SH 南亚新材料科技股份有限公司 上海市嘉定区南翔镇昌翔路158号 86-21-69178431 nanya@ccl-china.com www.ccl-china.com
|
||||
391 688521.SH 芯原微电子(上海)股份有限公司 上海市浦东新区自由贸易试验区春晓路289号张江大厦20A 86-21-68608521 IR@verisilicon.com www.verisilicon.com
|
||||
392 688538.SH 上海和辉光电股份有限公司 上海市金山区九工路1568号 86-21-60892866 ir@everdisplay.com www.everdisplay.com
|
||||
393 688578.SH 上海艾力斯医药科技股份有限公司 上海市浦东新区周浦镇凌霄花路268号 86-21-80423292 IR@allist.com.cn www.allist.com.cn
|
||||
394 688585.SH 上纬新材料科技股份有限公司 上海市松江区松胜路618号 86-21-57746183 ir@swancor.com.cn www.swancor.com.cn
|
||||
395 688590.SH 上海新致软件股份有限公司 上海市浦东新区康杉路308号 86-21-51105633 investor@newtouch.com www.newtouch.com
|
||||
396 688596.SH 上海正帆科技股份有限公司 上海市闵行区春永路55号2幢 86-21-54428800 ir@gentech-online.com www.gentechindustries.com
|
||||
397 688608.SH 恒玄科技(上海)股份有限公司 上海市浦东新区中国(上海)自由贸易试验区临港新片区环湖西二路800号904室 86-21-68771788*6666 ir@bestechnic.com www.bestechnic.com
|
||||
398 688660.SH 上海电气风电集团股份有限公司 上海市闵行区东川路555号己号楼8楼 86-21-54961895 sewc_ir@shanghai-electric.com www.sewpg.com
|
||||
399 688680.SH 上海海优威新材料股份有限公司 上海市中国(上海)自由贸易试验区龙东大道3000号1幢A楼909 86-21-58964210 hiuv@hiuv.com www.hiuv.com
|
||||
400 688682.SH 上海霍莱沃电子系统技术股份有限公司 上海市浦东新区自由贸易试验区郭守敬路498号15幢1层16102室 86-21-50809715 ir@holly-wave.com www.holly-wave.com
|
||||
401 688718.SH 上海唯赛勃新材料股份有限公司 上海市青浦区崧盈路899号 86-21-69758436 investor@wave-cyber.com www.wave-cyber.com
|
||||
402 688728.SH 格科微有限公司 Harneys Fiduciary (Cayman) Limited, 4th Floor, Harbour Place; 103 South Church Street; P.O. Box 10240; Grand Cayman KY1-1002; George Town; Cayman Islands 86-21-60126212 ir@gcoreinc.com www.gcoreinc.com
|
||||
403 688766.SH 普冉半导体(上海)股份有限公司 中国(上海)自由贸易试验区申江路5005弄1号9层整层(实际楼层8楼) 86-21-60791797 ir@puyasemi.com www.puyasemi.com
|
||||
404 688798.SH 上海艾为电子技术股份有限公司 上海市闵行区秀文路908弄2号1201室 86-21-52968068 securities@awinic.com www.awinic.com
|
||||
405 688981.SH 中芯国际集成电路制造有限公司 Cricket Square, Hutchins Drive, P.O. Box 2681, Grand Cayman, KY1-1111 Cayman Islands 86-21-20812800 ir@smics.com www.smics.com
|
||||
406 900929.SH 上海锦江国际旅游股份有限公司 上海市黄浦区延安东路100号联谊大厦27楼 86-21-32128021,86-21-32128781 wuxh@jjtravel.com;qiansj@jjtravel.com www.jjtravel.com
|
||||
407 900939.SH 上海汇丽建材股份有限公司 上海市浦东新区周浦镇横桥路406号1幢213室 86-21-58138717 stock@huili.com www.huili.com
|
||||
408 920139.BJ 上海华岭集成电路技术股份有限公司 上海市浦东新区张江高科技园区郭守敬路351号2号楼1楼 86-21-50278218 investor@sinoictest.com.cn www.sinoictest.com.cn
|
||||
409 920300.BJ 上海辰光医疗科技股份有限公司 上海市青浦区华青路1269号 86-21-60161688 SHCG@shanghaicg.net www.shanghaicg.net
|
||||
410 920346.BJ 上海威贸电子股份有限公司 上海市青浦区练东路28,38号 86-21-54252988 contact@shwmdz.com;lucas.zhou@shwmdz.com www.shwmdz.com
|
||||
411 920405.BJ 上海海希工业通讯股份有限公司 上海市松江区新桥镇新格路901号1幢3层A区 86-21-54902525 lichunyou@hysea.com.cn;IR@hysea.com.cn www.hysea.com.cn
|
||||
412 920414.BJ 上海欧普泰科技创业股份有限公司 上海市普陀区中江路879弄27号楼208室 86-21-52659337 cathygxh@optjt.cn;investor@opt.jt.cn www.optjt.cn
|
||||
413 920541.BJ 上海铁大电信科技股份有限公司 上海市嘉定区南翔镇蕰北公路1755弄6号 86-21-51235800 956470768@qq.com;tiedadianxin@tiedate.ltd www.tddx.com.cn
|
||||
414 920799.BJ 上海艾融软件股份有限公司 上海市崇明区城桥镇西门路799号306室 86-21-68816718 wang_tao@i2finance.net;public@i2finance.net www.i2finance.net
|
||||
415 920961.BJ 创远信科(上海)技术股份有限公司 上海市松江区泗泾镇高技路205弄7号1层110室,6层,7层,8层,9层 86-21-64326888 ir@transcom.net.cn;info@transcom.net.cn www.transcom.net.cn
|
||||
416 002027.SZ 分众传媒信息技术股份有限公司 上海市长宁区江苏路369号27H 86-21-22165288 FM002027@focusmedia.cn;ln002027@focusmedia.cn www.focusmedia.cn
|
||||
417 920693.BJ 上海阿为特精密机械股份有限公司 上海市宝山区宝安公路917号1幢一楼 86-21-65191708 securities@ahwit.com www.ahwit.com
|
||||
418 920504.BJ 上海博迅医疗生物仪器股份有限公司 上海市松江区泖港镇中强路599号 86-21-66052732 yingyun@boxun.com.cn;boxun@boxun.com.cn www.boxun.com.cn
|
||||
419 920112.BJ 上海巴兰仕汽车检测设备股份有限公司 上海市嘉定区安亭镇新源路58号701室J 86-21-39508868 zhengquanbu@balancer-sh.com www.balancer-sh.com
|
||||
420 688710.SH 上海益诺思生物技术股份有限公司 上海市中国(上海)自由贸易试验区郭守敬路199号106室 86-21-50801259 bo@innostarbio.com www.innostarbio.com
|
||||
421 688691.SH 灿芯半导体(上海)股份有限公司 上海市中国(上海)自由贸易试验区张东路1158号礼德国际2号楼6楼 86-21-50376585,86-21-50376566 IR@britesemi.com www.britesemi.com
|
||||
422 688653.SH 格兰康希通信科技(上海)股份有限公司 上海市中国(上海)自由贸易试验区科苑路399号10幢4层(名义层5层)502室 86-21-50479130 kctzqb@kxcomtech.com www.kxcomtech.com
|
||||
423 688648.SH 中邮科技股份有限公司 上海市普陀区中山北路3185号 86-21-62605607 ir@cpte.com www.cpte.com
|
||||
424 688615.SH 上海合合信息科技股份有限公司 上海市静安区万荣路1256,1258号1105-1123室 86-21-63061283 ir@intsig.net www.intsig.com
|
||||
425 688603.SH 上海天承科技股份有限公司 上海市浦东新区中国(上海)自由贸易试验区张江路665号3F306室 86-21-59766069 public@skychemcn.com;wei.fei@skychemcn.com;zhiqin.su@skychemcn.com www.skychemcn.com
|
||||
426 688602.SH 上海康鹏科技股份有限公司 上海市普陀区祁连山南路2891弄200号1幢 86-21-63638712 ir@chemspec.com.cn www.chemspec.com.cn
|
||||
427 688593.SH 上海新相微电子股份有限公司 上海市徐汇区苍梧路10号3层 86-21-51097181 office@newvisionu.com www.newvisionu.com.cn
|
||||
428 688592.SH 上海司南导航技术股份有限公司 上海市嘉定区马陆镇澄浏中路618号2幢3楼 86-21-39907000,86-21-64302208 IR@sinognss.com www.sinognss.com
|
||||
429 688591.SH 泰凌微电子(上海)股份有限公司 上海市浦东新区中国(上海)自由贸易试验区盛夏路61弄1号电梯楼层10层,11层(实际楼层9层,10层) 86-21-50653177 investors_relation@telink-semi.com www.telink-semi.cn
|
||||
430 688584.SH 上海合晶硅材料股份有限公司 上海市松江区石湖荡镇长塔路558号 86-21-57843535,86-21-57843535*5829 ir@wwxs.waferworks.com www.waferworks.com.cn
|
||||
431 688507.SH 上海索辰信息科技股份有限公司 上海市浦东新区中国(上海)自由贸易试验区新金桥路27号13号楼2层 86-21-50307121 Info@demxs.com www.demxs.com
|
||||
432 688484.SH 上海南芯半导体科技股份有限公司 上海市浦东新区中国(上海)自由贸易试验区盛夏路565弄54号(4幢)1601 86-21-50182236 investors@southchip.com www.southchip.com
|
||||
433 688479.SH 用友汽车信息科技(上海)股份有限公司 上海市普陀区泸定路276弄1号201室 86-21-52353603 zqb@yonyou.com www.yonyouqiche.com
|
||||
434 688347.SH 华虹半导体有限公司 香港中环夏悫道12号美国银行中心2212室 86-21-38829909,86-21-50809908 IR@hhgrace.com www.huahonggrace.com
|
||||
435 603418.SH 上海友升铝业股份有限公司 上海市青浦区沪青平公路2058号 86-21-59761698 yszq@unisonal.com www.unisonal.com
|
||||
436 603350.SH 安乃达驱动技术(上海)股份有限公司 上海市闵行区光中路133弄19号A座1-2层 86-21-31027576,86-21-31027576*868 security@ananda-drive.com www.ananda-drive.com
|
||||
437 603341.SH 上海龙旗科技股份有限公司 上海市徐汇区漕宝路401号1号楼一层 86-21-61890866 ir@longcheer.com www.longcheer.com
|
||||
438 603325.SH 上海博隆装备技术股份有限公司 上海市青浦区华新镇新协路1356号 86-21-69792579 IR@bloom-powder.com www.bloom-powder.com
|
||||
439 603296.SH 华勤技术股份有限公司 上海市中国(上海)自由贸易试验区科苑路399号1幢 86-21-80221108 ir@huaqin.com www.huaqin.com
|
||||
440 603275.SH 上海众辰电子科技股份有限公司 上海市松江区泖港镇叶新公路3768号 86-21-57860561*8155,86-21-57860560 xuwenjun@zoncn.cn www.zoncn.cn
|
||||
441 603207.SH 上海小方制药股份有限公司 上海市奉贤区洪朱路777号 86-21-58207999,86-21-50818259 info@xf-pharma.com www.xf-pharma.com
|
||||
442 603107.SH 上海汽车空调配件股份有限公司 上海市浦东新区莲溪路1188号 86-21-58442000 zqb@saaa.com.cn www.saaa.com.cn
|
||||
443 603062.SH 麦加芯彩新材料科技(上海)股份有限公司 上海市嘉定区马陆镇思诚路1515号 86-21-39907772 ir@megacoatings.com www.megacoatings.com
|
||||
444 601083.SH 上海锦江航运(集团)股份有限公司 上海市浦东新区龙居路180弄13号2楼 86-21-53866646 ir@jjshipping.cn www.jjshipping.cn
|
||||
445 601026.SH 道生天合材料科技(上海)股份有限公司 上海市中国(上海)自由贸易试验区临港新片区平达路308号1-3幢 86-21-53065580 investor@techstorm.com www.techstorm.com
|
||||
446 301584.SZ 上海建发致新医疗科技集团股份有限公司 上海市杨浦区杨树浦路288号9层 86-21-60430629 jfzx@innostic.com www.innostic.com
|
||||
447 301563.SZ 云汉芯城(上海)互联网科技股份有限公司 上海市松江区漕河泾开发区松江高科技园莘砖公路258号32幢1101室 86-21-31029123 ad@ickey.cn www.ickey.cn
|
||||
448 301555.SZ 惠柏新材料科技(上海)股份有限公司 上海市嘉定区江桥镇博园路558号第2幢 86-21-59970621,86-21-69116380 board-office@wellsepoxy.com;guojuhan@wellsepoxy.com;board-office@wellswam.com www.wellswam.com
|
||||
449 301525.SZ 上海儒竞科技股份有限公司 上海市杨浦区国权北路1688弄75号1204A室 86-21-61811998 juan.li@ruking.com;ir@ruking.com;weiwei.ma@ruking.com;haochao.li@ruking.com www.ruking.com
|
||||
450 301499.SZ 上海维科精密模塑股份有限公司 上海市闵行区北横沙河路598号 86-21-64960228 IR@vico.com.cn www.vico.com.cn
|
||||
451 301315.SZ 上海威士顿信息技术股份有限公司 上海市长宁区长宁路999号643室 86-21-65757700 DBO@wsdinfo.com www.wsdinfo.com
|
||||
452 301173.SZ 上海毓恬冠佳科技股份有限公司 上海市青浦工业园区崧煌路580号 86-21-59219238 mobitech@mobitech.com.cn www.mobitech.com.cn
|
||||
874
_doc/功能规划文档.md
Normal file
874
_doc/功能规划文档.md
Normal file
@@ -0,0 +1,874 @@
|
||||
# 自动找工作系统 - 功能规划文档
|
||||
|
||||
> 版本: v1.0 | 规划日期: 2025-12-25 | 状态: 待开发
|
||||
|
||||
## 文档说明
|
||||
|
||||
本文档规划了自动找工作系统的未开发功能,按优先级分为4个方向共20项功能。每项功能包含现状分析、待开发内容和预期价值,可直接转化为开发任务。
|
||||
|
||||
## 优先级说明
|
||||
|
||||
- **HIGH**: 核心功能,对系统价值提升明显,建议优先开发
|
||||
- **MEDIUM**: 重要优化,提升用户体验和系统性能,可分阶段实施
|
||||
- **LOW**: 未来规划,可根据实际需求决定是否开发
|
||||
|
||||
---
|
||||
|
||||
## 第一部分: 功能完善和补充
|
||||
|
||||
**优先级: HIGH** | **预计工期: 4-6周**
|
||||
|
||||
### 1.1 自动聊天功能完善 ⭐⭐⭐⭐⭐
|
||||
|
||||
**现状分析**
|
||||
- ✅ 聊天记录表结构完整 (`chat_records`)
|
||||
- ✅ AI聊天内容生成基础框架 (`aiService.generateChatContent`)
|
||||
- ✅ 聊天类型分类 (greeting/followup/interview/reply)
|
||||
- ❌ AI生成器未完整实现
|
||||
- ❌ 聊天时机判断逻辑缺失
|
||||
- ❌ 多轮对话上下文管理未实现
|
||||
|
||||
**待开发内容**
|
||||
1. **AI聊天内容生成器完整实现**
|
||||
- 完善 Prompt 模板(不同场景)
|
||||
- 集成简历信息和岗位描述
|
||||
- 个性化内容生成(根据HR回复调整策略)
|
||||
- 长度和语气控制
|
||||
|
||||
2. **聊天时机智能判断**
|
||||
- HR查看后多久发消息(规则+AI预测)
|
||||
- 避免过于频繁或过晚联系
|
||||
- 根据不同平台特性调整策略
|
||||
- 工作时间优先发送
|
||||
|
||||
3. **多轮对话上下文管理**
|
||||
- 记录对话历史
|
||||
- 上下文理解(避免重复询问)
|
||||
- 话题延续和自然过渡
|
||||
- 面试邀约智能识别和响应
|
||||
|
||||
4. **情感分析和回复策略调整**
|
||||
- 分析HR回复的情感倾向
|
||||
- 根据情感调整后续策略
|
||||
- 识别拒绝信号(及时停止沟通)
|
||||
- 识别兴趣信号(加大沟通力度)
|
||||
|
||||
**技术实现**
|
||||
- 文件路径: `api/middleware/job/chatManager.js`
|
||||
- 依赖: `aiService.js`, `chat_records表`
|
||||
- 预计工期: 2周
|
||||
|
||||
**预期价值**
|
||||
- 📈 HR回复率提升 30%+
|
||||
- 📈 面试邀约率提升 20%+
|
||||
- 💡 减少人工沟通成本 80%+
|
||||
- ✨ 提供24小时自动化沟通能力
|
||||
|
||||
---
|
||||
|
||||
### 1.2 账号保活任务 ⭐⭐⭐⭐⭐
|
||||
|
||||
**现状分析**
|
||||
- ✅ 配置项已有 (`pla_account.auto_active`, `active_interval`, `active_actions_json`)
|
||||
- ✅ 任务类型定义 (`auto_active_account`)
|
||||
- ❌ 执行逻辑未实现
|
||||
- ❌ 行为模拟策略缺失
|
||||
|
||||
**待开发内容**
|
||||
1. **定时浏览岗位模拟真实用户**
|
||||
- 随机浏览岗位详情
|
||||
- 模拟点击、滚动行为
|
||||
- 页面停留时间随机化(10-60秒)
|
||||
- 每日浏览次数控制(5-20次)
|
||||
|
||||
2. **随机时间间隔访问**
|
||||
- 避免固定时间访问(容易被识别)
|
||||
- 工作时间随机分布
|
||||
- 模拟午休和下班后的访问
|
||||
- 周末降低活跃频率
|
||||
|
||||
3. **多样化操作行为**
|
||||
- 搜索岗位(随机关键词)
|
||||
- 查看推荐岗位
|
||||
- 浏览公司主页
|
||||
- 修改简历可见性
|
||||
- 更新最后活跃时间
|
||||
|
||||
4. **避免账号被标记为机器人**
|
||||
- 行为模式随机化
|
||||
- 添加鼠标轨迹模拟
|
||||
- 操作速度人性化(不要太快)
|
||||
- 避免连续大量操作
|
||||
|
||||
**技术实现**
|
||||
- 文件路径: `api/middleware/schedule/taskHandlers.js` (新增 `handleAutoActiveTask`)
|
||||
- MQTT指令: 新增 `auto_active` 操作类型
|
||||
- 预计工期: 1周
|
||||
|
||||
**预期价值**
|
||||
- 📉 账号封禁风险降低 70%+
|
||||
- 📈 简历曝光率提升 40%+
|
||||
- 🔒 账号在线状态保持稳定
|
||||
- ✨ 自动维护账号活跃度
|
||||
|
||||
---
|
||||
|
||||
### 1.3 简历自动更新 ⭐⭐⭐⭐
|
||||
|
||||
**现状分析**
|
||||
- ✅ 简历同步功能完整
|
||||
- ✅ 简历信息存储完善
|
||||
- ❌ 简历刷新逻辑未实现
|
||||
- ❌ 简历优化建议缺失
|
||||
|
||||
**待开发内容**
|
||||
1. **定时刷新简历排名**
|
||||
- 每天自动刷新简历(提升排名)
|
||||
- 最佳刷新时间智能选择(如早上9点)
|
||||
- 通过MQTT下发刷新指令
|
||||
- 记录刷新历史和效果
|
||||
|
||||
2. **简历内容优化建议**
|
||||
- AI分析当前简历不足
|
||||
- 给出具体优化建议(哪些技能需要补充)
|
||||
- 对比同类岗位的简历特征
|
||||
- 建议调整项目经验描述
|
||||
|
||||
3. **A/B测试不同简历版本效果**
|
||||
- 支持多个简历版本
|
||||
- 自动切换测试
|
||||
- 统计不同版本的查看率和回复率
|
||||
- 推荐最优版本
|
||||
|
||||
**技术实现**
|
||||
- 文件路径: `api/middleware/job/resumeManager.js` (新增刷新方法)
|
||||
- MQTT指令: 新增 `refresh_resume` 操作
|
||||
- 数据库: `resume_info` 新增 `last_refresh_time` 字段
|
||||
- 预计工期: 1周
|
||||
|
||||
**预期价值**
|
||||
- 📈 简历曝光率提升 50%+
|
||||
- 📈 查看率提升 30%+
|
||||
- 💡 简历质量持续优化
|
||||
- ✨ 自动维护简历新鲜度
|
||||
|
||||
---
|
||||
|
||||
### 1.4 岗位黑名单和收藏 ⭐⭐⭐⭐
|
||||
|
||||
**现状分析**
|
||||
- ❌ 黑名单功能未实现
|
||||
- ❌ 收藏功能未实现
|
||||
- ❌ 岗位对比功能缺失
|
||||
|
||||
**待开发内容**
|
||||
1. **公司黑名单**
|
||||
- 不再投递某公司的岗位
|
||||
- 黑名单原因记录(薪资虚标、工作内容不符等)
|
||||
- 支持批量添加
|
||||
- 黑名单导入导出
|
||||
|
||||
2. **岗位类型黑名单**
|
||||
- 不再投递某类岗位(如外包、销售)
|
||||
- 支持自定义黑名单关键词
|
||||
- 黑名单优先级高于匹配规则
|
||||
|
||||
3. **收藏感兴趣岗位**
|
||||
- 标记收藏岗位
|
||||
- 收藏原因备注
|
||||
- 收藏夹分类管理
|
||||
- 收藏岗位状态跟踪(是否还在招聘)
|
||||
|
||||
4. **岗位对比功能**
|
||||
- 多个岗位并排对比
|
||||
- 对比维度: 薪资、技能要求、公司、地点、福利
|
||||
- AI给出推荐意见
|
||||
- 导出对比报告
|
||||
|
||||
**技术实现**
|
||||
- 数据库: 新增 `job_blacklist`, `job_favorites` 表
|
||||
- 文件路径: `api/controller_admin/` 新增相关API
|
||||
- 前端: `admin/src/views/work/` 新增黑名单和收藏页面
|
||||
- 预计工期: 1周
|
||||
|
||||
**预期价值**
|
||||
- 📈 投递精准度提升 40%+
|
||||
- 📉 无效投递减少 60%+
|
||||
- 💡 提供个性化岗位管理
|
||||
- ✨ 提高求职效率
|
||||
|
||||
---
|
||||
|
||||
### 1.5 多轮面试跟踪 ⭐⭐⭐⭐
|
||||
|
||||
**现状分析**
|
||||
- ✅ `apply_records` 表有 `hasInterview` 字段
|
||||
- ❌ 只记录是否有面试,未细分轮次
|
||||
- ❌ Offer管理功能缺失
|
||||
- ❌ 入职状态未追踪
|
||||
|
||||
**待开发内容**
|
||||
1. **一面/二面/三面状态追踪**
|
||||
- 新增面试轮次字段 (`interview_round`)
|
||||
- 每轮面试时间、地点、面试官记录
|
||||
- 面试类型(电话、视频、现场)
|
||||
- 面试状态(待面试、已面试、通过、淘汰)
|
||||
|
||||
2. **面试反馈记录**
|
||||
- 面试官反馈内容
|
||||
- 面试问题记录
|
||||
- 自我评价
|
||||
- 改进建议
|
||||
|
||||
3. **Offer管理**
|
||||
- Offer详情(薪资、福利、入职时间)
|
||||
- 接受/拒绝/谈薪状态
|
||||
- 谈薪记录(几轮谈判,最终结果)
|
||||
- Offer对比(如有多个Offer)
|
||||
|
||||
4. **入职状态追踪**
|
||||
- 入职日期
|
||||
- 试用期状态
|
||||
- 转正时间
|
||||
- 离职时间(如有)
|
||||
|
||||
**技术实现**
|
||||
- 数据库: `apply_records` 新增字段或新增 `interview_records`, `offer_records` 表
|
||||
- 文件路径: `api/controller_admin/apply_records.js` 扩展
|
||||
- 前端: `admin/src/views/work/apply_records.vue` 新增详情面板
|
||||
- 预计工期: 1.5周
|
||||
|
||||
**预期价值**
|
||||
- 📊 完整的求职生命周期管理
|
||||
- 📈 面试准备更充分
|
||||
- 💡 Offer决策更科学
|
||||
- ✨ 提供长期职业数据积累
|
||||
|
||||
---
|
||||
|
||||
## 第二部分: AI能力增强
|
||||
|
||||
**优先级: HIGH** | **预计工期: 5-7周**
|
||||
|
||||
### 2.1 简历智能优化 ⭐⭐⭐⭐⭐
|
||||
|
||||
**现状分析**
|
||||
- ✅ 简历AI分析基础功能 (`aiService.analyzeResume`)
|
||||
- ✅ 竞争力评分、技能提取、优劣势分析
|
||||
- ❌ 优化建议深度不足
|
||||
- ❌ 针对性改进方案缺失
|
||||
|
||||
**待开发内容**
|
||||
1. **AI简历分析深度提升**
|
||||
- 细粒度分析(每个项目、每段经历)
|
||||
- 识别简历中的弱项和亮点
|
||||
- 对比行业优秀简历
|
||||
- 生成详细分析报告
|
||||
|
||||
2. **简历改进建议(针对性)**
|
||||
- 针对目标岗位给出定制化建议
|
||||
- 建议补充哪些技能关键词
|
||||
- 建议如何重写工作描述
|
||||
- 建议哪些内容需要精简
|
||||
|
||||
3. **技能关键词优化建议**
|
||||
- 分析热门技能关键词
|
||||
- 建议替换为更专业的术语
|
||||
- 建议技能顺序排列
|
||||
- 建议补充相关技能
|
||||
|
||||
4. **项目经验描述优化**
|
||||
- 使用STAR法则重写项目描述
|
||||
- 量化项目成果(如提升XX%性能)
|
||||
- 突出个人贡献和技术难点
|
||||
- 精简冗长描述
|
||||
|
||||
**技术实现**
|
||||
- 文件路径: `api/middleware/job/aiService.js` 新增 `optimizeResume` 方法
|
||||
- Prompt工程: 设计专业的简历优化Prompt
|
||||
- 前端: `resume_info_detail.vue` 新增优化建议面板
|
||||
- 预计工期: 2周
|
||||
|
||||
**预期价值**
|
||||
- 📈 简历竞争力提升 20%+
|
||||
- 📈 查看率提升 35%+
|
||||
- 📈 回复率提升 25%+
|
||||
- 💡 简历质量专业化
|
||||
|
||||
---
|
||||
|
||||
### 2.2 面试问题预测 ⭐⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **基于岗位描述预测面试问题**
|
||||
- 分析岗位职责和要求
|
||||
- 预测技术问题(如React性能优化、数据库索引等)
|
||||
- 预测行为问题(如团队合作、项目经验)
|
||||
- 预测HR问题(如离职原因、职业规划)
|
||||
|
||||
2. **提供参考答案**
|
||||
- 给出专业、结构化的答案
|
||||
- 提供多种回答思路
|
||||
- 标注答案亮点和注意事项
|
||||
- 面试官可能的追问
|
||||
|
||||
3. **根据简历生成个性化回答**
|
||||
- 结合简历中的项目经验
|
||||
- 使用简历中的真实案例
|
||||
- 避免空洞的回答
|
||||
- 突出个人优势
|
||||
|
||||
**技术实现**
|
||||
- 文件路径: `api/middleware/job/aiService.js` 新增 `predictInterviewQuestions` 方法
|
||||
- 数据来源: `job_postings.jobDescription`, `resume_info`
|
||||
- 前端: 新增 `interview_prep.vue` 面试准备页面
|
||||
- 预计工期: 1.5周
|
||||
|
||||
**预期价值**
|
||||
- 📈 面试通过率提升 30%+
|
||||
- ⏱️ 面试准备时间减少 70%+
|
||||
- 💡 回答更专业、更自信
|
||||
- ✨ 提供全方位面试辅导
|
||||
|
||||
---
|
||||
|
||||
### 2.3 薪资谈判策略 ⭐⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **AI分析岗位薪资合理范围**
|
||||
- 根据岗位要求和地区计算合理薪资
|
||||
- 参考行业薪资数据(如拉勾、Boss薪资报告)
|
||||
- 考虑公司规模和融资阶段
|
||||
- 给出薪资范围建议(如18-22K)
|
||||
|
||||
2. **给出谈薪建议和话术**
|
||||
- 什么时候开始谈薪(面试哪个阶段)
|
||||
- 如何提薪资要求(不卑不亢)
|
||||
- 谈判策略(如先等对方报价)
|
||||
- 具体话术模板
|
||||
|
||||
3. **根据市场行情评估Offer价值**
|
||||
- 对比市场平均薪资
|
||||
- 综合评估(薪资+福利+发展空间)
|
||||
- 识别低于市场价的Offer
|
||||
- 给出接受/拒绝建议
|
||||
|
||||
**技术实现**
|
||||
- 文件路径: `api/middleware/job/aiService.js` 新增 `analyzeSalary` 方法
|
||||
- 数据源: 岗位描述、地区、公司、市场数据
|
||||
- 前端: `offer_analysis.vue` Offer分析页面
|
||||
- 预计工期: 1周
|
||||
|
||||
**预期价值**
|
||||
- 💰 薪资平均提升 10-15%
|
||||
- 📈 谈判成功率提升 40%+
|
||||
- 💡 避免接受低薪Offer
|
||||
- ✨ 提供科学的薪资决策
|
||||
|
||||
---
|
||||
|
||||
### 2.4 公司背景调查 ⭐⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **整合企查查/天眼查数据**
|
||||
- 调用企查查API获取公司信息
|
||||
- 公司基本信息(注册资本、成立时间、法人)
|
||||
- 融资情况(融资轮次、投资方)
|
||||
- 诉讼记录、欠薪记录
|
||||
|
||||
2. **AI分析公司发展前景**
|
||||
- 根据融资情况评估发展阶段
|
||||
- 根据招聘规模判断业务状态
|
||||
- 根据行业趋势预测前景
|
||||
- 给出公司评级(A/B/C/D)
|
||||
|
||||
3. **风险预警**
|
||||
- 识别高风险公司(如多次欠薪、频繁裁员)
|
||||
- 识别虚假招聘(如招聘周期异常长)
|
||||
- 识别外包公司(尽管岗位描述未标注)
|
||||
- 给出风险提示和建议
|
||||
|
||||
**技术实现**
|
||||
- 文件路径: `api/services/company_background_service.js`
|
||||
- 数据源: 企查查API、`company_info` 表
|
||||
- 前端: `job_postings.vue` 新增公司背调入口
|
||||
- 预计工期: 1.5周
|
||||
|
||||
**预期价值**
|
||||
- 📉 入职风险降低 80%+
|
||||
- 💡 避免进入高风险公司
|
||||
- ✨ 提供全面的公司情报
|
||||
- 🔍 识别隐藏的问题公司
|
||||
|
||||
---
|
||||
|
||||
### 2.5 职业发展路径 ⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **基于简历和目标生成职业规划**
|
||||
- 分析当前职业阶段(初级/中级/高级)
|
||||
- 根据目标岗位生成发展路径
|
||||
- 列出需要补充的技能和经验
|
||||
- 给出时间线规划(1年/3年/5年)
|
||||
|
||||
2. **技能提升建议**
|
||||
- 推荐学习资源(课程、书籍、开源项目)
|
||||
- 建议考取的证书
|
||||
- 建议参加的技术社区
|
||||
- 建议做的练手项目
|
||||
|
||||
3. **转行可行性分析**
|
||||
- 评估转行难度
|
||||
- 分析已有技能的可迁移性
|
||||
- 给出转行路径建议
|
||||
- 预测转行后的薪资变化
|
||||
|
||||
**技术实现**
|
||||
- 文件路径: `api/middleware/job/aiService.js` 新增 `generateCareerPath` 方法
|
||||
- 数据源: `resume_info`, 目标岗位描述
|
||||
- 前端: 新增 `career_plan.vue` 职业规划页面
|
||||
- 预计工期: 1.5周
|
||||
|
||||
**预期价值**
|
||||
- 📊 提供长期职业指导
|
||||
- 💡 明确发展方向和目标
|
||||
- 📈 技能提升更有针对性
|
||||
- ✨ 降低职业迷茫感
|
||||
|
||||
---
|
||||
|
||||
## 第三部分: 用户体验优化
|
||||
|
||||
**优先级: MEDIUM** | **预计工期: 4-5周**
|
||||
|
||||
### 3.1 数据可视化增强 ⭐⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **投递转化漏斗图**
|
||||
- 投递数 → 查看数 → 回复数 → 面试数 → Offer数
|
||||
- 每个环节的转化率
|
||||
- 识别漏斗中的薄弱环节
|
||||
- 对比不同平台的漏斗差异
|
||||
|
||||
2. **面试成功率趋势**
|
||||
- 按时间展示面试通过率变化
|
||||
- 分析成功率提升/下降原因
|
||||
- 识别面试表现最好的时间段
|
||||
- 给出改进建议
|
||||
|
||||
3. **薪资分布统计**
|
||||
- 投递岗位的薪资分布
|
||||
- Offer薪资分布
|
||||
- 对比期望薪资和实际薪资
|
||||
- 不同技能栈的薪资对比
|
||||
|
||||
4. **不同平台效果对比**
|
||||
- Boss、猎聘等平台的效果对比
|
||||
- 投递量、回复率、面试率对比
|
||||
- 平台特点分析
|
||||
- 推荐最优平台
|
||||
|
||||
**技术实现**
|
||||
- 前端: `admin/src/views/statistics/` 新增统计页面
|
||||
- ECharts图表: 漏斗图、折线图、柱状图、饼图
|
||||
- API: `statistics_server.js` 新增统计接口
|
||||
- 预计工期: 1周
|
||||
|
||||
**预期价值**
|
||||
- 📊 数据洞察更直观
|
||||
- 💡 快速发现问题环节
|
||||
- 📈 数据驱动优化决策
|
||||
- ✨ 提供专业的数据分析
|
||||
|
||||
---
|
||||
|
||||
### 3.2 实时通知系统 ⭐⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **浏览器通知**
|
||||
- 面试邀约即时通知
|
||||
- Offer通知
|
||||
- 重要聊天消息通知
|
||||
- 任务执行状态通知
|
||||
|
||||
2. **邮件通知**
|
||||
- 每日投递报告
|
||||
- 面试提醒(提前1天)
|
||||
- 重要事件邮件
|
||||
- 周报月报
|
||||
|
||||
3. **企业微信/钉钉集成**
|
||||
- 通过企业微信机器人推送
|
||||
- 支持快捷操作(如快速回复)
|
||||
- 群组通知
|
||||
- @指定人员
|
||||
|
||||
4. **关键事件提醒**
|
||||
- 面试邀约(立即通知)
|
||||
- Offer(立即通知)
|
||||
- 简历被查看(可配置)
|
||||
- 任务执行失败(立即通知)
|
||||
|
||||
**技术实现**
|
||||
- 文件路径: `api/services/notification_service.js`
|
||||
- 浏览器通知: Web Notification API
|
||||
- 邮件: nodemailer
|
||||
- 企业微信: 企业微信Webhook
|
||||
- 预计工期: 1.5周
|
||||
|
||||
**预期价值**
|
||||
- ⏱️ 响应时间缩短 90%+
|
||||
- 📲 不错过任何重要消息
|
||||
- 💡 多渠道及时触达
|
||||
- ✨ 提供主动式消息推送
|
||||
|
||||
---
|
||||
|
||||
### 3.3 批量操作功能 ⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **批量启用/禁用账号**
|
||||
- 勾选多个账号
|
||||
- 一键启用/禁用
|
||||
- 批量设置自动化开关
|
||||
- 操作日志记录
|
||||
|
||||
2. **批量设置投递策略**
|
||||
- 批量修改投递时间范围
|
||||
- 批量修改每日上限
|
||||
- 批量设置关键词过滤
|
||||
- 应用模板到多个账号
|
||||
|
||||
3. **批量导出数据**
|
||||
- 勾选导出字段
|
||||
- 导出为CSV/Excel
|
||||
- 定时导出任务
|
||||
- 导出历史管理
|
||||
|
||||
**技术实现**
|
||||
- 前端: 各列表页面新增批量操作工具栏
|
||||
- API: 各模块新增批量操作接口
|
||||
- 预计工期: 1周
|
||||
|
||||
**预期价值**
|
||||
- ⏱️ 管理效率提升 80%+
|
||||
- 💡 降低重复操作
|
||||
- ✨ 提供便捷的批量工具
|
||||
|
||||
---
|
||||
|
||||
### 3.4 移动端适配 ⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **响应式布局优化**
|
||||
- 适配手机、平板屏幕
|
||||
- 菜单改为抽屉式
|
||||
- 表格改为卡片式
|
||||
- 图表自适应尺寸
|
||||
|
||||
2. **移动端专属操作界面**
|
||||
- 大图标按钮
|
||||
- 手势操作(滑动、长按)
|
||||
- 底部操作栏
|
||||
- 快捷入口
|
||||
|
||||
3. **快捷操作入口**
|
||||
- 快捷查看今日统计
|
||||
- 快捷查看任务状态
|
||||
- 快捷回复聊天
|
||||
- 快捷查看面试安排
|
||||
|
||||
**技术实现**
|
||||
- 前端: 响应式CSS
|
||||
- 使用 iView 的响应式组件
|
||||
- 新增移动端专属组件
|
||||
- 预计工期: 1.5周
|
||||
|
||||
**预期价值**
|
||||
- 📱 随时随地管理
|
||||
- 💡 提升移动端体验
|
||||
- ✨ 扩大使用场景
|
||||
|
||||
---
|
||||
|
||||
### 3.5 智能推荐 ⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **推荐最适合的岗位**
|
||||
- 基于简历和历史投递记录
|
||||
- AI预测岗位匹配度
|
||||
- 推荐优先投递的岗位
|
||||
- 推荐理由说明
|
||||
|
||||
2. **推荐最佳投递时间**
|
||||
- 分析不同时间投递的效果
|
||||
- 推荐最佳投递时段
|
||||
- 避开竞争激烈的时段
|
||||
- 根据平台特性调整
|
||||
|
||||
3. **推荐优化策略**
|
||||
- 分析数据找出问题
|
||||
- 推荐具体改进措施
|
||||
- 预测改进后的效果
|
||||
- 跟踪改进效果
|
||||
|
||||
**技术实现**
|
||||
- 文件路径: `api/middleware/job/recommendService.js`
|
||||
- AI模型: 基于历史数据训练
|
||||
- 前端: 首页新增推荐面板
|
||||
- 预计工期: 1周
|
||||
|
||||
**预期价值**
|
||||
- 📈 投递效果提升 25%+
|
||||
- 💡 降低决策成本
|
||||
- ✨ 提供智能化建议
|
||||
|
||||
---
|
||||
|
||||
## 第四部分: 系统性能提升
|
||||
|
||||
**优先级: MEDIUM** | **预计工期: 3-4周**
|
||||
|
||||
### 4.1 缓存策略优化 ⭐⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **Redis缓存热点数据**
|
||||
- 缓存职位类型配置(5分钟→实时)
|
||||
- 缓存统计数据(避免重复计算)
|
||||
- 缓存简历信息(减少数据库查询)
|
||||
- 缓存设备状态
|
||||
|
||||
2. **职位类型配置缓存**
|
||||
- 当前5分钟缓存改为即时失效
|
||||
- 配置更新时清除缓存
|
||||
- 预加载常用配置
|
||||
|
||||
3. **简历信息缓存**
|
||||
- 缓存最近查询的简历
|
||||
- LRU淘汰策略
|
||||
- 简历更新时清除缓存
|
||||
|
||||
**技术实现**
|
||||
- Redis集成: `ioredis`
|
||||
- 文件路径: `api/middleware/cache/cacheManager.js`
|
||||
- 缓存策略: LRU, TTL
|
||||
- 预计工期: 1周
|
||||
|
||||
**预期价值**
|
||||
- 📈 响应速度提升 50%+
|
||||
- 📉 数据库压力降低 60%+
|
||||
- 💡 提升系统吞吐量
|
||||
- ✨ 提供更快的用户体验
|
||||
|
||||
---
|
||||
|
||||
### 4.2 数据库查询优化 ⭐⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **慢查询分析和优化**
|
||||
- 开启MySQL慢查询日志
|
||||
- 分析TOP 10慢查询
|
||||
- 优化SQL语句
|
||||
- 添加必要索引
|
||||
|
||||
2. **索引优化**
|
||||
- 分析现有索引使用情况
|
||||
- 添加复合索引
|
||||
- 删除冗余索引
|
||||
- 定期索引维护
|
||||
|
||||
3. **分表分库策略**
|
||||
- 大表分表(如 `chat_records` 按月分表)
|
||||
- 历史数据归档
|
||||
- 读写分离(可选)
|
||||
|
||||
**技术实现**
|
||||
- 使用 `EXPLAIN` 分析查询
|
||||
- Sequelize 索引配置优化
|
||||
- 数据库迁移脚本
|
||||
- 预计工期: 1周
|
||||
|
||||
**预期价值**
|
||||
- 📈 查询性能提升 3-5倍
|
||||
- 📉 慢查询数量减少 80%+
|
||||
- 💡 数据库可支撑更大数据量
|
||||
- ✨ 提升系统稳定性
|
||||
|
||||
---
|
||||
|
||||
### 4.3 任务队列扩展 ⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **支持更多任务类型**
|
||||
- 定时报告生成
|
||||
- 数据清理任务
|
||||
- 批量操作任务
|
||||
- 自定义任务
|
||||
|
||||
2. **任务优先级动态调整**
|
||||
- 根据紧急程度调整优先级
|
||||
- VIP用户任务优先执行
|
||||
- 失败任务降低优先级
|
||||
- 长时间等待的任务提升优先级
|
||||
|
||||
3. **任务失败自动重试优化**
|
||||
- 更智能的重试策略
|
||||
- 不同错误类型不同重试间隔
|
||||
- 重试次数动态调整
|
||||
- 重试失败后的降级处理
|
||||
|
||||
**技术实现**
|
||||
- 文件路径: `api/middleware/schedule/taskQueue.js` 优化
|
||||
- 新增任务类型处理器
|
||||
- 优化优先级算法
|
||||
- 预计工期: 1周
|
||||
|
||||
**预期价值**
|
||||
- 📈 任务处理更稳定
|
||||
- 💡 支持更复杂的任务场景
|
||||
- ✨ 提供更灵活的任务管理
|
||||
|
||||
---
|
||||
|
||||
### 4.4 并发控制优化 ⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **增加并发设备数**
|
||||
- 当前最多5个设备
|
||||
- 支持动态配置并发数
|
||||
- 根据服务器性能自动调整
|
||||
- 支持分布式部署
|
||||
|
||||
2. **更精细的限流策略**
|
||||
- 不同任务类型不同限流
|
||||
- 不同平台不同限流
|
||||
- 根据时间段动态调整
|
||||
- API请求限流
|
||||
|
||||
3. **分布式锁机制**
|
||||
- 避免多实例冲突
|
||||
- Redis分布式锁
|
||||
- 锁超时自动释放
|
||||
- 死锁检测
|
||||
|
||||
**技术实现**
|
||||
- Redis分布式锁: `redlock`
|
||||
- 配置动态化
|
||||
- 负载均衡策略
|
||||
- 预计工期: 1周
|
||||
|
||||
**预期价值**
|
||||
- 📈 系统吞吐量提升 2-3倍
|
||||
- 💡 支持更大规模部署
|
||||
- ✨ 提供企业级并发控制
|
||||
|
||||
---
|
||||
|
||||
### 4.5 日志和监控 ⭐⭐⭐⭐
|
||||
|
||||
**待开发内容**
|
||||
1. **完善日志记录**
|
||||
- 统一日志格式(JSON)
|
||||
- 日志级别分级(DEBUG/INFO/WARN/ERROR)
|
||||
- 敏感信息脱敏
|
||||
- 日志文件按日期切割
|
||||
|
||||
2. **性能监控面板**
|
||||
- API响应时间监控
|
||||
- 数据库查询时间监控
|
||||
- 任务执行时间监控
|
||||
- 内存和CPU监控
|
||||
|
||||
3. **异常告警机制**
|
||||
- 错误率超过阈值告警
|
||||
- 任务失败立即告警
|
||||
- 系统资源不足告警
|
||||
- 钉钉/企业微信告警
|
||||
|
||||
4. **操作审计日志**
|
||||
- 记录所有关键操作
|
||||
- 操作人、操作时间、操作内容
|
||||
- 敏感操作二次确认
|
||||
- 审计日志导出
|
||||
|
||||
**技术实现**
|
||||
- 日志库: `winston`
|
||||
- 监控: `prometheus` + `grafana` (可选)
|
||||
- 告警: `api/services/alert_service.js`
|
||||
- 预计工期: 1.5周
|
||||
|
||||
**预期价值**
|
||||
- 🔍 问题定位效率提升 80%+
|
||||
- 📊 系统运行状态可视化
|
||||
- 🚨 及时发现和处理异常
|
||||
- ✨ 提供运维级别的监控
|
||||
|
||||
---
|
||||
|
||||
## 附录: 开发优先级建议
|
||||
|
||||
### 短期(1-2个月)
|
||||
**优先开发高价值、低成本功能**
|
||||
|
||||
| 功能 | 优先级 | 预计工期 | 价值 |
|
||||
|------|--------|----------|------|
|
||||
| 自动聊天功能完善 | ⭐⭐⭐⭐⭐ | 2周 | HR回复率+30% |
|
||||
| 账号保活任务 | ⭐⭐⭐⭐⭐ | 1周 | 封禁风险-70% |
|
||||
| 简历智能优化 | ⭐⭐⭐⭐⭐ | 2周 | 竞争力+20% |
|
||||
| 缓存策略优化 | ⭐⭐⭐⭐ | 1周 | 响应速度+50% |
|
||||
|
||||
**预计总工期: 6周**
|
||||
|
||||
### 中期(3-4个月)
|
||||
**完善核心功能和AI能力**
|
||||
|
||||
| 功能 | 优先级 | 预计工期 | 价值 |
|
||||
|------|--------|----------|------|
|
||||
| 简历自动更新 | ⭐⭐⭐⭐ | 1周 | 曝光率+50% |
|
||||
| 岗位黑名单和收藏 | ⭐⭐⭐⭐ | 1周 | 精准度+40% |
|
||||
| 多轮面试跟踪 | ⭐⭐⭐⭐ | 1.5周 | 完整生命周期 |
|
||||
| 面试问题预测 | ⭐⭐⭐⭐ | 1.5周 | 通过率+30% |
|
||||
| 数据可视化增强 | ⭐⭐⭐⭐ | 1周 | 数据洞察更直观 |
|
||||
|
||||
**预计总工期: 6周**
|
||||
|
||||
### 长期(5-6个月)
|
||||
**提升体验和系统性能**
|
||||
|
||||
| 功能 | 优先级 | 预计工期 | 价值 |
|
||||
|------|--------|----------|------|
|
||||
| 薪资谈判策略 | ⭐⭐⭐⭐ | 1周 | 薪资+10-15% |
|
||||
| 公司背景调查 | ⭐⭐⭐⭐ | 1.5周 | 风险-80% |
|
||||
| 实时通知系统 | ⭐⭐⭐⭐ | 1.5周 | 响应时间-90% |
|
||||
| 日志和监控 | ⭐⭐⭐⭐ | 1.5周 | 定位效率+80% |
|
||||
| 数据库查询优化 | ⭐⭐⭐⭐ | 1周 | 性能+3-5倍 |
|
||||
|
||||
**预计总工期: 6.5周**
|
||||
|
||||
---
|
||||
|
||||
## 总结
|
||||
|
||||
本规划文档共列出 **20项待开发功能**,分为4个优先级方向:
|
||||
|
||||
- **功能完善和补充** (5项, HIGH): 完善核心业务流程
|
||||
- **AI能力增强** (5项, HIGH): 提升智能化水平
|
||||
- **用户体验优化** (5项, MEDIUM): 改善交互和便捷性
|
||||
- **系统性能提升** (5项, MEDIUM): 优化性能和稳定性
|
||||
|
||||
**预期开发周期**:
|
||||
- 短期(1-2月): 6周
|
||||
- 中期(3-4月): 6周
|
||||
- 长期(5-6月): 6.5周
|
||||
- **总计**: 约4.5个月
|
||||
|
||||
**预期收益**:
|
||||
- 📈 整体求职成功率提升 **50%+**
|
||||
- 📈 用户使用效率提升 **80%+**
|
||||
- 📈 系统性能提升 **3-5倍**
|
||||
- 💰 用户平均薪资提升 **10-15%**
|
||||
|
||||
---
|
||||
|
||||
**文档维护**: 开发团队
|
||||
**最后更新**: 2025-12-25
|
||||
1174
_doc/客户端待开发功能.md
Normal file
1174
_doc/客户端待开发功能.md
Normal file
File diff suppressed because it is too large
Load Diff
0
_doc/指令列表.md
Normal file
0
_doc/指令列表.md
Normal file
387
_doc/项目功能总结.md
Normal file
387
_doc/项目功能总结.md
Normal file
@@ -0,0 +1,387 @@
|
||||
# 自动找工作系统 - 项目功能总结
|
||||
|
||||
> 版本: v1.0 | 更新日期: 2025-12-25
|
||||
|
||||
## 一、项目概述
|
||||
|
||||
自动找工作系统(autoAiWorkSys)是一个基于AI的智能求职助手平台,通过自动化技术帮助求职者高效管理多个招聘平台账号、智能筛选匹配岗位、自动投递简历,并提供全流程的求职数据分析。系统集成Qwen 2.5 AI模型,实现简历智能分析、岗位匹配度评分、聊天内容生成等功能,大幅提升求职效率和成功率。
|
||||
|
||||
## 二、技术栈
|
||||
|
||||
### 前端技术
|
||||
- **框架**: Vue 2.6.14 + Vuex 3.6.2 + Vue Router 3.5.3
|
||||
- **UI组件**: View Design (iView) 4.7.0
|
||||
- **构建工具**: Webpack 5
|
||||
- **图表库**: ECharts
|
||||
- **HTTP客户端**: 自定义 framework.http
|
||||
|
||||
### 后端技术
|
||||
- **运行时**: Node.js
|
||||
- **Web框架**: Koa 2.16.3
|
||||
- **ORM**: Sequelize 5.22.5
|
||||
- **数据库**: MySQL 8.0
|
||||
- **消息队列**: MQTT (mqtt://192.144.167.231:1883)
|
||||
- **AI模型**: Qwen 2.5 (阿里云DashScope)
|
||||
- **缓存**: Redis (规划中)
|
||||
- **存储**: Ali OSS
|
||||
|
||||
### 核心框架
|
||||
- **Node Core Framework**: 自研框架,提供统一的API路由、数据库管理、日志管理
|
||||
|
||||
## 三、核心特性
|
||||
|
||||
1. **多平台账号管理** - 支持Boss直聘、猎聘等多个招聘平台,统一管理账号和授权
|
||||
2. **智能简历分析** - AI评估简历竞争力(0-100分),提取技能标签,给出优势劣势和职业建议
|
||||
3. **自动岗位投递** - 基于技能匹配和AI评分自动筛选岗位并投递,支持每日上限和时间范围控制
|
||||
4. **AI岗位匹配** - 多维度评分(技能、经验、薪资、公司质量),自动识别外包岗位
|
||||
5. **任务调度系统** - 优先级队列+MQTT通信,设备内串行、设备间并行执行
|
||||
6. **数据可视化统计** - 投递成功率、面试转化率、不同平台效果对比等多维度分析
|
||||
7. **设备实时监控** - 在线状态、健康度、错误信息、心跳检测
|
||||
8. **完整审计日志** - 任务执行、投递记录、聊天记录全链路追踪
|
||||
|
||||
## 四、功能模块一览
|
||||
|
||||
### 4.1 前端功能模块
|
||||
|
||||
| 模块 | 页面路径 | 主要功能 |
|
||||
|------|---------|----------|
|
||||
| **首页/仪表板** | `/home` | 设备选择、账户信息卡片、今日统计、当前任务列表、近7天趋势图 |
|
||||
| **账号管理** | `/account/pla_account` | 账号列表、新增/编辑、授权管理、批量位置解析、停止任务 |
|
||||
| **账号详情** | `/account/pla_account_detail` | 账号基本信息、任务历史、自动化配置、运行操作面板 |
|
||||
| **简历管理** | `/account/resume_info` | 简历列表、查看详情、AI分析结果展示、删除 |
|
||||
| **简历详情** | `/account/resume_info_detail` | 个人信息、教育背景、工作经验、期望信息、AI评分和建议 |
|
||||
| **岗位管理** | `/work/job_postings` | 岗位列表、过滤查询、打招呼、查看详情 |
|
||||
| **投递记录** | `/work/apply_records` | 投递状态追踪、反馈状态、面试/Offer信息 |
|
||||
| **职位类型** | `/work/job_types` | 职位类型配置、技能关键词、排除关键词 |
|
||||
| **任务管理** | `/task/task_status` | 任务列表、指令详情、取消/重试操作 |
|
||||
| **聊天管理** | `/chat/chat_list` | 双面板聊天界面、会话列表、AI生成回复 |
|
||||
| **聊天记录** | `/chat/chat_records` | 聊天历史记录、消息类型、发送状态 |
|
||||
| **系统配置** | `/system/system_config` | 系统参数配置、AI服务配置、MQTT配置 |
|
||||
|
||||
### 4.2 后端API模块
|
||||
|
||||
| 模块 | 接口前缀 | 主要功能 |
|
||||
|------|---------|----------|
|
||||
| **账号管理** | `/admin_api/account` | 列表、详情、新增、更新、删除、授权、停止任务、位置解析 |
|
||||
| **简历管理** | `/admin_api/resume` | 列表、详情、统计、删除、AI分析、按设备获取 |
|
||||
| **岗位管理** | `/admin_api/job` | 列表、详情、统计、删除、打招呼 |
|
||||
| **投递记录** | `/admin_api/apply` | 列表、详情、统计、删除 |
|
||||
| **聊天记录** | `/admin_api/chat` | 列表、详情、统计、删除 |
|
||||
| **设备监控** | `/admin_api/device` | 列表、概览、配置更新、错误重置 |
|
||||
| **任务状态** | `/admin_api/task` | 列表、详情、统计、更新、删除 |
|
||||
| **数据统计** | `/admin_api/dashboard` | 综合统计、投递转化率、平台对比、设备排名 |
|
||||
| **系统配置** | `/admin_api/system` | 配置列表、新增、更新、删除 |
|
||||
|
||||
### 4.3 核心业务流程
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ 账号配置和授权 │
|
||||
└──────────┬──────────┘
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ 获取在线简历(MQTT) │ → AI分析 → 竞争力评分、技能提取、优劣势分析
|
||||
└──────────┬──────────┘
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ 创建自动投递任务 │
|
||||
└──────────┬──────────┘
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ 搜索岗位(MQTT) │ → 保存到job_postings表
|
||||
└──────────┬──────────┘
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ 岗位过滤和匹配 │ → 技能匹配+AI评分+外包识别
|
||||
└──────────┬──────────┘
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ 自动投递(MQTT) │ → 记录apply_records
|
||||
└──────────┬──────────┘
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ 聊天和面试跟踪 │ → AI生成聊天内容
|
||||
└──────────┬──────────┘
|
||||
↓
|
||||
┌─────────────────────┐
|
||||
│ 数据统计和分析 │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
## 五、已实现功能清单
|
||||
|
||||
### 5.1 账号管理模块 ✅
|
||||
|
||||
- ✅ 多平台账号绑定(Boss直聘、猎聘)
|
||||
- ✅ 账号状态管理(启用/禁用、在线/离线、登录状态)
|
||||
- ✅ 自动化开关(自动投递、自动聊天、自动活跃)
|
||||
- ✅ 授权管理(设置授权日期、天数、过期时间计算)
|
||||
- ✅ 职位类型配置(关联job_types)
|
||||
- ✅ 投递配置(时间范围、每日上限、薪资范围、关键词过滤)
|
||||
- ✅ 沟通配置(时间范围、是否沟通外包岗位)
|
||||
- ✅ 活跃配置(活跃间隔、活跃动作)
|
||||
- ✅ 位置解析(单个/批量,经纬度获取)
|
||||
- ✅ 停止任务(取消该账号所有运行中任务)
|
||||
|
||||
### 5.2 简历管理模块 ✅
|
||||
|
||||
- ✅ 简历信息存储(个人信息、教育背景、工作经验、期望信息)
|
||||
- ✅ 简历获取(通过MQTT从设备获取在线简历)
|
||||
- ✅ AI竞争力评分(0-100分)
|
||||
- ✅ AI技能标签提取
|
||||
- ✅ AI优势劣势分析
|
||||
- ✅ AI职业建议生成
|
||||
- ✅ 简历与岗位匹配度计算
|
||||
- ✅ 简历统计(平均竞争力、工作年限分布、竞争力分布)
|
||||
- ✅ 按设备和平台查询简历
|
||||
|
||||
### 5.3 岗位管理模块 ✅
|
||||
|
||||
- ✅ 岗位信息存储(基本信息、要求、描述、薪资、地点)
|
||||
- ✅ 岗位搜索(通过MQTT下发搜索指令)
|
||||
- ✅ 岗位列表获取(通过MQTT获取列表)
|
||||
- ✅ AI岗位匹配评分(技能、经验、薪资、公司质量)
|
||||
- ✅ 外包岗位识别(规则+AI双层识别)
|
||||
- ✅ 岗位过滤(技能关键词匹配、排除关键词检测)
|
||||
- ✅ 综合评分计算(多维度权重评分)
|
||||
- ✅ 岗位统计(总数、平均匹配度、外包比例)
|
||||
- ✅ 打招呼功能(初次沟通)
|
||||
|
||||
### 5.4 自动投递模块 ✅
|
||||
|
||||
- ✅ 自动投递任务创建和调度
|
||||
- ✅ 每日投递上限控制
|
||||
- ✅ 投递时间范围控制(工作日/周末)
|
||||
- ✅ 简历刷新检查(2小时内刷新)
|
||||
- ✅ 岗位过滤和排序
|
||||
- ✅ 投递指令下发(MQTT)
|
||||
- ✅ 投递状态追踪(待投递、投递中、成功、失败、重复)
|
||||
- ✅ 反馈状态管理(无反馈、已查看、感兴趣、不合适、面试邀约)
|
||||
- ✅ 投递统计(成功率、面试转化率、Offer转化率)
|
||||
|
||||
### 5.5 AI分析模块 ✅
|
||||
|
||||
- ✅ Qwen 2.5模型集成(阿里云DashScope)
|
||||
- ✅ 简历智能分析
|
||||
- ✅ 技能标签提取
|
||||
- ✅ 竞争力评分(0-100)
|
||||
- ✅ 优势劣势分析
|
||||
- ✅ 职业发展建议
|
||||
- ✅ 岗位智能匹配
|
||||
- ✅ 技能匹配度(0-100)
|
||||
- ✅ 经验匹配度(0-100)
|
||||
- ✅ 薪资合理性(0-100)
|
||||
- ✅ 公司质量评分(0-100)
|
||||
- ✅ 外包岗位识别
|
||||
- ✅ 聊天内容生成(基础框架)
|
||||
|
||||
### 5.6 任务调度模块 ✅
|
||||
|
||||
- ✅ 优先级任务队列(堆实现,O(log n)性能)
|
||||
- ✅ 并发控制(全局5设备,每设备1任务)
|
||||
- ✅ 设备内串行、设备间并行执行
|
||||
- ✅ 任务状态管理(待执行、执行中、成功、失败、超时、取消)
|
||||
- ✅ 指数退避重试机制(最多3次)
|
||||
- ✅ 任务超时检测(10分钟)
|
||||
- ✅ 错误分类(可重试/不可重试)
|
||||
- ✅ 任务恢复(启动时恢复未完成任务)
|
||||
- ✅ 任务统计(岗位搜索数、过滤数、投递数、聊天数)
|
||||
|
||||
### 5.7 设备监控模块 ✅
|
||||
|
||||
- ✅ 设备状态追踪(在线/离线)
|
||||
- ✅ 心跳检测(通过MQTT)
|
||||
- ✅ 健康度评分
|
||||
- ✅ 错误信息记录
|
||||
- ✅ 最后心跳时间
|
||||
- ✅ 设备配置更新
|
||||
- ✅ 设备错误重置
|
||||
- ✅ 设备概览统计(在线数、离线数、健康度排名)
|
||||
|
||||
### 5.8 数据统计模块 ✅
|
||||
|
||||
- ✅ 投递成功率统计
|
||||
- ✅ 面试转化率统计
|
||||
- ✅ Offer转化率统计
|
||||
- ✅ 不同平台数据对比
|
||||
- ✅ 设备活跃度排名
|
||||
- ✅ 简历竞争力分布
|
||||
- ✅ 岗位外包比例统计
|
||||
- ✅ 近7天趋势图(投递、搜索、聊天)
|
||||
- ✅ 今日统计(实时刷新)
|
||||
|
||||
### 5.9 聊天管理模块 ✅
|
||||
|
||||
- ✅ 聊天记录存储
|
||||
- ✅ 聊天类型分类(打招呼、跟进、面试、回复)
|
||||
- ✅ 发送状态追踪
|
||||
- ✅ 回复检测和记录
|
||||
- ✅ 回复时长统计
|
||||
- ✅ 面试邀约识别
|
||||
- ✅ 情感分析(积极/中性/消极)
|
||||
- ✅ 效果评分
|
||||
- ✅ AI生成标记
|
||||
- ✅ 双面板聊天界面
|
||||
|
||||
### 5.10 其他功能 ✅
|
||||
|
||||
- ✅ 用户邀请和推广系统
|
||||
- ✅ 公司信息库(上市公司数据)
|
||||
- ✅ 版本管理
|
||||
- ✅ 邮件服务集成
|
||||
- ✅ OSS存储集成
|
||||
- ✅ 地理位置服务(百度地图API)
|
||||
- ✅ Swagger API文档
|
||||
- ✅ 数据导出(CSV)
|
||||
|
||||
## 六、数据模型
|
||||
|
||||
### 6.1 核心表结构
|
||||
|
||||
| 表名 | 说明 | 关键字段 |
|
||||
|------|------|---------|
|
||||
| **pla_account** | 平台账号表 | sn_code, platform_type, is_online, auto_deliver, deliver_config |
|
||||
| **resume_info** | 简历信息表 | resumeId, account_id, aiCompetitiveness, aiSkillTags, aiStrengths |
|
||||
| **job_postings** | 岗位信息表 | jobId, platform, aiMatchScore, isOutsourcing, applyStatus |
|
||||
| **apply_records** | 投递记录表 | resumeId, jobId, applyStatus, feedbackStatus, hasInterview, hasOffer |
|
||||
| **chat_records** | 聊天记录表 | conversationId, chatType, isAiGenerated, hasReply, sentiment |
|
||||
| **task_status** | 任务状态表 | taskType, status, priority, retryCount, progress |
|
||||
| **task_commands** | 任务指令表 | taskId, commandType, commandData, response, executeTime |
|
||||
| **job_types** | 职位类型表 | name, commonSkills, excludeKeywords, sortOrder |
|
||||
| **device_monitor** | 设备监控表 | sn_code, is_online, health_score, last_heartbeat_time |
|
||||
| **company_info** | 公司信息表 | company_name, is_listed, market_value, risk_level |
|
||||
|
||||
### 6.2 表关联关系
|
||||
|
||||
```
|
||||
pla_account (1) ──→ (N) resume_info
|
||||
│
|
||||
├─→ (N) apply_records ←── (1) job_postings
|
||||
│ │
|
||||
│ └─→ (N) chat_records
|
||||
│
|
||||
└─→ (N) task_status ──→ (N) task_commands
|
||||
|
||||
pla_account (1) ──→ (1) job_types (职位类型配置)
|
||||
pla_account (1) ──→ (N) device_monitor (设备监控)
|
||||
```
|
||||
|
||||
## 七、快速开始
|
||||
|
||||
### 7.1 环境要求
|
||||
|
||||
- Node.js >= 14.x
|
||||
- MySQL >= 8.0
|
||||
- Redis (可选,用于缓存)
|
||||
- MQTT Broker (已配置: mqtt://192.144.167.231:1883)
|
||||
|
||||
### 7.2 安装步骤
|
||||
|
||||
```bash
|
||||
# 1. 克隆项目
|
||||
git clone <repository-url>
|
||||
cd autoAiWorkSys
|
||||
|
||||
# 2. 安装后端依赖
|
||||
npm install
|
||||
|
||||
# 3. 安装前端依赖
|
||||
cd admin
|
||||
npm install
|
||||
|
||||
# 4. 配置数据库
|
||||
# 编辑 config/config.js
|
||||
# 设置MySQL连接信息
|
||||
|
||||
# 5. 初始化数据库
|
||||
# 执行 _sql 目录下的SQL脚本
|
||||
|
||||
# 6. 启动后端服务
|
||||
npm run dev
|
||||
|
||||
# 7. 启动前端服务(新终端)
|
||||
cd admin
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### 7.3 核心配置
|
||||
|
||||
**config/config.js** - 主配置文件
|
||||
```javascript
|
||||
{
|
||||
db: {
|
||||
host: 'localhost',
|
||||
port: 3306,
|
||||
database: 'auto_job',
|
||||
username: 'root',
|
||||
password: 'your_password'
|
||||
},
|
||||
mqtt: {
|
||||
host: 'mqtt://192.144.167.231:1883',
|
||||
clientId: 'autoAiWorkSys_server'
|
||||
},
|
||||
ai: {
|
||||
apiKey: 'your_dashscope_api_key',
|
||||
model: 'qwen-turbo' // qwen-turbo/qwen-plus/qwen-max
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.4 访问地址
|
||||
|
||||
- 前端管理后台: http://localhost:8080
|
||||
- 后端API: http://localhost:3000/admin_api
|
||||
- API文档: http://localhost:3000/api/docs
|
||||
|
||||
## 八、项目文件结构
|
||||
|
||||
```
|
||||
autoAiWorkSys/
|
||||
├── admin/ # 前端管理后台
|
||||
│ ├── src/
|
||||
│ │ ├── views/ # 页面组件
|
||||
│ │ ├── api/ # API调用
|
||||
│ │ ├── router/ # 路由配置
|
||||
│ │ └── store/ # Vuex状态管理
|
||||
│ └── package.json
|
||||
│
|
||||
├── api/ # 后端服务
|
||||
│ ├── controller_admin/ # 后台管理API
|
||||
│ ├── middleware/ # 核心业务逻辑
|
||||
│ │ ├── job/ # 岗位、简历、聊天管理
|
||||
│ │ ├── schedule/ # 任务调度系统
|
||||
│ │ └── mqtt/ # MQTT通信
|
||||
│ ├── model/ # 数据库模型
|
||||
│ ├── services/ # 业务服务层
|
||||
│ └── utils/ # 工具函数
|
||||
│
|
||||
├── config/ # 配置文件
|
||||
├── framework/ # 核心框架
|
||||
├── _doc/ # 文档目录
|
||||
├── _sql/ # 数据库脚本
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## 九、技术亮点
|
||||
|
||||
1. **优先级队列** - 堆实现,O(log n)性能,比数组提升10-100倍
|
||||
2. **双层过滤** - 规则过滤+AI评分,平衡性能和准确性
|
||||
3. **智能重试** - 指数退避策略,区分可重试和不可重试错误
|
||||
4. **MQTT通信** - 异步消息队列,高效的设备指令下发和响应
|
||||
5. **AI多场景应用** - 简历分析、岗位匹配、聊天生成、外包识别
|
||||
6. **完整审计** - 任务、投递、聊天全链路追踪
|
||||
7. **模块化设计** - 清晰的分层架构,易于扩展和维护
|
||||
|
||||
## 十、性能指标
|
||||
|
||||
- 任务调度延迟: < 100ms
|
||||
- 数据库查询: 95%在100ms内
|
||||
- MQTT消息延迟: < 50ms
|
||||
- 前端页面加载: < 2s
|
||||
- 并发支持: 最多5个设备同时执行
|
||||
|
||||
---
|
||||
|
||||
**文档维护**: 开发团队
|
||||
**最后更新**: 2025-12-25
|
||||
**联系方式**: 项目Issues
|
||||
34
_sql/add_job_postings_text_match_fields.sql
Normal file
34
_sql/add_job_postings_text_match_fields.sql
Normal file
@@ -0,0 +1,34 @@
|
||||
-- 为 job_postings 表添加文本匹配分析结果字段
|
||||
-- 执行时间: 2025-01-XX
|
||||
|
||||
-- ============================================
|
||||
-- 更新 job_postings 表
|
||||
-- ============================================
|
||||
|
||||
-- 添加 textMatchScore 字段(文本匹配综合评分)
|
||||
ALTER TABLE job_postings
|
||||
ADD COLUMN textMatchScore INT(11) DEFAULT 0 COMMENT '文本匹配综合评分' AFTER aiAnalysis;
|
||||
|
||||
-- 添加 matchSuggestion 字段(投递建议)
|
||||
ALTER TABLE job_postings
|
||||
ADD COLUMN matchSuggestion VARCHAR(200) DEFAULT '' COMMENT '投递建议' AFTER textMatchScore;
|
||||
|
||||
-- 添加 matchConcerns 字段(关注点,JSON数组)
|
||||
ALTER TABLE job_postings
|
||||
ADD COLUMN matchConcerns TEXT COMMENT '关注点(JSON数组)' AFTER matchSuggestion;
|
||||
|
||||
-- 添加 textMatchAnalysis 字段(文本匹配详细分析结果,JSON)
|
||||
ALTER TABLE job_postings
|
||||
ADD COLUMN textMatchAnalysis TEXT COMMENT '文本匹配详细分析结果(JSON)' AFTER matchConcerns;
|
||||
|
||||
-- ============================================
|
||||
-- 说明
|
||||
-- ============================================
|
||||
-- 1. 如果字段已存在,执行 ALTER TABLE 会报错,可以忽略
|
||||
-- 2. 执行前建议先备份数据库
|
||||
-- 3. 字段说明:
|
||||
-- - textMatchScore: 文本匹配综合评分(0-100)
|
||||
-- - matchSuggestion: 投递建议(如:必须投递、可以投递、谨慎考虑等)
|
||||
-- - matchConcerns: 关注点列表(JSON数组格式,如:["可能是外包岗位", "工作经验可能不足"])
|
||||
-- - textMatchAnalysis: 完整分析结果(JSON格式,包含所有详细分析数据)
|
||||
|
||||
79
admin/package-lock.json
generated
79
admin/package-lock.json
generated
@@ -22,6 +22,7 @@
|
||||
"css-loader": "^5.0.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-webpack-plugin": "^5.5.0",
|
||||
"mini-css-extract-plugin": "^2.9.4",
|
||||
"style-loader": "^2.0.0",
|
||||
"vue-loader": "^15.9.0",
|
||||
"vue-style-loader": "^4.1.0",
|
||||
@@ -4834,6 +4835,84 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/mini-css-extract-plugin": {
|
||||
"version": "2.9.4",
|
||||
"resolved": "https://registry.npmmirror.com/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.4.tgz",
|
||||
"integrity": "sha512-ZWYT7ln73Hptxqxk2DxPU9MmapXRhxkJD6tkSR04dnQxm8BGu2hzgKLugK5yySD97u/8yy7Ma7E76k9ZdvtjkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"schema-utils": "^4.0.0",
|
||||
"tapable": "^2.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.13.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"webpack": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/mini-css-extract-plugin/node_modules/ajv": {
|
||||
"version": "8.17.1",
|
||||
"resolved": "https://registry.npmmirror.com/ajv/-/ajv-8.17.1.tgz",
|
||||
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/mini-css-extract-plugin/node_modules/ajv-keywords": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/ajv-keywords/-/ajv-keywords-5.1.0.tgz",
|
||||
"integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ajv": "^8.8.2"
|
||||
}
|
||||
},
|
||||
"node_modules/mini-css-extract-plugin/node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mini-css-extract-plugin/node_modules/schema-utils": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/schema-utils/-/schema-utils-4.3.3.tgz",
|
||||
"integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/json-schema": "^7.0.9",
|
||||
"ajv": "^8.9.0",
|
||||
"ajv-formats": "^2.1.1",
|
||||
"ajv-keywords": "^5.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"scripts": {
|
||||
"install:deps": "npm install",
|
||||
"dev": "webpack serve --mode development --open",
|
||||
"build": "webpack --mode production",
|
||||
"build": "webpack --mode production --progress",
|
||||
"build:test": "webpack --mode test"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -23,6 +23,7 @@
|
||||
"css-loader": "^5.0.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"html-webpack-plugin": "^5.5.0",
|
||||
"mini-css-extract-plugin": "^2.9.4",
|
||||
"style-loader": "^2.0.0",
|
||||
"vue-loader": "^15.9.0",
|
||||
"vue-style-loader": "^4.1.0",
|
||||
|
||||
762
admin/public/register.html
Normal file
762
admin/public/register.html
Normal file
@@ -0,0 +1,762 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>邀请注册</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.register-container {
|
||||
background: #ffffff;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||||
padding: 40px 50px;
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-height: 95vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.register-header {
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.register-header h1 {
|
||||
color: #333;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.register-header p {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.invite-code-badge {
|
||||
display: inline-block;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
font-size: 13px;
|
||||
margin-bottom: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.invite-code-badge.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: inline-block;
|
||||
color: #333;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0;
|
||||
margin-right: 15px;
|
||||
min-width: 100px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
padding: 12px 16px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
background: #fff;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.form-input.error {
|
||||
border-color: #ff4757;
|
||||
background: #fff5f5;
|
||||
}
|
||||
|
||||
.sms-code-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.form-group .sms-code-wrapper .form-input {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.send-code-btn {
|
||||
padding: 12px 20px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.send-code-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ff4757;
|
||||
font-size: 13px;
|
||||
margin-top: 6px;
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
flex-basis: 100%;
|
||||
display: none;
|
||||
padding-left: 115px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.error-message.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-top: 10px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.submit-btn.loading {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.submit-btn.loading::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
margin-left: -10px;
|
||||
margin-top: -10px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
||||
border-top-color: white;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
display: none;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.success-message.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.error-alert {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 15px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
display: none;
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.error-alert.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.register-tips {
|
||||
margin-top: 15px;
|
||||
padding: 12px 15px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 10px;
|
||||
border-left: 4px solid #667eea;
|
||||
}
|
||||
|
||||
.register-tips h3 {
|
||||
color: #333;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.register-tips ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.register-tips li {
|
||||
color: #666;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
padding-left: 18px;
|
||||
position: relative;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.register-tips li::before {
|
||||
content: '✓';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: #667eea;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.register-container {
|
||||
padding: 30px 20px;
|
||||
}
|
||||
|
||||
.register-header h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="register-container">
|
||||
<div class="register-header">
|
||||
<h1>🎉 邀请注册</h1>
|
||||
<p>填写信息完成注册,邀请人将获得奖励</p>
|
||||
<div id="inviteCodeBadge" class="invite-code-badge hidden"></div>
|
||||
</div>
|
||||
|
||||
<div id="successMessage" class="success-message"></div>
|
||||
<div id="errorAlert" class="error-alert"></div>
|
||||
|
||||
<form id="registerForm">
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="email">邮箱地址</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
class="form-input"
|
||||
placeholder="请输入您的邮箱地址"
|
||||
required
|
||||
autocomplete="email"
|
||||
/>
|
||||
<div class="error-message" id="emailError"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="emailCode">验证码</label>
|
||||
<div class="sms-code-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
id="emailCode"
|
||||
class="form-input"
|
||||
placeholder="请输入邮箱验证码"
|
||||
required
|
||||
maxlength="6"
|
||||
style="flex: 1; margin-right: 10px;"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
id="sendCodeBtn"
|
||||
class="send-code-btn"
|
||||
>
|
||||
发送验证码
|
||||
</button>
|
||||
</div>
|
||||
<div class="error-message" id="emailCodeError"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="password">密码</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
class="form-input"
|
||||
placeholder="请输入密码(至少6位)"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
minlength="6"
|
||||
/>
|
||||
<div class="error-message" id="passwordError"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="confirmPassword">确认密码</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
class="form-input"
|
||||
placeholder="请再次输入密码"
|
||||
required
|
||||
autocomplete="new-password"
|
||||
minlength="6"
|
||||
/>
|
||||
<div class="error-message" id="confirmPasswordError"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="inviteCode">邀请码(选填)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="inviteCode"
|
||||
class="form-input"
|
||||
placeholder="请输入邀请码(可选)"
|
||||
/>
|
||||
<div class="error-message" id="inviteCodeError"></div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="submit-btn" id="submitBtn">
|
||||
立即注册
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="register-tips">
|
||||
<h3>注册说明</h3>
|
||||
<ul>
|
||||
<li>邮箱将作为您的登录账号</li>
|
||||
<li>密码长度至少6位,建议使用字母+数字组合</li>
|
||||
<li>邮箱验证码有效期为5分钟</li>
|
||||
<li>邀请码为选填,填写邀请码注册成功后,邀请人将获得3天试用期奖励</li>
|
||||
<li>请妥善保管您的账号信息</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 配置 API 地址(根据环境自动判断)
|
||||
const API_BASE_URL = window.location.hostname === 'localhost'
|
||||
? 'http://localhost:9097/api'
|
||||
: 'https://work.light120.com/api';
|
||||
|
||||
// 获取 URL 参数中的邀请码
|
||||
function getInviteCodeFromUrl() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
return urlParams.get('code') || '';
|
||||
}
|
||||
|
||||
// 倒计时状态
|
||||
let countdownTimer = null;
|
||||
let countdownSeconds = 0;
|
||||
|
||||
// 显示/隐藏消息
|
||||
function showMessage(elementId, message, isError = false) {
|
||||
const element = document.getElementById(elementId);
|
||||
element.textContent = message;
|
||||
element.className = isError ? 'error-alert show' : 'success-message show';
|
||||
|
||||
// 3秒后自动隐藏
|
||||
setTimeout(() => {
|
||||
element.className = isError ? 'error-alert' : 'success-message';
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// 显示邀请码徽章
|
||||
function displayInviteCodeBadge(code) {
|
||||
if (code) {
|
||||
const badge = document.getElementById('inviteCodeBadge');
|
||||
badge.textContent = `邀请码: ${code}`;
|
||||
badge.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
// 自动填充邀请码
|
||||
function autoFillInviteCode(code) {
|
||||
if (code) {
|
||||
const inviteCodeInput = document.getElementById('inviteCode');
|
||||
inviteCodeInput.value = code;
|
||||
}
|
||||
}
|
||||
|
||||
// 发送邮箱验证码
|
||||
async function sendEmailCode(email) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/invite/send_email_code`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
email: email
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('发送验证码失败:', error);
|
||||
throw new Error('网络请求失败,请检查网络连接');
|
||||
}
|
||||
}
|
||||
|
||||
// 开始倒计时
|
||||
function startCountdown(seconds = 60) {
|
||||
const sendBtn = document.getElementById('sendCodeBtn');
|
||||
countdownSeconds = seconds;
|
||||
sendBtn.disabled = true;
|
||||
|
||||
const updateCountdown = () => {
|
||||
if (countdownSeconds > 0) {
|
||||
sendBtn.textContent = `${countdownSeconds}秒后重新发送`;
|
||||
countdownSeconds--;
|
||||
countdownTimer = setTimeout(updateCountdown, 1000);
|
||||
} else {
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.textContent = '发送验证码';
|
||||
if (countdownTimer) {
|
||||
clearTimeout(countdownTimer);
|
||||
countdownTimer = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
updateCountdown();
|
||||
}
|
||||
|
||||
// 验证邮箱格式
|
||||
function validateEmail(email) {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
return emailRegex.test(email);
|
||||
}
|
||||
|
||||
// 显示字段错误
|
||||
function showFieldError(fieldId, message) {
|
||||
const input = document.getElementById(fieldId);
|
||||
const errorDiv = document.getElementById(fieldId + 'Error');
|
||||
|
||||
input.classList.add('error');
|
||||
errorDiv.textContent = message;
|
||||
errorDiv.classList.add('show');
|
||||
}
|
||||
|
||||
// 清除字段错误
|
||||
function clearFieldError(fieldId) {
|
||||
const input = document.getElementById(fieldId);
|
||||
const errorDiv = document.getElementById(fieldId + 'Error');
|
||||
|
||||
input.classList.remove('error');
|
||||
errorDiv.classList.remove('show');
|
||||
}
|
||||
|
||||
// 验证表单
|
||||
function validateForm() {
|
||||
const inviteCode = document.getElementById('inviteCode').value.trim();
|
||||
const email = document.getElementById('email').value.trim();
|
||||
const emailCode = document.getElementById('emailCode').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||
|
||||
let isValid = true;
|
||||
|
||||
// 邀请码为选填,无需验证
|
||||
|
||||
// 验证邮箱
|
||||
if (!email) {
|
||||
showFieldError('email', '请输入邮箱地址');
|
||||
isValid = false;
|
||||
} else if (!validateEmail(email)) {
|
||||
showFieldError('email', '邮箱格式不正确');
|
||||
isValid = false;
|
||||
} else {
|
||||
clearFieldError('email');
|
||||
}
|
||||
|
||||
// 验证邮箱验证码
|
||||
if (!emailCode) {
|
||||
showFieldError('emailCode', '请输入邮箱验证码');
|
||||
isValid = false;
|
||||
} else if (!/^\d{6}$/.test(emailCode)) {
|
||||
showFieldError('emailCode', '验证码为6位数字');
|
||||
isValid = false;
|
||||
} else {
|
||||
clearFieldError('emailCode');
|
||||
}
|
||||
|
||||
// 验证密码
|
||||
if (!password) {
|
||||
showFieldError('password', '请输入密码');
|
||||
isValid = false;
|
||||
} else if (password.length < 6) {
|
||||
showFieldError('password', '密码长度不能少于6位');
|
||||
isValid = false;
|
||||
} else {
|
||||
clearFieldError('password');
|
||||
}
|
||||
|
||||
// 验证确认密码
|
||||
if (!confirmPassword) {
|
||||
showFieldError('confirmPassword', '请再次输入密码');
|
||||
isValid = false;
|
||||
} else if (password !== confirmPassword) {
|
||||
showFieldError('confirmPassword', '两次输入的密码不一致');
|
||||
isValid = false;
|
||||
} else {
|
||||
clearFieldError('confirmPassword');
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
// 注册请求
|
||||
async function register(email, emailCode, password, inviteCode) {
|
||||
try {
|
||||
const requestBody = {
|
||||
email: email,
|
||||
email_code: emailCode,
|
||||
password: password
|
||||
};
|
||||
|
||||
// 如果邀请码不为空,则添加到请求中
|
||||
if (inviteCode) {
|
||||
requestBody.invite_code = inviteCode;
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE_URL}/invite/register`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(requestBody)
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('注册请求失败:', error);
|
||||
throw new Error('网络请求失败,请检查网络连接');
|
||||
}
|
||||
}
|
||||
|
||||
// 处理表单提交
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// 清除之前的错误信息
|
||||
document.getElementById('errorAlert').classList.remove('show');
|
||||
document.getElementById('successMessage').classList.remove('show');
|
||||
|
||||
// 验证表单
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inviteCode = document.getElementById('inviteCode').value.trim();
|
||||
const email = document.getElementById('email').value.trim();
|
||||
const emailCode = document.getElementById('emailCode').value.trim();
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
// 邀请码为选填,可以为空
|
||||
|
||||
// 禁用提交按钮
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.classList.add('loading');
|
||||
|
||||
try {
|
||||
const result = await register(email, emailCode, password, inviteCode);
|
||||
|
||||
if (result.code === 0 || result.success) {
|
||||
showMessage('successMessage', '注册成功!请使用邮箱和密码登录系统。');
|
||||
|
||||
// 清空表单
|
||||
document.getElementById('registerForm').reset();
|
||||
|
||||
// 3秒后可以提示跳转(如果需要)
|
||||
setTimeout(() => {
|
||||
// 可以在这里添加跳转到登录页的逻辑
|
||||
// window.location.href = '/login.html';
|
||||
}, 3000);
|
||||
} else {
|
||||
showMessage('errorAlert', result.message || '注册失败,请重试', true);
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('errorAlert', error.message || '注册失败,请稍后重试', true);
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.classList.remove('loading');
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const inviteCodeFromUrl = getInviteCodeFromUrl();
|
||||
|
||||
// 自动填充邀请码
|
||||
autoFillInviteCode(inviteCodeFromUrl);
|
||||
|
||||
// 显示邀请码徽章
|
||||
if (inviteCodeFromUrl) {
|
||||
displayInviteCodeBadge(inviteCodeFromUrl);
|
||||
}
|
||||
|
||||
// 绑定表单提交事件
|
||||
document.getElementById('registerForm').addEventListener('submit', handleSubmit);
|
||||
|
||||
// 发送验证码按钮事件
|
||||
document.getElementById('sendCodeBtn').addEventListener('click', async function() {
|
||||
const email = document.getElementById('email').value.trim();
|
||||
|
||||
// 验证邮箱
|
||||
if (!email) {
|
||||
showFieldError('email', '请先输入邮箱地址');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!validateEmail(email)) {
|
||||
showFieldError('email', '邮箱格式不正确');
|
||||
return;
|
||||
}
|
||||
|
||||
clearFieldError('email');
|
||||
|
||||
// 禁用按钮
|
||||
const sendBtn = document.getElementById('sendCodeBtn');
|
||||
sendBtn.disabled = true;
|
||||
sendBtn.textContent = '发送中...';
|
||||
|
||||
try {
|
||||
const result = await sendEmailCode(email);
|
||||
|
||||
if (result.code === 0 || result.success) {
|
||||
showMessage('successMessage', '验证码已发送到您的邮箱,请查收');
|
||||
startCountdown(60);
|
||||
} else {
|
||||
showMessage('errorAlert', result.message || '发送验证码失败,请重试', true);
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.textContent = '发送验证码';
|
||||
}
|
||||
} catch (error) {
|
||||
showMessage('errorAlert', error.message || '发送验证码失败,请稍后重试', true);
|
||||
sendBtn.disabled = false;
|
||||
sendBtn.textContent = '发送验证码';
|
||||
}
|
||||
});
|
||||
|
||||
// 实时验证
|
||||
document.getElementById('email').addEventListener('blur', function() {
|
||||
const email = this.value.trim();
|
||||
if (email && !validateEmail(email)) {
|
||||
showFieldError('email', '邮箱格式不正确');
|
||||
} else {
|
||||
clearFieldError('email');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('password').addEventListener('blur', function() {
|
||||
const password = this.value;
|
||||
if (password && password.length < 6) {
|
||||
showFieldError('password', '密码长度不能少于6位');
|
||||
} else {
|
||||
clearFieldError('password');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('confirmPassword').addEventListener('blur', function() {
|
||||
const password = document.getElementById('password').value;
|
||||
const confirmPassword = this.value;
|
||||
if (confirmPassword && password !== confirmPassword) {
|
||||
showFieldError('confirmPassword', '两次输入的密码不一致');
|
||||
} else {
|
||||
clearFieldError('confirmPassword');
|
||||
}
|
||||
});
|
||||
|
||||
// 输入时清除错误
|
||||
document.getElementById('email').addEventListener('input', function() {
|
||||
if (!this.classList.contains('error')) return;
|
||||
const email = this.value.trim();
|
||||
if (email && validateEmail(email)) {
|
||||
clearFieldError('email');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('password').addEventListener('input', function() {
|
||||
if (!this.classList.contains('error')) return;
|
||||
const password = this.value;
|
||||
if (password && password.length >= 6) {
|
||||
clearFieldError('password');
|
||||
}
|
||||
|
||||
// 如果确认密码已输入,重新验证
|
||||
const confirmPassword = document.getElementById('confirmPassword').value;
|
||||
if (confirmPassword) {
|
||||
if (password === confirmPassword) {
|
||||
clearFieldError('confirmPassword');
|
||||
} else {
|
||||
showFieldError('confirmPassword', '两次输入的密码不一致');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('confirmPassword').addEventListener('input', function() {
|
||||
if (!this.classList.contains('error')) return;
|
||||
const password = document.getElementById('password').value;
|
||||
const confirmPassword = this.value;
|
||||
if (confirmPassword && password === confirmPassword) {
|
||||
clearFieldError('confirmPassword');
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('emailCode').addEventListener('input', function() {
|
||||
if (!this.classList.contains('error')) return;
|
||||
const emailCode = this.value.trim();
|
||||
if (emailCode && /^\d{6}$/.test(emailCode)) {
|
||||
clearFieldError('emailCode');
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -132,6 +132,17 @@ class PlaAccountServer {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试指令
|
||||
* @param {Number|String} commandId - 指令ID
|
||||
* @returns {Promise}
|
||||
*/
|
||||
retryCommand(commandId) {
|
||||
return window.framework.http.post(`pla_account/retryCommand`, {
|
||||
commandId
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止账号的所有任务
|
||||
* @param {Object} row - 账号数据(包含id和sn_code)
|
||||
@@ -186,6 +197,15 @@ class PlaAccountServer {
|
||||
updateAuthorization(param) {
|
||||
return window.framework.http.post('/account/update-authorization', param)
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析账号的在线简历
|
||||
* @param {Number|String} accountId - 账号ID
|
||||
* @returns {Promise}
|
||||
*/
|
||||
parseResume(accountId) {
|
||||
return window.framework.http.post('/pla_account/parse-resume', { id: accountId })
|
||||
}
|
||||
}
|
||||
|
||||
export default new PlaAccountServer()
|
||||
|
||||
@@ -39,6 +39,15 @@ class ResumeInfoServer {
|
||||
del(row) {
|
||||
return window.framework.http.post('/resume/delete', { resumeId: row.resumeId || row.id })
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 分析简历
|
||||
* @param {String} resumeId - 简历ID
|
||||
* @returns {Promise}
|
||||
*/
|
||||
analyzeWithAI(resumeId) {
|
||||
return window.framework.http.post('/resume/analyze-with-ai', { resumeId })
|
||||
}
|
||||
}
|
||||
|
||||
export default new ResumeInfoServer()
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -25,8 +25,6 @@ import JobTypes from '@/views/work/job_types.vue'
|
||||
// 首页模块
|
||||
import HomeIndex from '@/views/home/index.vue'
|
||||
|
||||
// 邀请注册模块
|
||||
import InviteRegister from '@/views/invite/invite_register.vue'
|
||||
|
||||
|
||||
|
||||
@@ -56,7 +54,8 @@ const componentMap = {
|
||||
'system/version': Version,
|
||||
'work/job_types': JobTypes,
|
||||
'home/index': HomeIndex,
|
||||
'invite/invite_register': InviteRegister,
|
||||
|
||||
|
||||
}
|
||||
|
||||
export default componentMap
|
||||
|
||||
@@ -2,17 +2,43 @@
|
||||
* 设备选择模块 - 用于统计页面的设备切换
|
||||
* 这个模块会被持久化到 localStorage
|
||||
*/
|
||||
|
||||
// localStorage 的 key
|
||||
const STORAGE_KEY = 'device_selected_sn'
|
||||
|
||||
// 从 localStorage 读取上次选中的设备
|
||||
const loadSelectedDevice = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
return saved || ''
|
||||
} catch (error) {
|
||||
console.error('读取选中设备失败:', error)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// 保存选中的设备到 localStorage
|
||||
const saveSelectedDevice = (deviceSn) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, deviceSn)
|
||||
} catch (error) {
|
||||
console.error('保存选中设备失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const deviceModule = {
|
||||
namespaced: true,
|
||||
|
||||
state: {
|
||||
selectedDeviceSn: '', // 当前选中的设备 SN
|
||||
selectedDeviceSn: loadSelectedDevice(), // 从 localStorage 恢复上次选中的设备
|
||||
deviceList: [], // 设备列表
|
||||
},
|
||||
|
||||
mutations: {
|
||||
SET_SELECTED_DEVICE(state, deviceSn) {
|
||||
state.selectedDeviceSn = deviceSn
|
||||
// 持久化到 localStorage
|
||||
saveSelectedDevice(deviceSn)
|
||||
},
|
||||
|
||||
SET_DEVICE_LIST(state, list) {
|
||||
@@ -20,6 +46,7 @@ const deviceModule = {
|
||||
// 如果没有选中的设备,且列表不为空,自动选中第一个
|
||||
if (!state.selectedDeviceSn && list.length > 0) {
|
||||
state.selectedDeviceSn = list[0].deviceSn
|
||||
saveSelectedDevice(state.selectedDeviceSn)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -99,8 +99,11 @@
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<!-- 简历详情弹窗 -->
|
||||
<Modal v-model="resumeModal.visible" :title="resumeModal.title" width="900" :footer-hide="true">
|
||||
<!-- 简历详情弹窗 - 使用 ResumeInfoDetail 组件 -->
|
||||
<ResumeInfoDetail ref="resumeDetail" @on-close="handleResumeDetailClose" />
|
||||
|
||||
<!-- 原简历详情弹窗(备份,暂时保留) -->
|
||||
<Modal v-model="resumeModal.visible" :title="resumeModal.title" width="900" :footer-hide="true" v-if="false">
|
||||
<div v-if="resumeModal.loading" style="text-align: center; padding: 40px;">
|
||||
<Spin size="large"></Spin>
|
||||
<p style="margin-top: 20px;">加载简历数据中...</p>
|
||||
@@ -276,9 +279,11 @@
|
||||
import plaAccountServer from '@/api/profile/pla_account_server.js'
|
||||
import jobTypesServer from '@/api/work/job_types_server.js'
|
||||
import PlaAccountEdit from './pla_account_edit.vue'
|
||||
import ResumeInfoDetail from './resume_info_detail.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ResumeInfoDetail,
|
||||
PlaAccountEdit
|
||||
},
|
||||
data() {
|
||||
@@ -344,13 +349,12 @@ export default {
|
||||
{ title: '密码', key: 'pwd', com: 'Password', required: true },
|
||||
],
|
||||
listColumns: [
|
||||
{ title: 'ID', key: 'id', minWidth: 80 },
|
||||
{ title: '账户名', key: 'name', minWidth: 150 },
|
||||
{ title: '设备SN码', key: 'sn_code', minWidth: 120 },
|
||||
{ title: 'ID', key: 'id' },
|
||||
{ title: '账户名', key: 'name' },
|
||||
{ title: '设备SN码', key: 'sn_code'},
|
||||
{
|
||||
title: '平台',
|
||||
key: 'platform_type',
|
||||
minWidth: 100,
|
||||
render: (h, params) => {
|
||||
const platformMap = {
|
||||
'boss': { text: 'Boss直聘', color: 'blue' },
|
||||
@@ -360,13 +364,13 @@ export default {
|
||||
return h('Tag', { props: { color: platform.color } }, platform.text)
|
||||
}
|
||||
},
|
||||
{ title: '登录名', key: 'login_name', minWidth: 150 },
|
||||
{ title: '搜索关键词', key: 'keyword', minWidth: 150 },
|
||||
{ title: '用户地址', key: 'user_address', minWidth: 150 },
|
||||
{ title: '登录名', key: 'login_name'},
|
||||
{ title: '搜索关键词', key: 'keyword' },
|
||||
{ title: '用户地址', key: 'user_address' },
|
||||
{
|
||||
title: '经纬度',
|
||||
key: 'location',
|
||||
minWidth: 150,
|
||||
|
||||
render: (h, params) => {
|
||||
const lon = params.row.user_longitude;
|
||||
const lat = params.row.user_latitude;
|
||||
@@ -379,7 +383,7 @@ export default {
|
||||
{
|
||||
title: '在线状态',
|
||||
key: 'is_online',
|
||||
minWidth: 100,
|
||||
|
||||
render: (h, params) => {
|
||||
return h('Tag', {
|
||||
props: { color: params.row.is_online ? 'success' : 'default' }
|
||||
@@ -390,7 +394,7 @@ export default {
|
||||
title: '自动投递',
|
||||
key: 'auto_deliver',
|
||||
com: "Radio",
|
||||
minWidth: 100,
|
||||
|
||||
options: [
|
||||
{ value: 1, label: '开启' },
|
||||
{ value: 0, label: '关闭' }
|
||||
@@ -409,7 +413,6 @@ export default {
|
||||
{ value: 1, label: '开启' },
|
||||
{ value: 0, label: '关闭' }
|
||||
],
|
||||
minWidth: 100,
|
||||
render: (h, params) => {
|
||||
return h('Tag', {
|
||||
props: { color: params.row.auto_chat ? 'success' : 'default' }
|
||||
@@ -419,7 +422,7 @@ export default {
|
||||
{
|
||||
title: '剩余天数',
|
||||
key: 'remaining_days',
|
||||
minWidth: 100,
|
||||
|
||||
render: (h, params) => {
|
||||
const remainingDays = params.row.remaining_days || 0
|
||||
let color = 'success'
|
||||
@@ -436,7 +439,7 @@ export default {
|
||||
{
|
||||
title: '授权日期',
|
||||
key: 'authorization_date',
|
||||
minWidth: 150,
|
||||
|
||||
render: (h, params) => {
|
||||
if (!params.row.authorization_date) {
|
||||
return h('span', { style: { color: '#999' } }, '未授权')
|
||||
@@ -448,7 +451,7 @@ export default {
|
||||
{
|
||||
title: '过期时间',
|
||||
key: 'expire_date',
|
||||
minWidth: 150,
|
||||
|
||||
render: (h, params) => {
|
||||
if (!params.row.authorization_date || !params.row.authorization_days) {
|
||||
return h('span', { style: { color: '#999' } }, '未授权')
|
||||
@@ -469,7 +472,7 @@ export default {
|
||||
{ value: 1, label: '开启' },
|
||||
{ value: 0, label: '关闭' }
|
||||
],
|
||||
minWidth: 100,
|
||||
|
||||
render: (h, params) => {
|
||||
return h('Tag', {
|
||||
props: { color: params.row.auto_active ? 'success' : 'default' }
|
||||
@@ -479,7 +482,6 @@ export default {
|
||||
{
|
||||
title: '启用状态',
|
||||
key: 'is_enabled',
|
||||
minWidth: 100,
|
||||
render: (h, params) => {
|
||||
return h('i-switch', {
|
||||
props: {
|
||||
@@ -494,11 +496,11 @@ export default {
|
||||
})
|
||||
}
|
||||
},
|
||||
{ title: '创建时间', key: 'create_time', minWidth: 150 },
|
||||
{ title: '创建时间', key: 'create_time', },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 450,
|
||||
width: 500,
|
||||
type: 'template',
|
||||
render: (h, params) => {
|
||||
let btns = [
|
||||
@@ -661,10 +663,11 @@ export default {
|
||||
},
|
||||
// 查看简历
|
||||
async showResume(row) {
|
||||
this.resumeModal.visible = true
|
||||
this.resumeModal.loading = true
|
||||
this.resumeModal.data = null
|
||||
this.resumeModal.title = `${row.name} - 在线简历`
|
||||
// 显示加载提示
|
||||
const loadingMsg = this.$Message.loading({
|
||||
content: '正在加载简历数据...',
|
||||
duration: 0
|
||||
})
|
||||
|
||||
try {
|
||||
// 根据 sn_code 和 platform 获取简历
|
||||
@@ -677,18 +680,24 @@ export default {
|
||||
// admin 会自动加 /admin_api 前缀
|
||||
const res = await window.framework.http.get(`/resume/get-by-device?sn_code=${row.sn_code}&platform=${platform}`)
|
||||
|
||||
if (res.code === 0) {
|
||||
this.resumeModal.data = res.data
|
||||
loadingMsg()
|
||||
|
||||
if (res.code === 0 && res.data && res.data.resumeId) {
|
||||
// 使用 ResumeInfoDetail 组件显示简历
|
||||
this.$refs.resumeDetail.show(res.data.resumeId)
|
||||
} else {
|
||||
this.$Message.warning(res.message || '未找到简历数据')
|
||||
}
|
||||
} catch (error) {
|
||||
loadingMsg()
|
||||
console.error('获取简历失败:', error)
|
||||
this.$Message.error('获取简历失败:' + (error.message || '请稍后重试'))
|
||||
} finally {
|
||||
this.resumeModal.loading = false
|
||||
}
|
||||
},
|
||||
// 关闭简历详情
|
||||
handleResumeDetailClose() {
|
||||
// 可以在这里添加关闭后的逻辑
|
||||
},
|
||||
// 解析技能标签
|
||||
parseSkills(skills) {
|
||||
if (!skills) return []
|
||||
@@ -852,7 +861,9 @@ export default {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('位置解析失败:', error)
|
||||
this.$Message.error('位置解析失败:' + (error.message || '请稍后重试'))
|
||||
// 优先从 error.response.data.message 获取,然后是 error.message
|
||||
const errorMsg = error.response?.data?.message || error.message || '请稍后重试'
|
||||
this.$Message.error(errorMsg)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -899,7 +910,9 @@ export default {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('批量位置解析失败:', error)
|
||||
this.$Message.error('批量位置解析失败:' + (error.message || '请稍后重试'))
|
||||
// 优先从 error.response.data.message 获取,然后是 error.message
|
||||
const errorMsg = error.response?.data?.message || error.message || '请稍后重试'
|
||||
this.$Message.error(errorMsg)
|
||||
} finally {
|
||||
this.batchParseLoading = false
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
<Col span="8">
|
||||
<div class="detail-item">
|
||||
<span class="label">过期时间:</span>
|
||||
<span class="value" :class="{'text-danger': isExpired(accountInfo)}">
|
||||
<span class="value" :class="{ 'text-danger': isExpired(accountInfo) }">
|
||||
{{ getExpireDate(accountInfo) }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -176,7 +176,8 @@
|
||||
<span class="priority-value">{{ item.weight }}%</span>
|
||||
</div>
|
||||
<div class="priority-total-display">
|
||||
<span>总权重:<strong :class="{'weight-warning': totalWeight !== 100}">{{ totalWeight }}%</strong></span>
|
||||
<span>总权重:<strong :class="{ 'weight-warning': totalWeight !== 100 }">{{ totalWeight
|
||||
}}%</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-config">暂无配置</div>
|
||||
@@ -189,8 +190,8 @@
|
||||
<div class="detail-item">
|
||||
<span class="label">自动投递:</span>
|
||||
<span class="value">
|
||||
<Tag :color="deliverConfig.auto_deliver === 1 ? 'success' : 'default'">
|
||||
{{ deliverConfig.auto_deliver === 1 ? '开启' : '关闭' }}
|
||||
<Tag :color="deliverConfig.auto_deliver ? 'success' : 'default'">
|
||||
{{ deliverConfig.auto_deliver ? '开启' : '关闭' }}
|
||||
</Tag>
|
||||
</span>
|
||||
</div>
|
||||
@@ -204,13 +205,15 @@
|
||||
<Col span="8">
|
||||
<div class="detail-item">
|
||||
<span class="label">最低薪资(元):</span>
|
||||
<span class="value">{{ deliverConfig.min_salary || deliverConfig.min_salary === 0 ? deliverConfig.min_salary : '-' }}</span>
|
||||
<span class="value">{{ deliverConfig.min_salary || deliverConfig.min_salary === 0 ?
|
||||
deliverConfig.min_salary : '-' }}</span>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span="8">
|
||||
<div class="detail-item">
|
||||
<span class="label">最高薪资(元):</span>
|
||||
<span class="value">{{ deliverConfig.max_salary || deliverConfig.max_salary === 0 ? deliverConfig.max_salary : '-' }}</span>
|
||||
<span class="value">{{ deliverConfig.max_salary || deliverConfig.max_salary === 0 ?
|
||||
deliverConfig.max_salary : '-' }}</span>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span="8">
|
||||
@@ -355,9 +358,19 @@
|
||||
<TabPane name="tasks" label="任务列表">
|
||||
<div class="tab-content">
|
||||
<div class="tab-body">
|
||||
<tables :columns="taskColumns" :value="tasksData" :loading="tasksLoading"
|
||||
:pageOption="tasksPageOption" @changePage="queryTasks">
|
||||
</tables>
|
||||
<Table :columns="taskColumns" :data="tasksData" :loading="tasksLoading" border>
|
||||
</Table>
|
||||
<Page
|
||||
:current="tasksPageOption.page"
|
||||
:total="tasksPageOption.total"
|
||||
:page-size="tasksPageOption.pageSize"
|
||||
show-total
|
||||
show-elevator
|
||||
show-sizer
|
||||
@on-change="queryTasks"
|
||||
@on-page-size-change="handleTasksPageSizeChange"
|
||||
style="margin-top: 16px; text-align: right;">
|
||||
</Page>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
@@ -365,9 +378,19 @@
|
||||
<TabPane name="commands" label="指令列表">
|
||||
<div class="tab-content">
|
||||
<div class="tab-body">
|
||||
<tables :columns="commandColumns" :value="commandsData" :loading="commandsLoading"
|
||||
:pageOption="commandsPageOption" @changePage="queryCommands">
|
||||
</tables>
|
||||
<Table :columns="commandColumns" :data="commandsData" :loading="commandsLoading" border>
|
||||
</Table>
|
||||
<Page
|
||||
:current="commandsPageOption.page"
|
||||
:total="commandsPageOption.total"
|
||||
:page-size="commandsPageOption.pageSize"
|
||||
show-total
|
||||
show-elevator
|
||||
show-sizer
|
||||
@on-change="queryCommands"
|
||||
@on-page-size-change="handleCommandsPageSizeChange"
|
||||
style="margin-top: 16px; text-align: right;">
|
||||
</Page>
|
||||
</div>
|
||||
</div>
|
||||
</TabPane>
|
||||
@@ -487,6 +510,7 @@
|
||||
<div v-else-if="qrCodeError" class="qr-code-error">
|
||||
<Icon type="ios-close-circle" size="40" color="#ed4014" />
|
||||
<p>{{ qrCodeError }}</p>
|
||||
|
||||
</div>
|
||||
<div v-else-if="qrCodeData" class="qr-code-display">
|
||||
<div v-if="qrCodeData.qrCode || qrCodeData.qr_code_url || qrCodeData.oos_url || qrCodeData.image || qrCodeData.data"
|
||||
@@ -510,6 +534,9 @@
|
||||
<Button type="primary" @click="refreshQrCode" :loading="qrCodeLoading">刷新二维码</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<!-- 简历详情弹窗 -->
|
||||
<ResumeInfoDetail ref="resumeDetail" @on-close="handleResumeDetailClose" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -517,17 +544,21 @@
|
||||
import plaAccountServer from '@/api/profile/pla_account_server.js'
|
||||
import taskStatusServer from '@/api/task/task_status_server.js'
|
||||
import jobTypesServer from '@/api/work/job_types_server.js'
|
||||
import ResumeInfoDetail from './resume_info_detail.vue'
|
||||
|
||||
export default {
|
||||
name: 'PlaAccountDetail',
|
||||
components: {
|
||||
ResumeInfoDetail
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
accountInfo: {},
|
||||
activeTab: 'tasks',
|
||||
|
||||
|
||||
// 职位类型选项
|
||||
jobTypeOptions: [],
|
||||
|
||||
|
||||
// 配置数据
|
||||
priorityList: [],
|
||||
deliverConfig: {
|
||||
@@ -584,6 +615,12 @@ export default {
|
||||
commandType: 'get_online_resume',
|
||||
commandName: '获取在线简历'
|
||||
},
|
||||
{
|
||||
value: 'parse_and_view_resume',
|
||||
label: '解析简历',
|
||||
icon: 'ios-paper',
|
||||
isCustomAction: true
|
||||
},
|
||||
{
|
||||
value: 'search_jobs',
|
||||
label: '搜索岗位',
|
||||
@@ -778,17 +815,35 @@ export default {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 120,
|
||||
width: 180,
|
||||
render: (h, params) => {
|
||||
return h('Button', {
|
||||
props: {
|
||||
type: 'primary',
|
||||
size: 'small'
|
||||
},
|
||||
on: {
|
||||
click: () => this.showCommandDetail(params.row)
|
||||
}
|
||||
}, '详情')
|
||||
const btns = []
|
||||
// 详情按钮
|
||||
btns.push({
|
||||
title: '详情',
|
||||
type: 'primary',
|
||||
click: () => this.showCommandDetail(params.row)
|
||||
})
|
||||
// 重试按钮(只在失败状态时显示)
|
||||
if (params.row.status === 'failed') {
|
||||
btns.push({
|
||||
title: '重试',
|
||||
type: 'warning',
|
||||
click: () => this.retryCommand(params.row)
|
||||
})
|
||||
}
|
||||
return h('div', btns.map(btn =>
|
||||
h('Button', {
|
||||
props: {
|
||||
type: btn.type,
|
||||
size: 'small'
|
||||
},
|
||||
style: { marginRight: '5px' },
|
||||
on: {
|
||||
click: btn.click
|
||||
}
|
||||
}, btn.title)
|
||||
))
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -828,7 +883,7 @@ export default {
|
||||
this.accountInfo = {}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// 解析配置数据
|
||||
parseConfigData(accountInfo) {
|
||||
// 解析排序优先级配置
|
||||
@@ -847,7 +902,7 @@ export default {
|
||||
} else {
|
||||
this.priorityList = []
|
||||
}
|
||||
|
||||
|
||||
// 解析自动投递配置
|
||||
if (accountInfo.deliver_config) {
|
||||
const deliverConfig = typeof accountInfo.deliver_config === 'string'
|
||||
@@ -885,7 +940,7 @@ export default {
|
||||
deliver_workdays_only: 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 解析自动沟通配置
|
||||
if (accountInfo.chat_strategy) {
|
||||
const chatStrategy = typeof accountInfo.chat_strategy === 'string'
|
||||
@@ -909,7 +964,7 @@ export default {
|
||||
chat_workdays_only: 1
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 解析自动活跃配置
|
||||
if (accountInfo.active_actions) {
|
||||
const activeActions = typeof accountInfo.active_actions === 'string'
|
||||
@@ -928,7 +983,7 @@ export default {
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// 加载职位类型
|
||||
async loadJobTypes() {
|
||||
try {
|
||||
@@ -943,14 +998,14 @@ export default {
|
||||
console.error('加载职位类型失败:', err)
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// 获取职位类型名称
|
||||
getJobTypeName(jobTypeId) {
|
||||
if (!jobTypeId) return ''
|
||||
const jobType = this.jobTypeOptions.find(item => item.value === jobTypeId)
|
||||
return jobType ? jobType.label : ''
|
||||
},
|
||||
|
||||
|
||||
// 获取优先级标签
|
||||
getPriorityLabel(key) {
|
||||
const labelMap = {
|
||||
@@ -974,8 +1029,12 @@ export default {
|
||||
pageOption: this.tasksPageOption
|
||||
}
|
||||
const res = await plaAccountServer.getTasks(this.accountId, param)
|
||||
|
||||
console.log('res', res);
|
||||
|
||||
this.tasksData = res.data.rows || []
|
||||
this.tasksPageOption.total = res.data.count || 0
|
||||
|
||||
} catch (error) {
|
||||
this.$Message.error('加载任务列表失败')
|
||||
this.tasksData = []
|
||||
@@ -997,8 +1056,10 @@ export default {
|
||||
pageOption: this.commandsPageOption
|
||||
}
|
||||
const res = await plaAccountServer.getCommands(this.accountId, param)
|
||||
|
||||
this.commandsData = res.data.rows || []
|
||||
this.commandsPageOption.total = res.data.count || 0
|
||||
|
||||
} catch (error) {
|
||||
this.$Message.error('加载指令列表失败')
|
||||
this.commandsData = []
|
||||
@@ -1020,6 +1081,25 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// 重试指令
|
||||
async retryCommand(command) {
|
||||
this.$Modal.confirm({
|
||||
title: '确认重试',
|
||||
content: `确定要重试指令"${command.command_name}"吗?`,
|
||||
onOk: async () => {
|
||||
try {
|
||||
await plaAccountServer.retryCommand(command.id)
|
||||
this.$Message.success('重试指令成功')
|
||||
// 刷新指令列表
|
||||
|
||||
} catch (error) {
|
||||
console.error('重试指令失败:', error)
|
||||
this.$Message.error(error.message || '重试指令失败')
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 取消任务
|
||||
async cancelTask(task) {
|
||||
this.$Modal.confirm({
|
||||
@@ -1058,6 +1138,20 @@ export default {
|
||||
}
|
||||
},
|
||||
|
||||
// 任务列表分页大小改变
|
||||
handleTasksPageSizeChange(pageSize) {
|
||||
this.tasksPageOption.pageSize = pageSize
|
||||
this.tasksPageOption.page = 1
|
||||
this.queryTasks(1)
|
||||
},
|
||||
|
||||
// 指令列表分页大小改变
|
||||
handleCommandsPageSizeChange(pageSize) {
|
||||
this.commandsPageOption.pageSize = pageSize
|
||||
this.commandsPageOption.page = 1
|
||||
this.queryCommands(1)
|
||||
},
|
||||
|
||||
// 处理刷新
|
||||
handleRefresh() {
|
||||
if (this.activeTab === 'tasks') {
|
||||
@@ -1087,6 +1181,12 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
// 如果是解析简历的自定义操作
|
||||
if (action === 'parse_and_view_resume') {
|
||||
this.handleParseAndViewResume()
|
||||
return
|
||||
}
|
||||
|
||||
// 从菜单列表中查找对应的配置
|
||||
const actionItem = this.actionMenuList.find(item => item.value === action)
|
||||
if (!actionItem) {
|
||||
@@ -1123,6 +1223,42 @@ export default {
|
||||
})
|
||||
},
|
||||
|
||||
// 处理解析并查看简历
|
||||
async handleParseAndViewResume() {
|
||||
this.$Modal.confirm({
|
||||
title: '确认解析简历',
|
||||
content: '确定要解析该账号的在线简历吗?系统会自动获取简历并进行AI分析。',
|
||||
onOk: async () => {
|
||||
const loadingMsg = this.$Message.loading({
|
||||
content: '正在解析简历,请稍候...',
|
||||
duration: 0
|
||||
})
|
||||
|
||||
try {
|
||||
// 调用后端接口解析简历
|
||||
const res = await plaAccountServer.parseResume(this.accountId)
|
||||
|
||||
loadingMsg()
|
||||
this.$Message.success('简历解析成功')
|
||||
|
||||
// 打开简历详情弹窗
|
||||
if (res.data && res.data.resumeId) {
|
||||
this.$refs.resumeDetail.show(res.data.resumeId)
|
||||
}
|
||||
} catch (error) {
|
||||
loadingMsg()
|
||||
console.error('解析简历失败:', error)
|
||||
this.$Message.error('解析简历失败: ' + (error.message || '请稍后重试'))
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// 关闭简历详情弹窗
|
||||
handleResumeDetailClose() {
|
||||
// 可以在这里添加关闭后的逻辑
|
||||
},
|
||||
|
||||
// 显示二维码
|
||||
async showQrCode() {
|
||||
this.qrCodeVisible = true
|
||||
@@ -1618,13 +1754,23 @@ export default {
|
||||
.tab-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 400px;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.tab-body {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow: visible;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
/* TabPane 最小高度 */
|
||||
.tabs-card>>>.ivu-tabs-content {
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.tabs-card>>>.ivu-tabs-tabpane {
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
/* Tab右侧按钮 */
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
<FormItem label="登录名" prop="login_name">
|
||||
<Input v-model="formData.login_name" placeholder="请输入登录名" />
|
||||
</FormItem>
|
||||
<FormItem label="密码" prop="pwd">
|
||||
<FormItem label="密码" prop="pwd" v-if="!isEdit">
|
||||
<Input v-model="formData.pwd" type="password" placeholder="请输入密码" />
|
||||
</FormItem>
|
||||
<FormItem label="搜索关键词">
|
||||
@@ -98,7 +98,7 @@
|
||||
<Card title="排序优先级配置" style="margin-bottom: 16px;">
|
||||
<FormItem label="排序优先级">
|
||||
<div class="priority-config">
|
||||
<div v-for="(item, index) in priorityList" :key="item.key" class="priority-item">
|
||||
<div v-for="item in priorityList" :key="item.key" class="priority-item">
|
||||
<div class="priority-label">
|
||||
<Icon type="ios-menu" class="drag-handle" />
|
||||
<span>{{ getPriorityLabel(item.key) }}</span>
|
||||
@@ -127,8 +127,8 @@
|
||||
<Card title="自动投递配置" style="margin-bottom: 16px;">
|
||||
<FormItem label="自动投递">
|
||||
<RadioGroup v-model="formData.auto_deliver">
|
||||
<Radio :label="1">开启</Radio>
|
||||
<Radio :label="0">关闭</Radio>
|
||||
<Radio :label="true">开启</Radio>
|
||||
<Radio :label="false">关闭</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
<FormItem label="投递间隔(分钟)">
|
||||
@@ -147,10 +147,20 @@
|
||||
<InputNumber v-model="formData.max_deliver" :min="1" placeholder="默认10个" style="width: 100%;" />
|
||||
</FormItem>
|
||||
<FormItem label="过滤关键词">
|
||||
<Input v-model="formData.filter_keywords" placeholder="包含这些关键词的职位会被优先考虑,多个用逗号分隔" />
|
||||
<Input
|
||||
v-model="formData.filter_keywords"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="包含这些关键词的职位会被优先考虑,每行一个关键词,或使用逗号分隔"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="排除关键词">
|
||||
<Input v-model="formData.exclude_keywords" placeholder="包含这些关键词的职位会被排除,多个用逗号分隔" />
|
||||
<Input
|
||||
v-model="formData.exclude_keywords"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
placeholder="包含这些关键词的职位会被排除,每行一个关键词,或使用逗号分隔"
|
||||
/>
|
||||
</FormItem>
|
||||
<FormItem label="投递开始时间">
|
||||
<Input v-model="formData.deliver_start_time" placeholder="格式:HH:mm,如:09:00" />
|
||||
@@ -250,7 +260,7 @@ export default {
|
||||
job_type_id: null,
|
||||
user_address: '',
|
||||
is_salary_priority: '',
|
||||
auto_deliver: 0,
|
||||
auto_deliver: false,
|
||||
deliver_interval: 30,
|
||||
min_salary: 0,
|
||||
max_salary: 0,
|
||||
@@ -312,7 +322,8 @@ export default {
|
||||
this.formData.sn_code = row.sn_code || ''
|
||||
this.formData.platform_type = row.platform_type || ''
|
||||
this.formData.login_name = row.login_name || ''
|
||||
this.formData.pwd = row.pwd || ''
|
||||
// 编辑模式下不加载密码,密码修改需要使用单独的接口
|
||||
this.formData.pwd = ''
|
||||
this.formData.keyword = row.keyword || ''
|
||||
|
||||
// 复制编辑字段
|
||||
@@ -364,16 +375,20 @@ export default {
|
||||
const deliverConfig = typeof row.deliver_config === 'string'
|
||||
? JSON.parse(row.deliver_config)
|
||||
: row.deliver_config
|
||||
// 保存原有配置以便合并
|
||||
this.formData.auto_deliver = deliverConfig.auto_deliver !== undefined ? deliverConfig.auto_deliver : false
|
||||
|
||||
this.formData.original_deliver_config = deliverConfig
|
||||
this.formData.deliver_interval = deliverConfig.deliver_interval || 30
|
||||
this.formData.min_salary = deliverConfig.min_salary || 0
|
||||
this.formData.max_salary = deliverConfig.max_salary || 0
|
||||
this.formData.page_count = deliverConfig.page_count || 3
|
||||
this.formData.max_deliver = deliverConfig.max_deliver || 10
|
||||
this.formData.filter_keywords = Array.isArray(deliverConfig.filter_keywords)
|
||||
? deliverConfig.filter_keywords.join(',')
|
||||
? deliverConfig.filter_keywords.join('\n')
|
||||
: (deliverConfig.filter_keywords || '')
|
||||
this.formData.exclude_keywords = Array.isArray(deliverConfig.exclude_keywords)
|
||||
? deliverConfig.exclude_keywords.join(',')
|
||||
? deliverConfig.exclude_keywords.join('\n')
|
||||
: (deliverConfig.exclude_keywords || '')
|
||||
// 处理时间区间配置
|
||||
if (deliverConfig.time_range) {
|
||||
@@ -381,6 +396,8 @@ export default {
|
||||
this.formData.deliver_end_time = deliverConfig.time_range.end_time || '18:00'
|
||||
this.formData.deliver_workdays_only = deliverConfig.time_range.workdays_only !== undefined ? deliverConfig.time_range.workdays_only : 1
|
||||
}
|
||||
} else {
|
||||
this.formData.original_deliver_config = {}
|
||||
}
|
||||
|
||||
// chat_strategy 配置
|
||||
@@ -422,7 +439,7 @@ export default {
|
||||
job_type_id: null,
|
||||
user_address: '',
|
||||
is_salary_priority: '',
|
||||
auto_deliver: 0,
|
||||
auto_deliver: false,
|
||||
deliver_interval: 30,
|
||||
min_salary: 0,
|
||||
max_salary: 0,
|
||||
@@ -560,24 +577,42 @@ export default {
|
||||
weight: Number(item.weight) || 0
|
||||
}))
|
||||
|
||||
// 处理 deliver_config
|
||||
const deliverConfig = {}
|
||||
deliverConfig.deliver_interval = Number(saveData.deliver_interval) || 30
|
||||
deliverConfig.min_salary = Number(saveData.min_salary) || 0
|
||||
deliverConfig.max_salary = Number(saveData.max_salary) || 0
|
||||
deliverConfig.page_count = Number(saveData.page_count) || 3
|
||||
deliverConfig.max_deliver = Number(saveData.max_deliver) || 10
|
||||
deliverConfig.filter_keywords = typeof saveData.filter_keywords === 'string' && saveData.filter_keywords.trim()
|
||||
? saveData.filter_keywords.split(',').map(k => k.trim()).filter(k => k)
|
||||
: []
|
||||
deliverConfig.exclude_keywords = typeof saveData.exclude_keywords === 'string' && saveData.exclude_keywords.trim()
|
||||
? saveData.exclude_keywords.split(',').map(k => k.trim()).filter(k => k)
|
||||
: []
|
||||
// 处理时间区间配置
|
||||
deliverConfig.time_range = {
|
||||
start_time: saveData.deliver_start_time || '09:00',
|
||||
end_time: saveData.deliver_end_time || '18:00',
|
||||
workdays_only: saveData.deliver_workdays_only ? 1 : 0
|
||||
// 处理 deliver_config(合并原有配置)
|
||||
const originalDeliverConfig = saveData.original_deliver_config || {}
|
||||
const deliverConfig = { ...originalDeliverConfig }
|
||||
|
||||
// 只覆盖传入的字段
|
||||
if (saveData.deliver_interval !== undefined) {
|
||||
deliverConfig.deliver_interval = Number(saveData.deliver_interval) || 30
|
||||
}
|
||||
if (saveData.min_salary !== undefined) {
|
||||
deliverConfig.min_salary = Number(saveData.min_salary) || 0
|
||||
}
|
||||
if (saveData.max_salary !== undefined) {
|
||||
deliverConfig.max_salary = Number(saveData.max_salary) || 0
|
||||
}
|
||||
if (saveData.page_count !== undefined) {
|
||||
deliverConfig.page_count = Number(saveData.page_count) || 3
|
||||
}
|
||||
if (saveData.max_deliver !== undefined) {
|
||||
deliverConfig.max_deliver = Number(saveData.max_deliver) || 10
|
||||
}
|
||||
// 解析过滤关键词:支持换行和逗号分隔
|
||||
if (saveData.filter_keywords !== undefined) {
|
||||
deliverConfig.filter_keywords = this.parseKeywords(saveData.filter_keywords)
|
||||
}
|
||||
// 解析排除关键词:支持换行和逗号分隔
|
||||
if (saveData.exclude_keywords !== undefined) {
|
||||
deliverConfig.exclude_keywords = this.parseKeywords(saveData.exclude_keywords)
|
||||
}
|
||||
// 处理时间区间配置(合并原有配置)
|
||||
if (saveData.deliver_start_time !== undefined || saveData.deliver_end_time !== undefined || saveData.deliver_workdays_only !== undefined) {
|
||||
deliverConfig.time_range = {
|
||||
...(originalDeliverConfig.time_range || {}),
|
||||
start_time: saveData.deliver_start_time !== undefined ? (saveData.deliver_start_time || '09:00') : (originalDeliverConfig.time_range?.start_time || '09:00'),
|
||||
end_time: saveData.deliver_end_time !== undefined ? (saveData.deliver_end_time || '18:00') : (originalDeliverConfig.time_range?.end_time || '18:00'),
|
||||
workdays_only: saveData.deliver_workdays_only !== undefined ? (saveData.deliver_workdays_only ? 1 : 0) : (originalDeliverConfig.time_range?.workdays_only !== undefined ? originalDeliverConfig.time_range.workdays_only : 1)
|
||||
}
|
||||
}
|
||||
saveData.deliver_config = deliverConfig
|
||||
delete saveData.deliver_interval
|
||||
@@ -590,6 +625,7 @@ export default {
|
||||
delete saveData.deliver_start_time
|
||||
delete saveData.deliver_end_time
|
||||
delete saveData.deliver_workdays_only
|
||||
delete saveData.original_deliver_config
|
||||
|
||||
// 处理 chat_strategy
|
||||
const chatStrategy = {}
|
||||
@@ -682,6 +718,28 @@ export default {
|
||||
this.priorityList[0].weight += diff
|
||||
}
|
||||
}
|
||||
},
|
||||
// 解析关键词:支持换行和逗号分隔
|
||||
parseKeywords(keywords) {
|
||||
if (!keywords || typeof keywords !== 'string') {
|
||||
return []
|
||||
}
|
||||
|
||||
const trimmed = keywords.trim()
|
||||
if (!trimmed) {
|
||||
return []
|
||||
}
|
||||
|
||||
// 先按换行分割,再按逗号分割,然后过滤空值
|
||||
const result = []
|
||||
trimmed.split(/\n|,|,/).forEach(keyword => {
|
||||
const trimmedKeyword = keyword.trim()
|
||||
if (trimmedKeyword) {
|
||||
result.push(trimmedKeyword)
|
||||
}
|
||||
})
|
||||
|
||||
return result
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<template>
|
||||
<div class="content-view">
|
||||
<div class="table-head-tool">
|
||||
<Button type="primary" @click="showAddWarp">新增简历</Button>
|
||||
<Form ref="formInline" :model="gridOption.param.seachOption" inline :label-width="80">
|
||||
<FormItem :label-width="20" class="flex">
|
||||
<Select v-model="gridOption.param.seachOption.key" style="width: 120px"
|
||||
@@ -29,13 +28,20 @@
|
||||
@changePage="query"></tables>
|
||||
</div>
|
||||
<editModal ref="editModal" :columns="editColumns" :rules="gridOption.rules"></editModal>
|
||||
|
||||
<!-- 详情组件 -->
|
||||
<ResumeInfoDetail ref="resumeDetail" @on-close="handleDetailClose" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import resumeInfoServer from '@/api/profile/resume_info_server.js'
|
||||
import ResumeInfoDetail from './resume_info_detail.vue'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ResumeInfoDetail
|
||||
},
|
||||
data() {
|
||||
let rules = {}
|
||||
rules["sn_code"] = [{ required: true, message: '请填写设备SN码', trigger: 'blur' }]
|
||||
@@ -90,10 +96,17 @@ export default {
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
width: 200,
|
||||
width: 250,
|
||||
type: 'template',
|
||||
render: (h, params) => {
|
||||
let btns = [
|
||||
{
|
||||
title: '查看',
|
||||
type: 'info',
|
||||
click: () => {
|
||||
this.showDetail(params.row)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '编辑',
|
||||
type: 'primary',
|
||||
@@ -168,6 +181,19 @@ export default {
|
||||
platform: null
|
||||
}
|
||||
this.query(1)
|
||||
},
|
||||
showDetail(row) {
|
||||
// 优先使用 resumeId,如果没有则使用 id
|
||||
const resumeId = row.resumeId || row.id
|
||||
if (resumeId) {
|
||||
this.$refs.resumeDetail.show(resumeId)
|
||||
} else {
|
||||
this.$Message.warning('简历ID不存在')
|
||||
}
|
||||
},
|
||||
handleDetailClose() {
|
||||
// 详情关闭后的回调,可以在这里刷新列表
|
||||
// this.query(this.gridOption.param.pageOption.page)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
|
||||
490
admin/src/views/account/resume_info_detail.vue
Normal file
490
admin/src/views/account/resume_info_detail.vue
Normal file
@@ -0,0 +1,490 @@
|
||||
<template>
|
||||
<FloatPanel
|
||||
ref="floatPanel"
|
||||
title="简历详情"
|
||||
position="right"
|
||||
:show-back="true"
|
||||
back-text="返回"
|
||||
@back="handleBack"
|
||||
>
|
||||
<template #header-right>
|
||||
<Button type="primary" @click="handleAnalyzeAI" :loading="analyzing">AI 分析</Button>
|
||||
</template>
|
||||
|
||||
<div class="resume-detail-content" v-if="resumeData">
|
||||
<Spin fix v-if="loading">
|
||||
<Icon type="ios-loading" size="18" class="spin-icon-load"></Icon>
|
||||
<div>加载中...</div>
|
||||
</Spin>
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<Card title="基本信息" style="margin-bottom: 16px;">
|
||||
<Row :gutter="16">
|
||||
<Col span="12">
|
||||
<div class="info-item">
|
||||
<span class="label">姓名:</span>
|
||||
<span class="value">{{ resumeData.fullName || '-' }}</span>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<div class="info-item">
|
||||
<span class="label">性别:</span>
|
||||
<span class="value">{{ resumeData.gender || '-' }}</span>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<div class="info-item">
|
||||
<span class="label">年龄:</span>
|
||||
<span class="value">{{ resumeData.age || '-' }}</span>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<div class="info-item">
|
||||
<span class="label">电话:</span>
|
||||
<span class="value">{{ resumeData.phone || '-' }}</span>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<div class="info-item">
|
||||
<span class="label">邮箱:</span>
|
||||
<span class="value">{{ resumeData.email || '-' }}</span>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<div class="info-item">
|
||||
<span class="label">所在地:</span>
|
||||
<span class="value">{{ resumeData.location || '-' }}</span>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<div class="info-item">
|
||||
<span class="label">工作年限:</span>
|
||||
<span class="value">{{ resumeData.workYears || '-' }}</span>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<div class="info-item">
|
||||
<span class="label">当前职位:</span>
|
||||
<span class="value">{{ resumeData.currentPosition || '-' }}</span>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<div class="info-item">
|
||||
<span class="label">当前公司:</span>
|
||||
<span class="value">{{ resumeData.currentCompany || '-' }}</span>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<!-- 教育背景 -->
|
||||
<Card title="教育背景" style="margin-bottom: 16px;">
|
||||
<Row :gutter="16">
|
||||
<Col span="12">
|
||||
<div class="info-item">
|
||||
<span class="label">学历:</span>
|
||||
<span class="value">{{ resumeData.education || '-' }}</span>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<div class="info-item">
|
||||
<span class="label">专业:</span>
|
||||
<span class="value">{{ resumeData.major || '-' }}</span>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<div class="info-item">
|
||||
<span class="label">毕业院校:</span>
|
||||
<span class="value">{{ resumeData.school || '-' }}</span>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<div class="info-item">
|
||||
<span class="label">毕业年份:</span>
|
||||
<span class="value">{{ resumeData.graduationYear || '-' }}</span>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<!-- 期望信息 -->
|
||||
<Card title="期望信息" style="margin-bottom: 16px;">
|
||||
<Row :gutter="16">
|
||||
<Col span="12">
|
||||
<div class="info-item">
|
||||
<span class="label">期望职位:</span>
|
||||
<span class="value">{{ resumeData.expectedPosition || '-' }}</span>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<div class="info-item">
|
||||
<span class="label">期望薪资:</span>
|
||||
<span class="value">{{ resumeData.expectedSalary || '-' }}</span>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<div class="info-item">
|
||||
<span class="label">期望地点:</span>
|
||||
<span class="value">{{ resumeData.expectedLocation || '-' }}</span>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span="12">
|
||||
<div class="info-item">
|
||||
<span class="label">期望行业:</span>
|
||||
<span class="value">{{ resumeData.expectedIndustry || '-' }}</span>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<!-- 技能标签 -->
|
||||
<Card title="技能标签" style="margin-bottom: 16px;">
|
||||
<div v-if="skillTags && skillTags.length > 0">
|
||||
<Tag v-for="(skill, index) in skillTags" :key="index" color="blue" style="margin: 4px;">{{ skill }}</Tag>
|
||||
</div>
|
||||
<div v-else class="no-data">暂无技能标签</div>
|
||||
</Card>
|
||||
|
||||
<!-- 技能描述 -->
|
||||
<Card title="技能描述" style="margin-bottom: 16px;" v-if="resumeData.skillDescription">
|
||||
<div class="text-content">{{ resumeData.skillDescription }}</div>
|
||||
</Card>
|
||||
|
||||
<!-- 工作经历 -->
|
||||
<Card title="工作经历" style="margin-bottom: 16px;" v-if="workExperience && workExperience.length > 0">
|
||||
<Timeline>
|
||||
<TimelineItem v-for="(work, index) in workExperience" :key="index">
|
||||
<p class="work-title">
|
||||
<strong>{{ work.position }}</strong>
|
||||
<span class="work-company"> - {{ work.company }}</span>
|
||||
</p>
|
||||
<p class="work-time" v-if="work.startDate || work.endDate">
|
||||
{{ work.startDate }} - {{ work.endDate || '至今' }}
|
||||
</p>
|
||||
<p class="work-content" v-if="work.content">{{ work.content }}</p>
|
||||
</TimelineItem>
|
||||
</Timeline>
|
||||
</Card>
|
||||
|
||||
<!-- 项目经验 -->
|
||||
<Card title="项目经验" style="margin-bottom: 16px;" v-if="projectExperience && projectExperience.length > 0">
|
||||
<div v-for="(project, index) in projectExperience" :key="index" class="project-item" style="margin-bottom: 20px;">
|
||||
<p class="project-title">
|
||||
<strong>{{ project.name }}</strong>
|
||||
<span class="project-role" v-if="project.role"> - {{ project.role }}</span>
|
||||
</p>
|
||||
<p class="project-time" v-if="project.startDate || project.endDate">
|
||||
{{ project.startDate }} - {{ project.endDate || '至今' }}
|
||||
</p>
|
||||
<p class="project-desc" v-if="project.description">{{ project.description }}</p>
|
||||
<p class="project-performance" v-if="project.performance">
|
||||
<strong>项目成果:</strong>{{ project.performance }}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 简历内容 -->
|
||||
<Card title="简历完整内容" style="margin-bottom: 16px;" v-if="resumeData.resumeContent">
|
||||
<div class="text-content" style="white-space: pre-wrap;">{{ resumeData.resumeContent }}</div>
|
||||
</Card>
|
||||
|
||||
<!-- AI 分析结果 -->
|
||||
<Card title="AI 分析结果" style="margin-bottom: 16px;">
|
||||
<Row :gutter="16">
|
||||
<Col span="24">
|
||||
<div class="info-item">
|
||||
<span class="label">竞争力评分:</span>
|
||||
<span class="value" :style="{ color: getCompetitivenessColor(resumeData.aiCompetitiveness), fontSize: '18px', fontWeight: 'bold' }">
|
||||
{{ resumeData.aiCompetitiveness || 0 }} 分
|
||||
</span>
|
||||
<Progress
|
||||
:percent="resumeData.aiCompetitiveness || 0"
|
||||
:stroke-color="getCompetitivenessColor(resumeData.aiCompetitiveness)"
|
||||
style="margin-top: 8px; width: 300px;"
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span="24" v-if="resumeData.aiStrengths">
|
||||
<div class="info-item ai-section">
|
||||
<div class="ai-section-header">
|
||||
<Icon type="ios-thumbs-up" color="#19be6b" size="18" />
|
||||
<span class="label">优势分析</span>
|
||||
</div>
|
||||
<div class="text-content ai-content">{{ resumeData.aiStrengths }}</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span="24" v-if="resumeData.aiWeaknesses">
|
||||
<div class="info-item ai-section">
|
||||
<div class="ai-section-header">
|
||||
<Icon type="ios-warning" color="#ff9900" size="18" />
|
||||
<span class="label">劣势分析</span>
|
||||
</div>
|
||||
<div class="text-content ai-content">{{ resumeData.aiWeaknesses }}</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span="24" v-if="resumeData.aiCareerSuggestion">
|
||||
<div class="info-item ai-section">
|
||||
<div class="ai-section-header">
|
||||
<Icon type="ios-bulb" color="#2d8cf0" size="18" />
|
||||
<span class="label">职业建议</span>
|
||||
</div>
|
||||
<div class="text-content ai-content">{{ resumeData.aiCareerSuggestion }}</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span="24" v-if="aiSkillTags && aiSkillTags.length > 0">
|
||||
<div class="info-item">
|
||||
<span class="label">AI 提取的技能标签:</span>
|
||||
<div style="margin-top: 8px;">
|
||||
<Tag v-for="(skill, index) in aiSkillTags" :key="index" color="green" style="margin: 4px;">{{ skill }}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span="24" v-if="!resumeData.aiCompetitiveness && !resumeData.aiStrengths">
|
||||
<div class="no-data">
|
||||
<Icon type="ios-information-circle" size="24" color="#c5c8ce" />
|
||||
<p>暂无 AI 分析结果,请点击上方"AI 分析"按钮进行分析</p>
|
||||
</div>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<!-- 原始数据(可展开) -->
|
||||
<Card title="原始数据" style="margin-bottom: 16px;">
|
||||
<Collapse>
|
||||
<Panel name="originalData">
|
||||
查看原始 JSON 数据
|
||||
<template slot="content">
|
||||
<pre class="json-preview">{{ JSON.stringify(originalDataObj, null, 2) }}</pre>
|
||||
</template>
|
||||
</Panel>
|
||||
</Collapse>
|
||||
</Card>
|
||||
</div>
|
||||
</FloatPanel>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import resumeInfoServer from '@/api/profile/resume_info_server.js'
|
||||
|
||||
export default {
|
||||
name: 'ResumeInfoDetail',
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
analyzing: false,
|
||||
resumeData: null,
|
||||
skillTags: [],
|
||||
workExperience: [],
|
||||
projectExperience: [],
|
||||
aiSkillTags: [],
|
||||
originalDataObj: {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async show(resumeId) {
|
||||
this.$refs.floatPanel.show()
|
||||
await this.loadResumeData(resumeId)
|
||||
},
|
||||
async loadResumeData(resumeId) {
|
||||
this.loading = true
|
||||
try {
|
||||
const res = await resumeInfoServer.getById(resumeId)
|
||||
this.resumeData = res.data || {}
|
||||
|
||||
// 解析 JSON 字段
|
||||
this.skillTags = this.parseJsonField(this.resumeData.skills) || []
|
||||
this.workExperience = this.parseJsonField(this.resumeData.workExperience) || []
|
||||
this.projectExperience = this.parseJsonField(this.resumeData.projectExperience) || []
|
||||
this.aiSkillTags = this.parseJsonField(this.resumeData.aiSkillTags) || []
|
||||
|
||||
// 解析原始数据
|
||||
this.originalDataObj = this.parseJsonField(this.resumeData.originalData) || {}
|
||||
} catch (error) {
|
||||
console.error('加载简历详情失败:', error)
|
||||
this.$Message.error('加载简历详情失败')
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
},
|
||||
parseJsonField(field) {
|
||||
if (!field) return null
|
||||
if (typeof field === 'string') {
|
||||
try {
|
||||
return JSON.parse(field)
|
||||
} catch (e) {
|
||||
return field
|
||||
}
|
||||
}
|
||||
return field
|
||||
},
|
||||
async handleAnalyzeAI() {
|
||||
if (!this.resumeData || !this.resumeData.resumeId) {
|
||||
this.$Message.warning('简历ID不存在')
|
||||
return
|
||||
}
|
||||
|
||||
this.analyzing = true
|
||||
try {
|
||||
await resumeInfoServer.analyzeWithAI(this.resumeData.resumeId)
|
||||
this.$Message.success('AI 分析完成')
|
||||
// 重新加载数据
|
||||
await this.loadResumeData(this.resumeData.resumeId)
|
||||
} catch (error) {
|
||||
console.error('AI 分析失败:', error)
|
||||
// 优先从 error.response.data.message 获取,然后是 error.message
|
||||
const errorMsg = error.response?.data?.message || error.message || '请稍后重试'
|
||||
this.$Message.error(errorMsg)
|
||||
} finally {
|
||||
this.analyzing = false
|
||||
}
|
||||
},
|
||||
handleBack() {
|
||||
this.$refs.floatPanel.hide()
|
||||
this.$emit('on-close')
|
||||
},
|
||||
getCompetitivenessColor(score) {
|
||||
if (!score) return '#666'
|
||||
if (score >= 80) return '#19be6b'
|
||||
if (score >= 60) return '#2d8cf0'
|
||||
return '#ed4014'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.resume-detail-content {
|
||||
padding: 0;
|
||||
position: relative;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.info-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.info-item .label {
|
||||
font-weight: 600;
|
||||
color: #515a6e;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.info-item .value {
|
||||
color: #2d8cf0;
|
||||
}
|
||||
|
||||
.text-content {
|
||||
color: #515a6e;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.work-title {
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.work-company {
|
||||
color: #808695;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.work-time {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.work-content {
|
||||
color: #515a6e;
|
||||
line-height: 1.6;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.project-item {
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid #e8eaec;
|
||||
}
|
||||
|
||||
.project-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.project-title {
|
||||
font-size: 14px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.project-role {
|
||||
color: #808695;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.project-time {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.project-desc {
|
||||
color: #515a6e;
|
||||
line-height: 1.6;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.project-performance {
|
||||
color: #19be6b;
|
||||
line-height: 1.6;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.no-data {
|
||||
color: #c5c8ce;
|
||||
text-align: center;
|
||||
padding: 40px 20px;
|
||||
}
|
||||
|
||||
.no-data p {
|
||||
margin-top: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* AI 分析区域样式 */
|
||||
.ai-section {
|
||||
margin-bottom: 20px;
|
||||
padding: 16px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #2d8cf0;
|
||||
}
|
||||
|
||||
.ai-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.ai-section-header .label {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #17233d;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.ai-content {
|
||||
padding-left: 26px;
|
||||
line-height: 1.8;
|
||||
color: #515a6e;
|
||||
}
|
||||
|
||||
.json-preview {
|
||||
background: #f8f8f9;
|
||||
padding: 16px;
|
||||
border-radius: 4px;
|
||||
max-height: 500px;
|
||||
overflow-y: auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -72,10 +72,10 @@
|
||||
<Col span="4">
|
||||
<div class="account-switch-item">
|
||||
<div class="switch-label">是否在线</div>
|
||||
<i-switch
|
||||
v-model="accountInfo.is_online"
|
||||
:true-value="true"
|
||||
:false-value="false"
|
||||
<i-switch
|
||||
v-model="accountInfo.is_online"
|
||||
:true-value="1"
|
||||
:false-value="0"
|
||||
@on-change="handleSwitchChange('is_online', $event)"
|
||||
:loading="switchLoading.is_online"
|
||||
:disabled="true"
|
||||
@@ -85,10 +85,10 @@
|
||||
<Col span="4">
|
||||
<div class="account-switch-item">
|
||||
<div class="switch-label">是否登录</div>
|
||||
<i-switch
|
||||
v-model="accountInfo.is_logged_in"
|
||||
:true-value="true"
|
||||
:false-value="false"
|
||||
<i-switch
|
||||
v-model="accountInfo.is_logged_in"
|
||||
:true-value="1"
|
||||
:false-value="0"
|
||||
@on-change="handleSwitchChange('is_logged_in', $event)"
|
||||
:loading="switchLoading.is_logged_in"
|
||||
:disabled="true"
|
||||
@@ -208,7 +208,7 @@ export default {
|
||||
|
||||
data() {
|
||||
return {
|
||||
selectedDeviceSn: '',
|
||||
selectedDeviceSn: this.$store.state.device?.selectedDeviceSn || '', // 从 store 恢复上次选中的设备
|
||||
deviceList: [],
|
||||
todayStats: {
|
||||
applyCount: 0,
|
||||
@@ -320,17 +320,25 @@ export default {
|
||||
})
|
||||
|
||||
this.deviceList = Array.from(deviceMap.values())
|
||||
|
||||
|
||||
console.log('处理后的设备列表:', this.deviceList)
|
||||
console.log('处理后设备数量:', this.deviceList.length)
|
||||
|
||||
|
||||
this.$store.dispatch('device/setDeviceList', this.deviceList)
|
||||
|
||||
// 如果没有选中设备且有设备列表,选中第一个
|
||||
if (!this.selectedDeviceSn && this.deviceList.length > 0) {
|
||||
// 优先从 store 恢复上次选中的设备
|
||||
const savedDeviceSn = this.$store.state.device?.selectedDeviceSn
|
||||
|
||||
if (savedDeviceSn && this.deviceList.some(d => d.deviceSn === savedDeviceSn)) {
|
||||
// 如果 store 中保存的设备在列表中存在,恢复选中
|
||||
console.log('恢复上次选中的设备:', savedDeviceSn)
|
||||
this.selectedDeviceSn = savedDeviceSn
|
||||
this.loadData()
|
||||
} else if (!this.selectedDeviceSn && this.deviceList.length > 0) {
|
||||
// 如果没有保存的设备或保存的设备不在列表中,选中第一个
|
||||
console.log('选中第一个设备:', this.deviceList[0].deviceSn)
|
||||
this.selectedDeviceSn = this.deviceList[0].deviceSn
|
||||
this.$store.dispatch('device/setSelectedDevice', this.selectedDeviceSn)
|
||||
// 立即加载数据
|
||||
this.loadData()
|
||||
} else if (this.selectedDeviceSn) {
|
||||
// 如果已有选中设备,直接加载数据
|
||||
@@ -740,21 +748,23 @@ export default {
|
||||
const deviceRes = await DeviceStatusServer.getById(this.selectedDeviceSn)
|
||||
console.log('设备状态接口返回:', deviceRes)
|
||||
if (deviceRes.code === 0 && deviceRes.data) {
|
||||
accountData.is_online = deviceRes.data.isOnline || false
|
||||
accountData.is_logged_in = deviceRes.data.isLoggedIn || false
|
||||
accountData.is_online = deviceRes.data.isOnline ? 1 : 0
|
||||
accountData.is_logged_in = deviceRes.data.isLoggedIn ? 1 : 0
|
||||
} else {
|
||||
accountData.is_online = false
|
||||
accountData.is_logged_in = false
|
||||
accountData.is_online = 0
|
||||
accountData.is_logged_in = 0
|
||||
}
|
||||
} catch (deviceError) {
|
||||
console.error('获取设备状态失败:', deviceError)
|
||||
accountData.is_online = false
|
||||
accountData.is_logged_in = false
|
||||
accountData.is_online = 0
|
||||
accountData.is_logged_in = 0
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 确保布尔值字段正确初始化,转换为数字类型(0 或 1)
|
||||
accountData.is_enabled = accountData.is_enabled !== undefined ? Number(accountData.is_enabled) : 1
|
||||
accountData.is_online = accountData.is_online !== undefined ? Number(accountData.is_online) : 0
|
||||
accountData.is_logged_in = accountData.is_logged_in !== undefined ? Number(accountData.is_logged_in) : 0
|
||||
accountData.auto_deliver = accountData.auto_deliver !== undefined ? Number(accountData.auto_deliver) : 0
|
||||
accountData.auto_chat = accountData.auto_chat !== undefined ? Number(accountData.auto_chat) : 0
|
||||
accountData.auto_active = accountData.auto_active !== undefined ? Number(accountData.auto_active) : 0
|
||||
|
||||
@@ -1,359 +0,0 @@
|
||||
<template>
|
||||
<div class="invite-register-page">
|
||||
<div class="register-container">
|
||||
<Card class="register-card">
|
||||
<div slot="title" class="card-title">
|
||||
<Icon type="ios-person-add" size="24" />
|
||||
<span>邀请注册</span>
|
||||
</div>
|
||||
|
||||
<div class="register-form">
|
||||
<!-- 邀请码提示 -->
|
||||
<Alert v-if="inviteCode" type="success" show-icon style="margin-bottom: 20px;">
|
||||
<span slot="desc">您正在通过邀请码 <strong>{{ inviteCode }}</strong> 进行注册</span>
|
||||
</Alert>
|
||||
<Alert v-else type="warning" show-icon style="margin-bottom: 20px;">
|
||||
<span slot="desc">未检测到邀请码,请通过邀请链接访问</span>
|
||||
</Alert>
|
||||
|
||||
<Form ref="registerForm" :model="formData" :rules="formRules" :label-width="100">
|
||||
<!-- 手机号 -->
|
||||
<FormItem label="手机号" prop="phone">
|
||||
<Input
|
||||
v-model="formData.phone"
|
||||
placeholder="请输入手机号"
|
||||
:maxlength="11"
|
||||
@on-blur="validatePhone"
|
||||
>
|
||||
<Icon type="ios-phone-portrait" slot="prefix" />
|
||||
</Input>
|
||||
</FormItem>
|
||||
|
||||
<!-- 密码 -->
|
||||
<FormItem label="密码" prop="password">
|
||||
<Input
|
||||
v-model="formData.password"
|
||||
type="password"
|
||||
placeholder="请输入密码(至少6位)"
|
||||
:maxlength="50"
|
||||
password
|
||||
>
|
||||
<Icon type="ios-lock" slot="prefix" />
|
||||
</Input>
|
||||
</FormItem>
|
||||
|
||||
<!-- 确认密码 -->
|
||||
<FormItem label="确认密码" prop="confirmPassword">
|
||||
<Input
|
||||
v-model="formData.confirmPassword"
|
||||
type="password"
|
||||
placeholder="请再次输入密码"
|
||||
:maxlength="50"
|
||||
password
|
||||
>
|
||||
<Icon type="ios-lock" slot="prefix" />
|
||||
</Input>
|
||||
</FormItem>
|
||||
|
||||
<!-- 短信验证码 -->
|
||||
<FormItem label="短信验证码" prop="sms_code">
|
||||
<div class="sms-code-wrapper">
|
||||
<Input
|
||||
v-model="formData.sms_code"
|
||||
placeholder="请输入短信验证码"
|
||||
:maxlength="6"
|
||||
style="width: 200px;"
|
||||
>
|
||||
<Icon type="ios-keypad" slot="prefix" />
|
||||
</Input>
|
||||
<Button
|
||||
:disabled="smsCodeDisabled"
|
||||
:loading="smsCodeLoading"
|
||||
@click="handleSendSmsCode"
|
||||
style="margin-left: 10px;"
|
||||
>
|
||||
{{ smsCodeButtonText }}
|
||||
</Button>
|
||||
</div>
|
||||
</FormItem>
|
||||
|
||||
<!-- 提交按钮 -->
|
||||
<FormItem>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
:loading="registerLoading"
|
||||
@click="handleRegister"
|
||||
style="width: 100%;"
|
||||
>
|
||||
注册
|
||||
</Button>
|
||||
</FormItem>
|
||||
</Form>
|
||||
|
||||
<!-- 注册说明 -->
|
||||
<div class="register-tips">
|
||||
<p><strong>注册说明:</strong></p>
|
||||
<ul>
|
||||
<li>手机号将作为您的登录账号</li>
|
||||
<li>密码长度至少6位,建议使用字母+数字组合</li>
|
||||
<li>短信验证码有效期为5分钟</li>
|
||||
<li>注册成功后,邀请人将获得3天试用期奖励</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import inviteRegisterServer from '@/api/invite/invite_register_server.js';
|
||||
|
||||
export default {
|
||||
name: 'InviteRegister',
|
||||
data() {
|
||||
// 验证确认密码
|
||||
const validateConfirmPassword = (rule, value, callback) => {
|
||||
if (!value) {
|
||||
callback(new Error('请再次输入密码'));
|
||||
} else if (value !== this.formData.password) {
|
||||
callback(new Error('两次输入的密码不一致'));
|
||||
} else {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
inviteCode: '', // 邀请码(从URL参数获取)
|
||||
formData: {
|
||||
phone: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
sms_code: ''
|
||||
},
|
||||
formRules: {
|
||||
phone: [
|
||||
{ required: true, message: '请输入手机号', trigger: 'blur' },
|
||||
{ pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确', trigger: 'blur' }
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: '请输入密码', trigger: 'blur' },
|
||||
{ min: 6, message: '密码长度不能少于6位', trigger: 'blur' }
|
||||
],
|
||||
confirmPassword: [
|
||||
{ required: true, message: '请再次输入密码', trigger: 'blur' },
|
||||
{ validator: validateConfirmPassword, trigger: 'blur' }
|
||||
],
|
||||
sms_code: [
|
||||
{ required: true, message: '请输入短信验证码', trigger: 'blur' },
|
||||
{ pattern: /^\d{6}$/, message: '验证码为6位数字', trigger: 'blur' }
|
||||
]
|
||||
},
|
||||
smsCodeLoading: false,
|
||||
smsCodeDisabled: false,
|
||||
smsCodeCountdown: 0,
|
||||
registerLoading: false
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
smsCodeButtonText() {
|
||||
if (this.smsCodeCountdown > 0) {
|
||||
return `${this.smsCodeCountdown}秒后重新获取`;
|
||||
}
|
||||
return '获取验证码';
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
// 从URL参数获取邀请码
|
||||
this.getInviteCodeFromUrl();
|
||||
},
|
||||
methods: {
|
||||
/**
|
||||
* 从URL参数获取邀请码
|
||||
*/
|
||||
getInviteCodeFromUrl() {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const code = urlParams.get('code');
|
||||
if (code) {
|
||||
this.inviteCode = code;
|
||||
} else {
|
||||
this.$Message.warning('未检测到邀请码,请通过邀请链接访问');
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 验证手机号
|
||||
*/
|
||||
validatePhone() {
|
||||
this.$refs.registerForm.validateField('phone');
|
||||
},
|
||||
|
||||
/**
|
||||
* 发送短信验证码
|
||||
*/
|
||||
async handleSendSmsCode() {
|
||||
// 先验证手机号
|
||||
this.$refs.registerForm.validateField('phone', async (valid) => {
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.formData.phone) {
|
||||
this.$Message.error('请先输入手机号');
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证手机号格式
|
||||
const phoneRegex = /^1[3-9]\d{9}$/;
|
||||
if (!phoneRegex.test(this.formData.phone)) {
|
||||
this.$Message.error('手机号格式不正确');
|
||||
return;
|
||||
}
|
||||
|
||||
this.smsCodeLoading = true;
|
||||
try {
|
||||
const result = await inviteRegisterServer.sendSmsCode(this.formData.phone);
|
||||
if (result.code === 0) {
|
||||
this.$Message.success('短信验证码已发送,请注意查收');
|
||||
// 开始倒计时
|
||||
this.startCountdown();
|
||||
} else {
|
||||
this.$Message.error(result.message || '发送短信验证码失败');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.message || error.message || '发送短信验证码失败';
|
||||
this.$Message.error(errorMessage);
|
||||
} finally {
|
||||
this.smsCodeLoading = false;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* 开始倒计时
|
||||
*/
|
||||
startCountdown() {
|
||||
this.smsCodeCountdown = 60;
|
||||
this.smsCodeDisabled = true;
|
||||
const timer = setInterval(() => {
|
||||
this.smsCodeCountdown--;
|
||||
if (this.smsCodeCountdown <= 0) {
|
||||
clearInterval(timer);
|
||||
this.smsCodeDisabled = false;
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
/**
|
||||
* 注册
|
||||
*/
|
||||
async handleRegister() {
|
||||
// 验证表单
|
||||
this.$refs.registerForm.validate(async (valid) => {
|
||||
if (!valid) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.inviteCode) {
|
||||
this.$Message.error('邀请码不能为空,请通过邀请链接访问');
|
||||
return;
|
||||
}
|
||||
|
||||
this.registerLoading = true;
|
||||
try {
|
||||
const result = await inviteRegisterServer.register({
|
||||
phone: this.formData.phone,
|
||||
password: this.formData.password,
|
||||
sms_code: this.formData.sms_code,
|
||||
invite_code: this.inviteCode
|
||||
});
|
||||
|
||||
if (result.code === 0) {
|
||||
this.$Message.success('注册成功!');
|
||||
// 延迟跳转到登录页面或提示用户登录
|
||||
setTimeout(() => {
|
||||
this.$Message.info('请使用注册的手机号和密码登录');
|
||||
// 可以跳转到登录页面
|
||||
// this.$router.push('/login');
|
||||
}, 2000);
|
||||
} else {
|
||||
this.$Message.error(result.message || '注册失败');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.message || error.message || '注册失败';
|
||||
this.$Message.error(errorMessage);
|
||||
} finally {
|
||||
this.registerLoading = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.invite-register-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.register-container {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.register-card {
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #2d8cf0;
|
||||
}
|
||||
|
||||
.register-form {
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
.sms-code-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.register-tips {
|
||||
margin-top: 30px;
|
||||
padding: 15px;
|
||||
background: #f8f8f9;
|
||||
border-radius: 4px;
|
||||
border-left: 4px solid #2d8cf0;
|
||||
}
|
||||
|
||||
.register-tips p {
|
||||
margin: 0 0 10px 0;
|
||||
font-weight: 600;
|
||||
color: #515a6e;
|
||||
}
|
||||
|
||||
.register-tips ul {
|
||||
margin: 0;
|
||||
padding-left: 20px;
|
||||
color: #808695;
|
||||
}
|
||||
|
||||
.register-tips ul li {
|
||||
margin-bottom: 5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -284,6 +284,7 @@ export default {
|
||||
|
||||
try {
|
||||
const res = await taskStatusServer.getCommands(row.id)
|
||||
|
||||
this.commandsModal.data = res.data || []
|
||||
} catch (error) {
|
||||
this.$Message.error('获取指令列表失败')
|
||||
|
||||
@@ -104,9 +104,9 @@ export default {
|
||||
{ title: '公司名称', key: 'companyName', minWidth: 180 },
|
||||
{ title: '薪资', key: 'salary', minWidth: 120 },
|
||||
{ title: '地点', key: 'location', minWidth: 120 },
|
||||
{
|
||||
title: '投递状态',
|
||||
key: 'applyStatus',
|
||||
{
|
||||
title: '投递状态',
|
||||
key: 'applyStatus',
|
||||
minWidth: 120,
|
||||
render: (h, params) => {
|
||||
const statusMap = {
|
||||
@@ -117,6 +117,27 @@ export default {
|
||||
'duplicate': { text: '重复投递', color: 'warning' }
|
||||
}
|
||||
const status = statusMap[params.row.applyStatus] || { text: params.row.applyStatus, color: 'default' }
|
||||
|
||||
// 如果是投递失败且有错误信息,显示带 tooltip 的标签
|
||||
if (params.row.applyStatus === 'failed' && params.row.errorMessage) {
|
||||
return h('Tooltip', {
|
||||
props: {
|
||||
content: params.row.errorMessage,
|
||||
maxWidth: 300,
|
||||
transfer: true,
|
||||
placement: 'top'
|
||||
}
|
||||
}, [
|
||||
h('Tag', { props: { color: status.color } }, [
|
||||
status.text,
|
||||
h('Icon', {
|
||||
props: { type: 'ios-information-circle' },
|
||||
style: { marginLeft: '4px' }
|
||||
})
|
||||
])
|
||||
])
|
||||
}
|
||||
|
||||
return h('Tag', { props: { color: status.color } }, status.text)
|
||||
}
|
||||
},
|
||||
@@ -146,7 +167,7 @@ export default {
|
||||
return h('Tag', { props: { color: color } }, `${score}%`)
|
||||
}
|
||||
},
|
||||
{ title: '投递时间', key: 'applyTime', minWidth: 250 },
|
||||
{ title: '创建时间', key: 'create_time', minWidth: 250 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
@@ -270,6 +291,20 @@ export default {
|
||||
|
||||
h('h3', { style: { marginTop: '20px', marginBottom: '15px', color: '#2d8cf0' } }, '投递状态'),
|
||||
h('p', [h('strong', '投递状态: '), this.getApplyStatusText(row.applyStatus)]),
|
||||
row.applyStatus === 'failed' && row.errorMessage ? h('div', { style: { marginTop: '10px', marginBottom: '10px' } }, [
|
||||
h('strong', '失败原因: '),
|
||||
h('p', {
|
||||
style: {
|
||||
whiteSpace: 'pre-wrap',
|
||||
marginTop: '8px',
|
||||
padding: '10px',
|
||||
backgroundColor: '#fff1f0',
|
||||
borderRadius: '4px',
|
||||
border: '1px solid #ffccc7',
|
||||
color: '#cf1322'
|
||||
}
|
||||
}, row.errorMessage)
|
||||
]) : null,
|
||||
h('p', [h('strong', '反馈状态: '), this.getFeedbackStatusText(row.feedbackStatus)]),
|
||||
h('p', [h('strong', '投递时间: '), row.applyTime || '未知']),
|
||||
h('p', [h('strong', '匹配度: '), `${row.matchScore || 0}%`]),
|
||||
|
||||
@@ -126,7 +126,7 @@ export default {
|
||||
return h('Tag', { props: { color: status.color } }, status.text)
|
||||
}
|
||||
},
|
||||
{ title: '发布时间', key: 'publishTime', minWidth: 150 },
|
||||
{ title: '创建时间', key: 'create_time', minWidth: 220 },
|
||||
{
|
||||
title: '操作',
|
||||
key: 'action',
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
const path = require('path')
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin')
|
||||
const { VueLoaderPlugin } = require('vue-loader')
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
|
||||
|
||||
// 判断是否为生产环境
|
||||
const is_production = process.env.NODE_ENV === 'production' || process.argv.includes('--mode=production')
|
||||
|
||||
module.exports = {
|
||||
entry: './src/main.js',
|
||||
entry: {
|
||||
main: './src/main.js'
|
||||
},
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
filename: 'app.js',
|
||||
filename: 'js/[name].[contenthash:8].js',
|
||||
chunkFilename: 'js/[name].[contenthash:8].chunk.js',
|
||||
clean: true
|
||||
},
|
||||
module: {
|
||||
@@ -22,20 +29,60 @@ module.exports = {
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: ['vue-style-loader', 'css-loader']
|
||||
use: [
|
||||
is_production ? MiniCssExtractPlugin.loader : 'vue-style-loader',
|
||||
'css-loader'
|
||||
]
|
||||
},
|
||||
{
|
||||
test: /\.(png|jpe?g|gif|svg|woff2?|eot|ttf|otf)$/,
|
||||
type: 'asset/resource'
|
||||
test: /\.(png|jpe?g|gif|svg)$/,
|
||||
type: 'asset/resource',
|
||||
generator: {
|
||||
filename: 'images/[name].[hash:8][ext]'
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.(woff2?|eot|ttf|otf)$/,
|
||||
type: 'asset/resource',
|
||||
generator: {
|
||||
filename: 'fonts/[name].[hash:8][ext]'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
new VueLoaderPlugin(),
|
||||
// 主应用 HTML
|
||||
new HtmlWebpackPlugin({
|
||||
template: './public/index.html',
|
||||
title: 'Admin Framework Demo'
|
||||
})
|
||||
title: 'Admin Framework Demo',
|
||||
filename: 'index.html',
|
||||
chunks: ['main']
|
||||
}),
|
||||
// 注册页面 HTML(独立页面,不依赖 webpack)
|
||||
new HtmlWebpackPlugin({
|
||||
template: './public/register.html',
|
||||
title: '邀请注册',
|
||||
filename: 'register.html',
|
||||
inject: false, // 不注入 webpack 生成的脚本
|
||||
minify: is_production ? {
|
||||
removeComments: true,
|
||||
collapseWhitespace: true,
|
||||
removeRedundantAttributes: true,
|
||||
useShortDoctype: true,
|
||||
removeEmptyAttributes: true,
|
||||
removeStyleLinkTypeAttributes: true,
|
||||
keepClosingSlash: true,
|
||||
minifyJS: true,
|
||||
minifyCSS: true
|
||||
} : false
|
||||
}),
|
||||
...(is_production ? [
|
||||
new MiniCssExtractPlugin({
|
||||
filename: 'css/[name].[contenthash:8].css',
|
||||
chunkFilename: 'css/[name].[contenthash:8].chunk.css'
|
||||
})
|
||||
] : [])
|
||||
],
|
||||
resolve: {
|
||||
extensions: ['.js', '.vue', '.json'],
|
||||
@@ -52,6 +99,58 @@ module.exports = {
|
||||
client: {
|
||||
overlay: false // 禁用错误浮层
|
||||
}
|
||||
},
|
||||
optimization: {
|
||||
splitChunks: {
|
||||
chunks: 'all',
|
||||
cacheGroups: {
|
||||
// 分离 Vue 核心库
|
||||
vue: {
|
||||
name: 'vue',
|
||||
test: /[\\/]node_modules[\\/](vue|vue-router|vuex)[\\/]/,
|
||||
priority: 20,
|
||||
reuseExistingChunk: true
|
||||
},
|
||||
// 分离 UI 库
|
||||
ui: {
|
||||
name: 'ui',
|
||||
test: /[\\/]node_modules[\\/]view-design[\\/]/,
|
||||
priority: 15,
|
||||
reuseExistingChunk: true
|
||||
},
|
||||
// 分离图表库
|
||||
echarts: {
|
||||
name: 'echarts',
|
||||
test: /[\\/]node_modules[\\/]echarts[\\/]/,
|
||||
priority: 15,
|
||||
reuseExistingChunk: true
|
||||
},
|
||||
// 分离其他第三方库
|
||||
vendor: {
|
||||
name: 'vendor',
|
||||
test: /[\\/]node_modules[\\/]/,
|
||||
priority: 10,
|
||||
reuseExistingChunk: true
|
||||
},
|
||||
// 公共代码
|
||||
common: {
|
||||
name: 'common',
|
||||
minChunks: 2,
|
||||
priority: 5,
|
||||
reuseExistingChunk: true
|
||||
}
|
||||
}
|
||||
},
|
||||
runtimeChunk: {
|
||||
name: 'runtime'
|
||||
}
|
||||
},
|
||||
stats: {
|
||||
colors: true,
|
||||
modules: false,
|
||||
children: false,
|
||||
chunks: false,
|
||||
chunkModules: false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -104,10 +104,8 @@ module.exports = {
|
||||
});
|
||||
|
||||
return ctx.success({
|
||||
count: result.count,
|
||||
total: result.count,
|
||||
rows: result.rows,
|
||||
list: result.rows
|
||||
count: result.count
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -27,12 +27,10 @@ module.exports = {
|
||||
chat_records,
|
||||
op
|
||||
} = models;
|
||||
const deviceManager = require('../middleware/schedule/deviceManager');
|
||||
|
||||
// 设备统计(从 pla_account 和 deviceManager 获取)
|
||||
// 设备统计(直接从数据库获取)
|
||||
const totalDevices = await pla_account.count({ where: { is_delete: 0 } });
|
||||
const deviceStatus = deviceManager.getAllDevicesStatus();
|
||||
const onlineDevices = Object.values(deviceStatus).filter(d => d.isOnline).length;
|
||||
const onlineDevices = await pla_account.count({ where: { is_delete: 0, is_online: 1 } });
|
||||
const runningDevices = 0; // 不再维护运行状态
|
||||
|
||||
// 任务统计
|
||||
@@ -211,29 +209,25 @@ return ctx.success({
|
||||
'GET /dashboard/device-performance': async (ctx) => {
|
||||
const models = Framework.getModels();
|
||||
const { pla_account, task_status, job_postings, apply_records, chat_records } = models;
|
||||
const deviceManager = require('../middleware/schedule/deviceManager');
|
||||
|
||||
// 从 pla_account 获取所有账号
|
||||
const accounts = await pla_account.findAll({
|
||||
where: { is_delete: 0 },
|
||||
attributes: ['id', 'sn_code', 'name'],
|
||||
attributes: ['id', 'sn_code', 'name', 'is_online'],
|
||||
limit: 20
|
||||
});
|
||||
|
||||
// 获取设备在线状态
|
||||
const deviceStatus = deviceManager.getAllDevicesStatus();
|
||||
|
||||
// 为每个账号统计任务、岗位、投递、聊天数据
|
||||
const performanceData = await Promise.all(accounts.map(async (account) => {
|
||||
const snCode = account.sn_code;
|
||||
const status = deviceStatus[snCode] || { isOnline: false };
|
||||
|
||||
const isOnline = account.is_online === 1;
|
||||
|
||||
// 统计任务
|
||||
const [completedTasks, failedTasks] = await Promise.all([
|
||||
task_status.count({ where: { sn_code: snCode, status: 'completed' } }),
|
||||
task_status.count({ where: { sn_code: snCode, status: 'failed' } })
|
||||
]);
|
||||
|
||||
|
||||
// 统计岗位、投递、聊天(如果有相关字段)
|
||||
const [jobsSearched, applies, chats] = await Promise.all([
|
||||
job_postings.count({ where: { sn_code: snCode } }).catch(() => 0),
|
||||
@@ -253,7 +247,7 @@ return ctx.success({
|
||||
applies,
|
||||
chats,
|
||||
successRate,
|
||||
healthScore: status.isOnline ? 100 : 0,
|
||||
healthScore: isOnline ? 100 : 0,
|
||||
onlineDuration: 0 // 不再维护在线时长
|
||||
};
|
||||
}));
|
||||
|
||||
@@ -62,7 +62,6 @@ module.exports = {
|
||||
'POST /device/detail': async (ctx) => {
|
||||
const models = Framework.getModels();
|
||||
const { pla_account } = models;
|
||||
const deviceManager = require('../middleware/schedule/deviceManager');
|
||||
const body = ctx.getBody();
|
||||
const { deviceSn } = body;
|
||||
|
||||
@@ -70,7 +69,7 @@ module.exports = {
|
||||
return ctx.fail('设备SN码不能为空');
|
||||
}
|
||||
|
||||
// 从 pla_account 获取账号信息
|
||||
// 从 pla_account 获取账号信息(包含 is_online 和 is_logged_in)
|
||||
const account = await pla_account.findOne({
|
||||
where: { sn_code: deviceSn }
|
||||
});
|
||||
@@ -81,21 +80,17 @@ module.exports = {
|
||||
|
||||
const accountData = account.toJSON();
|
||||
|
||||
// 从 deviceManager 获取在线状态
|
||||
const deviceStatus = deviceManager.getAllDevicesStatus();
|
||||
const onlineStatus = deviceStatus[deviceSn] || { isOnline: false };
|
||||
|
||||
// 组合返回数据
|
||||
// 组合返回数据(直接从数据库读取 is_online 和 is_logged_in)
|
||||
const deviceData = {
|
||||
sn_code: accountData.sn_code,
|
||||
device_id: accountData.device_id,
|
||||
deviceName: accountData.name || accountData.sn_code,
|
||||
platform: accountData.platform_type,
|
||||
isOnline: onlineStatus.isOnline || false,
|
||||
is_online: onlineStatus.isOnline || false, // 前端使用的字段名
|
||||
is_logged_in: onlineStatus.isLoggedIn || false, // 从 deviceManager 内存中获取登录状态
|
||||
isOnline: accountData.is_online === 1,
|
||||
is_online: accountData.is_online === 1,
|
||||
is_logged_in: accountData.is_logged_in === 1,
|
||||
isRunning: false, // 不再维护运行状态
|
||||
lastHeartbeatTime: onlineStatus.lastHeartbeat ? new Date(onlineStatus.lastHeartbeat) : null,
|
||||
lastHeartbeatTime: null, // 不再从内存读取
|
||||
accountName: accountData.name,
|
||||
platform_type: accountData.platform_type,
|
||||
is_enabled: accountData.is_enabled
|
||||
@@ -107,10 +102,9 @@ module.exports = {
|
||||
'POST /device/list': async (ctx) => {
|
||||
const models = Framework.getModels();
|
||||
const { pla_account, op } = models;
|
||||
const deviceManager = require('../middleware/schedule/deviceManager');
|
||||
const body = ctx.getBody();
|
||||
const { isOnline, healthStatus, platform, searchText} = ctx.getBody();
|
||||
|
||||
|
||||
// 获取分页参数
|
||||
const { limit, offset } = ctx.getPageSize();
|
||||
|
||||
@@ -126,6 +120,11 @@ module.exports = {
|
||||
];
|
||||
}
|
||||
|
||||
// 在线状态筛选(直接查询数据库)
|
||||
if (isOnline !== undefined) {
|
||||
where.is_online = isOnline ? 1 : 0;
|
||||
}
|
||||
|
||||
const result = await pla_account.findAndCountAll({
|
||||
where,
|
||||
limit,
|
||||
@@ -133,35 +132,26 @@ module.exports = {
|
||||
order: [['id', 'DESC']]
|
||||
});
|
||||
|
||||
// 获取设备在线状态
|
||||
const deviceStatus = deviceManager.getAllDevicesStatus();
|
||||
|
||||
// 组合数据并过滤在线状态
|
||||
let list = result.rows.map(account => {
|
||||
// 组合数据(直接从数据库读取)
|
||||
const list = result.rows.map(account => {
|
||||
const accountData = account.toJSON();
|
||||
const status = deviceStatus[accountData.sn_code] || { isOnline: false };
|
||||
return {
|
||||
sn_code: accountData.sn_code,
|
||||
device_id: accountData.device_id,
|
||||
deviceName: accountData.name || accountData.sn_code,
|
||||
platform: accountData.platform_type,
|
||||
isOnline: status.isOnline || false,
|
||||
isOnline: accountData.is_online === 1,
|
||||
isRunning: false,
|
||||
lastHeartbeatTime: status.lastHeartbeat ? new Date(status.lastHeartbeat) : null,
|
||||
lastHeartbeatTime: null,
|
||||
accountName: accountData.name,
|
||||
platform_type: accountData.platform_type,
|
||||
is_enabled: accountData.is_enabled
|
||||
};
|
||||
});
|
||||
|
||||
// 如果指定了在线状态筛选
|
||||
if (isOnline !== undefined) {
|
||||
list = list.filter(item => item.isOnline === isOnline);
|
||||
}
|
||||
|
||||
return ctx.success({
|
||||
total: list.length,
|
||||
list: list.slice(0, limit)
|
||||
rows: list,
|
||||
count: result.count
|
||||
});
|
||||
},
|
||||
|
||||
@@ -179,30 +169,28 @@ module.exports = {
|
||||
'GET /device/overview': async (ctx) => {
|
||||
const models = Framework.getModels();
|
||||
const { pla_account } = models;
|
||||
const deviceManager = require('../middleware/schedule/deviceManager');
|
||||
|
||||
// 从 pla_account 获取账号总数
|
||||
// 从 pla_account 获取账号统计(直接查询数据库)
|
||||
const totalDevices = await pla_account.count({ where: { is_delete: 0 } });
|
||||
|
||||
// 从 deviceManager 获取在线设备统计
|
||||
const deviceStatus = deviceManager.getAllDevicesStatus();
|
||||
const onlineDevices = Object.values(deviceStatus).filter(d => d.isOnline).length;
|
||||
const onlineDevices = await pla_account.count({ where: { is_delete: 0, is_online: 1 } });
|
||||
const runningDevices = 0; // 不再维护运行状态
|
||||
const healthyDevices = onlineDevices; // 简化处理,在线即健康
|
||||
const warningDevices = 0;
|
||||
const errorDevices = 0;
|
||||
|
||||
// 获取最近离线的设备(从内存状态中获取)
|
||||
const offlineDevicesList = Object.entries(deviceStatus)
|
||||
.filter(([sn_code, status]) => !status.isOnline)
|
||||
.map(([sn_code, status]) => ({
|
||||
sn_code,
|
||||
deviceName: sn_code,
|
||||
lastOfflineTime: status.lastHeartbeat ? new Date(status.lastHeartbeat) : null,
|
||||
lastError: ''
|
||||
}))
|
||||
.sort((a, b) => (b.lastOfflineTime?.getTime() || 0) - (a.lastOfflineTime?.getTime() || 0))
|
||||
.slice(0, 5);
|
||||
// 获取最近离线的设备(从数据库查询)
|
||||
const offlineAccounts = await pla_account.findAll({
|
||||
where: { is_delete: 0, is_online: 0 },
|
||||
limit: 5,
|
||||
order: [['id', 'DESC']]
|
||||
});
|
||||
|
||||
const offlineDevicesList = offlineAccounts.map(account => ({
|
||||
sn_code: account.sn_code,
|
||||
deviceName: account.name || account.sn_code,
|
||||
lastOfflineTime: null,
|
||||
lastError: ''
|
||||
}));
|
||||
|
||||
return ctx.success({
|
||||
totalDevices,
|
||||
|
||||
@@ -1,340 +0,0 @@
|
||||
/**
|
||||
* 邀请注册管理控制器(后台管理)
|
||||
* 提供邀请注册相关的接口,不需要登录验证
|
||||
*/
|
||||
|
||||
const Framework = require("../../framework/node-core-framework.js");
|
||||
const dayjs = require('dayjs');
|
||||
|
||||
module.exports = {
|
||||
/**
|
||||
* @swagger
|
||||
* /admin_api/invite/register:
|
||||
* post:
|
||||
* summary: 邀请注册
|
||||
* description: 通过邀请码注册新用户,注册成功后给邀请人增加3天试用期
|
||||
* tags: [后台-邀请注册]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - phone
|
||||
* - password
|
||||
* - sms_code
|
||||
* - invite_code
|
||||
* properties:
|
||||
* phone:
|
||||
* type: string
|
||||
* description: 手机号
|
||||
* example: '13800138000'
|
||||
* password:
|
||||
* type: string
|
||||
* description: 密码
|
||||
* example: 'password123'
|
||||
* sms_code:
|
||||
* type: string
|
||||
* description: 短信验证码
|
||||
* example: '123456'
|
||||
* invite_code:
|
||||
* type: string
|
||||
* description: 邀请码
|
||||
* example: 'INV123_ABC123'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 注册成功
|
||||
*/
|
||||
'POST /invite/register': async (ctx) => {
|
||||
try {
|
||||
const body = ctx.getBody();
|
||||
const { phone, password, sms_code, invite_code } = body;
|
||||
|
||||
// 验证参数
|
||||
if (!phone || !password || !sms_code || !invite_code) {
|
||||
return ctx.fail('手机号、密码、短信验证码和邀请码不能为空');
|
||||
}
|
||||
|
||||
// 验证手机号格式
|
||||
const phoneRegex = /^1[3-9]\d{9}$/;
|
||||
if (!phoneRegex.test(phone)) {
|
||||
return ctx.fail('手机号格式不正确');
|
||||
}
|
||||
|
||||
// 验证密码长度
|
||||
if (password.length < 6) {
|
||||
return ctx.fail('密码长度不能少于6位');
|
||||
}
|
||||
|
||||
// 验证短信验证码(这里需要调用短信验证服务)
|
||||
const smsVerifyResult = await verifySmsCode(phone, sms_code);
|
||||
if (!smsVerifyResult.success) {
|
||||
return ctx.fail(smsVerifyResult.message || '短信验证码错误或已过期');
|
||||
}
|
||||
|
||||
const { pla_account } = await Framework.getModels();
|
||||
|
||||
// 检查手机号是否已注册
|
||||
const existingUser = await pla_account.findOne({
|
||||
where: { login_name: phone }
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return ctx.fail('该手机号已被注册');
|
||||
}
|
||||
|
||||
// 解析邀请码,获取邀请人ID
|
||||
// 邀请码格式:INV{user_id}_{timestamp}
|
||||
const inviteMatch = invite_code.match(/^INV(\d+)_/);
|
||||
if (!inviteMatch) {
|
||||
return ctx.fail('邀请码格式不正确');
|
||||
}
|
||||
|
||||
const inviter_id = parseInt(inviteMatch[1]);
|
||||
|
||||
// 验证邀请人是否存在
|
||||
const inviter = await pla_account.findOne({
|
||||
where: { id: inviter_id }
|
||||
});
|
||||
|
||||
if (!inviter) {
|
||||
return ctx.fail('邀请码无效,邀请人不存在');
|
||||
}
|
||||
|
||||
// 生成设备SN码(基于手机号和时间戳)
|
||||
const sn_code = `SN${Date.now()}${Math.random().toString(36).substr(2, 6).toUpperCase()}`;
|
||||
|
||||
// 创建新用户
|
||||
const newUser = await pla_account.create({
|
||||
name: phone, // 默认使用手机号作为名称
|
||||
sn_code: sn_code,
|
||||
device_id: '', // 设备ID由客户端登录时提供
|
||||
platform_type: 'boss', // 默认平台类型
|
||||
login_name: phone,
|
||||
pwd: password,
|
||||
keyword: '',
|
||||
is_enabled: 1,
|
||||
is_delete: 0,
|
||||
authorization_date: null,
|
||||
authorization_days: 0
|
||||
});
|
||||
|
||||
// 给邀请人增加3天试用期
|
||||
if (inviter) {
|
||||
const inviterData = inviter.toJSON();
|
||||
const currentAuthDate = inviterData.authorization_date;
|
||||
const currentAuthDays = inviterData.authorization_days || 0;
|
||||
|
||||
let newAuthDate = currentAuthDate;
|
||||
let newAuthDays = currentAuthDays + 3; // 增加3天
|
||||
|
||||
// 如果当前没有授权日期,则从今天开始
|
||||
if (!currentAuthDate) {
|
||||
newAuthDate = new Date();
|
||||
} else {
|
||||
// 如果当前授权已过期,从今天开始计算
|
||||
const currentEndDate = dayjs(currentAuthDate).add(currentAuthDays, 'day');
|
||||
const now = dayjs();
|
||||
if (currentEndDate.isBefore(now)) {
|
||||
newAuthDate = new Date();
|
||||
newAuthDays = 3; // 重新设置为3天
|
||||
}
|
||||
}
|
||||
|
||||
// 更新邀请人的授权信息
|
||||
await pla_account.update(
|
||||
{
|
||||
authorization_date: newAuthDate,
|
||||
authorization_days: newAuthDays
|
||||
},
|
||||
{ where: { id: inviter_id } }
|
||||
);
|
||||
|
||||
// 记录邀请记录
|
||||
const { invite_record } = await Framework.getModels();
|
||||
await invite_record.create({
|
||||
inviter_id: inviter_id,
|
||||
inviter_sn_code: inviter.sn_code,
|
||||
invitee_id: newUser.id,
|
||||
invitee_sn_code: newUser.sn_code,
|
||||
invitee_phone: phone,
|
||||
invite_code: invite_code,
|
||||
register_time: new Date(),
|
||||
reward_status: 1, // 已发放
|
||||
reward_type: 'trial_days',
|
||||
reward_value: 3,
|
||||
is_delete: 0
|
||||
});
|
||||
|
||||
console.log(`[邀请注册] 用户 ${phone} 通过邀请码 ${invite_code} 注册成功,邀请人 ${inviter.sn_code} 获得3天试用期`);
|
||||
}
|
||||
|
||||
return ctx.success({
|
||||
message: '注册成功',
|
||||
user: {
|
||||
id: newUser.id,
|
||||
sn_code: newUser.sn_code,
|
||||
login_name: newUser.login_name
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('邀请注册失败:', error);
|
||||
return ctx.fail('注册失败: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin_api/invite/send-sms:
|
||||
* post:
|
||||
* summary: 发送短信验证码
|
||||
* description: 发送短信验证码到指定手机号
|
||||
* tags: [后台-邀请注册]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - phone
|
||||
* properties:
|
||||
* phone:
|
||||
* type: string
|
||||
* description: 手机号
|
||||
* example: '13800138000'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 发送成功
|
||||
*/
|
||||
'POST /invite/send-sms': async (ctx) => {
|
||||
try {
|
||||
const body = ctx.getBody();
|
||||
const { phone } = body;
|
||||
|
||||
if (!phone) {
|
||||
return ctx.fail('手机号不能为空');
|
||||
}
|
||||
|
||||
// 验证手机号格式
|
||||
const phoneRegex = /^1[3-9]\d{9}$/;
|
||||
if (!phoneRegex.test(phone)) {
|
||||
return ctx.fail('手机号格式不正确');
|
||||
}
|
||||
|
||||
// 发送短信验证码
|
||||
const smsResult = await sendSmsCode(phone);
|
||||
if (!smsResult.success) {
|
||||
return ctx.fail(smsResult.message || '发送短信验证码失败');
|
||||
}
|
||||
|
||||
return ctx.success({
|
||||
message: '短信验证码已发送',
|
||||
expire_time: smsResult.expire_time || 300 // 默认5分钟过期
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('发送短信验证码失败:', error);
|
||||
return ctx.fail('发送短信验证码失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 发送短信验证码
|
||||
* @param {string} phone 手机号
|
||||
* @returns {Promise<{success: boolean, message?: string, expire_time?: number}>}
|
||||
*/
|
||||
async function sendSmsCode(phone) {
|
||||
try {
|
||||
// TODO: 实现真实的短信发送逻辑
|
||||
// 这里可以使用第三方短信服务(如阿里云、腾讯云等)
|
||||
|
||||
// 生成6位随机验证码
|
||||
const code = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
|
||||
// 将验证码存储到缓存中(可以使用Redis或内存缓存)
|
||||
// 格式:sms_code:{phone} = {code, expire_time}
|
||||
const expire_time = Date.now() + 5 * 60 * 1000; // 5分钟后过期
|
||||
|
||||
// 这里应该存储到缓存中,暂时使用全局变量(生产环境应使用Redis)
|
||||
if (!global.smsCodeCache) {
|
||||
global.smsCodeCache = {};
|
||||
}
|
||||
global.smsCodeCache[phone] = {
|
||||
code: code,
|
||||
expire_time: expire_time
|
||||
};
|
||||
|
||||
// TODO: 调用真实的短信发送接口
|
||||
console.log(`[短信验证] 发送验证码到 ${phone}: ${code} (5分钟内有效)`);
|
||||
|
||||
// 模拟发送成功
|
||||
return {
|
||||
success: true,
|
||||
expire_time: 300
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('发送短信验证码失败:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || '发送短信验证码失败'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证短信验证码
|
||||
* @param {string} phone 手机号
|
||||
* @param {string} code 验证码
|
||||
* @returns {Promise<{success: boolean, message?: string}>}
|
||||
*/
|
||||
async function verifySmsCode(phone, code) {
|
||||
try {
|
||||
if (!global.smsCodeCache) {
|
||||
return {
|
||||
success: false,
|
||||
message: '验证码不存在或已过期'
|
||||
};
|
||||
}
|
||||
|
||||
const cached = global.smsCodeCache[phone];
|
||||
if (!cached) {
|
||||
return {
|
||||
success: false,
|
||||
message: '验证码不存在或已过期'
|
||||
};
|
||||
}
|
||||
|
||||
// 检查是否过期
|
||||
if (Date.now() > cached.expire_time) {
|
||||
delete global.smsCodeCache[phone];
|
||||
return {
|
||||
success: false,
|
||||
message: '验证码已过期,请重新获取'
|
||||
};
|
||||
}
|
||||
|
||||
// 验证码是否正确
|
||||
if (cached.code !== code) {
|
||||
return {
|
||||
success: false,
|
||||
message: '验证码错误'
|
||||
};
|
||||
}
|
||||
|
||||
// 验证成功后删除缓存
|
||||
delete global.smsCodeCache[phone];
|
||||
|
||||
return {
|
||||
success: true
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('验证短信验证码失败:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || '验证失败'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -445,6 +445,35 @@ module.exports = {
|
||||
return ctx.success(commandDetail);
|
||||
},
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin_api/pla_account/retryCommand:
|
||||
* post:
|
||||
* summary: 重试指令
|
||||
* description: 重新执行失败的指令
|
||||
* tags: [后台-账号管理]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - commandId
|
||||
* properties:
|
||||
* commandId:
|
||||
* type: integer
|
||||
* description: 指令ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 重试成功
|
||||
*/
|
||||
'POST /pla_account/retryCommand': async (ctx) => {
|
||||
const body = ctx.getBody();
|
||||
const result = await plaAccountService.retryCommand(body);
|
||||
return ctx.success(result);
|
||||
},
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin_api/pla_account/parseLocation:
|
||||
@@ -557,17 +586,14 @@ module.exports = {
|
||||
const authDays = accountData.authorization_days || 0;
|
||||
|
||||
// 计算剩余天数
|
||||
let remaining_days = 0;
|
||||
let is_expired = false;
|
||||
const { calculateRemainingDays } = require('../utils/account_utils');
|
||||
const remaining_days = calculateRemainingDays(authDate, authDays);
|
||||
const is_expired = remaining_days <= 0;
|
||||
let end_date = null;
|
||||
|
||||
if (authDate && authDays > 0) {
|
||||
const startDate = dayjs(authDate);
|
||||
end_date = startDate.add(authDays, 'day').toDate();
|
||||
const now = dayjs();
|
||||
const remaining = dayjs(end_date).diff(now, 'day', true);
|
||||
remaining_days = Math.max(0, Math.ceil(remaining));
|
||||
is_expired = remaining_days <= 0;
|
||||
}
|
||||
|
||||
return ctx.success({
|
||||
@@ -646,10 +672,9 @@ module.exports = {
|
||||
});
|
||||
|
||||
// 计算更新后的剩余天数
|
||||
const { calculateRemainingDays } = require('../utils/account_utils');
|
||||
const remaining_days = calculateRemainingDays(authDate, authorization_days);
|
||||
const end_date = dayjs(authDate).add(authorization_days, 'day').toDate();
|
||||
const now = dayjs();
|
||||
const remaining = dayjs(end_date).diff(now, 'day', true);
|
||||
const remaining_days = Math.max(0, Math.ceil(remaining));
|
||||
|
||||
return ctx.success({
|
||||
message: '授权信息更新成功',
|
||||
@@ -661,6 +686,86 @@ module.exports = {
|
||||
end_date: end_date
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin_api/pla_account/parse-resume:
|
||||
* post:
|
||||
* summary: 解析账号在线简历
|
||||
* description: 获取指定账号的在线简历并进行AI分析,返回简历详情
|
||||
* tags: [后台-账号管理]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - id
|
||||
* properties:
|
||||
* id:
|
||||
* type: integer
|
||||
* description: 账号ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 解析成功,返回简历ID
|
||||
*/
|
||||
'POST /pla_account/parse-resume': async (ctx) => {
|
||||
const { id } = ctx.getBody();
|
||||
const models = await Framework.getModels();
|
||||
const { pla_account } = models;
|
||||
const mqttClient = require('../middleware/mqtt/mqttClient');
|
||||
const resumeManager = require('../middleware/job/resumeManager');
|
||||
|
||||
if (!id) {
|
||||
return ctx.fail('账号ID不能为空');
|
||||
}
|
||||
|
||||
// 获取账号信息
|
||||
const account = await pla_account.findOne({ where: { id } });
|
||||
|
||||
if (!account) {
|
||||
return ctx.fail('账号不存在');
|
||||
}
|
||||
|
||||
const { sn_code, platform_type } = account;
|
||||
|
||||
if (!sn_code) {
|
||||
return ctx.fail('该账号未绑定设备');
|
||||
}
|
||||
|
||||
try {
|
||||
// 调用简历管理器获取并保存简历
|
||||
const resumeData = await resumeManager.get_online_resume(sn_code, mqttClient, {
|
||||
platform: platform_type || 'boss'
|
||||
});
|
||||
|
||||
// 从返回数据中获取 resumeId
|
||||
// resumeManager 已经保存了简历到数据库,我们需要查询获取 resumeId
|
||||
const resume_info = models.resume_info;
|
||||
const savedResume = await resume_info.findOne({
|
||||
where: {
|
||||
sn_code,
|
||||
platform: platform_type || 'boss',
|
||||
isActive: true
|
||||
},
|
||||
order: [['syncTime', 'DESC']]
|
||||
});
|
||||
|
||||
if (!savedResume) {
|
||||
return ctx.fail('简历解析失败:未找到保存的简历记录');
|
||||
}
|
||||
|
||||
return ctx.success({
|
||||
message: '简历解析成功',
|
||||
resumeId: savedResume.resumeId,
|
||||
data: resumeData
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[解析简历] 失败:', error);
|
||||
return ctx.fail('解析简历失败: ' + (error.message || '未知错误'));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -69,8 +69,8 @@ const result = await resume_info.findAndCountAll({
|
||||
});
|
||||
|
||||
return ctx.success({
|
||||
total: result.count,
|
||||
list: result.rows
|
||||
rows: result.rows,
|
||||
count: result.count
|
||||
});
|
||||
|
||||
},
|
||||
@@ -153,24 +153,45 @@ return ctx.success({
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
*/
|
||||
'GET /resume/detail': async (ctx) => {
|
||||
'POST /resume/detail': async (ctx) => {
|
||||
const models = Framework.getModels();
|
||||
const { resume_info } = models;
|
||||
const { resumeId } = ctx.query;
|
||||
const { resumeId } = ctx.getBody();
|
||||
|
||||
if (!resumeId) {
|
||||
return ctx.fail('简历ID不能为空');
|
||||
}
|
||||
|
||||
|
||||
const resume = await resume_info.findOne({ where: { resumeId } });
|
||||
const resume = await resume_info.findOne({ where: { resumeId } });
|
||||
|
||||
if (!resume) {
|
||||
return ctx.fail('简历不存在');
|
||||
}
|
||||
if (!resume) {
|
||||
return ctx.fail('简历不存在');
|
||||
}
|
||||
|
||||
return ctx.success(resume);
|
||||
// 解析 JSON 字段
|
||||
const resumeDetail = resume.toJSON();
|
||||
const jsonFields = ['skills', 'certifications', 'projectExperience', 'workExperience', 'aiSkillTags'];
|
||||
|
||||
jsonFields.forEach(field => {
|
||||
if (resumeDetail[field]) {
|
||||
try {
|
||||
resumeDetail[field] = JSON.parse(resumeDetail[field]);
|
||||
} catch (e) {
|
||||
console.error(`解析字段 ${field} 失败:`, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 解析原始数据(如果存在)
|
||||
if (resumeDetail.originalData) {
|
||||
try {
|
||||
resumeDetail.originalData = JSON.parse(resumeDetail.originalData);
|
||||
} catch (e) {
|
||||
console.error('解析原始数据失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
return ctx.success(resumeDetail);
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -269,6 +290,74 @@ return ctx.success({ message: '简历删除成功' });
|
||||
});
|
||||
|
||||
return ctx.success(resumeDetail);
|
||||
},
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /admin_api/resume/analyze-with-ai:
|
||||
* post:
|
||||
* summary: AI 分析简历
|
||||
* description: 使用 AI 分析简历并更新 AI 字段
|
||||
* tags: [后台-简历管理]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - resumeId
|
||||
* properties:
|
||||
* resumeId:
|
||||
* type: string
|
||||
* description: 简历ID
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 分析成功
|
||||
*/
|
||||
'POST /resume/analyze-with-ai': async (ctx) => {
|
||||
const models = Framework.getModels();
|
||||
const { resume_info } = models;
|
||||
const { resumeId } = ctx.getBody();
|
||||
|
||||
if (!resumeId) {
|
||||
return ctx.fail('简历ID不能为空');
|
||||
}
|
||||
|
||||
const resume = await resume_info.findOne({ where: { resumeId } });
|
||||
|
||||
if (!resume) {
|
||||
return ctx.fail('简历不存在');
|
||||
}
|
||||
|
||||
try {
|
||||
const resumeManager = require('../middleware/job/resumeManager');
|
||||
const resumeData = resume.toJSON();
|
||||
|
||||
// 调用 AI 分析
|
||||
await resumeManager.analyze_resume_with_ai(resumeId, resumeData);
|
||||
|
||||
// 重新获取更新后的数据
|
||||
const updatedResume = await resume_info.findOne({ where: { resumeId } });
|
||||
const resumeDetail = updatedResume.toJSON();
|
||||
|
||||
// 解析 JSON 字段
|
||||
const jsonFields = ['skills', 'certifications', 'projectExperience', 'workExperience', 'aiSkillTags'];
|
||||
jsonFields.forEach(field => {
|
||||
if (resumeDetail[field]) {
|
||||
try {
|
||||
resumeDetail[field] = JSON.parse(resumeDetail[field]);
|
||||
} catch (e) {
|
||||
console.error(`解析字段 ${field} 失败:`, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return ctx.success(resumeDetail);
|
||||
} catch (error) {
|
||||
console.error('AI 分析失败:', error);
|
||||
return ctx.fail('AI 分析失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -166,6 +166,33 @@ module.exports = {
|
||||
console.error('获取价格套餐失败:', error);
|
||||
return ctx.fail('获取价格套餐失败: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/config/remote-code/version:
|
||||
* get:
|
||||
* summary: 获取远程代码版本
|
||||
* description: 获取远程代码版本
|
||||
* tags: [前端-系统配置]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 获取成功
|
||||
*/
|
||||
'GET /config/remote-code/version': async (ctx) => {
|
||||
try {
|
||||
|
||||
const remoteCode =
|
||||
{
|
||||
version: "1.0.9",
|
||||
download_url: "https://work.light120.com/app/dist-modules/remote.zip",
|
||||
info_url: "https://work.light120.com/app/dist-modules/remote-version.json",
|
||||
}
|
||||
return ctx.success(remoteCode);
|
||||
} catch (error) {
|
||||
console.error('获取远程代码失败:', error);
|
||||
return ctx.fail('获取远程代码失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
*/
|
||||
|
||||
const Framework = require("../../framework/node-core-framework.js");
|
||||
|
||||
const dayjs = require('dayjs');
|
||||
const email_service = require('../services/email_service.js');
|
||||
module.exports = {
|
||||
/**
|
||||
* @swagger
|
||||
@@ -52,7 +53,7 @@ module.exports = {
|
||||
|
||||
// 生成邀请码(基于用户ID和sn_code)
|
||||
const invite_code = `INV${user.id}_${Date.now().toString(36).toUpperCase()}`;
|
||||
const invite_link = `https://work.light120.com/invite?code=${invite_code}`;
|
||||
const invite_link = `https://work.light120.com/register.html?code=${invite_code}`;
|
||||
|
||||
// 保存邀请码到用户记录(可以保存到invite_code字段,如果没有则保存到备注或其他字段)
|
||||
// 这里暂时不保存到数据库,每次生成新的
|
||||
@@ -115,7 +116,7 @@ module.exports = {
|
||||
|
||||
// 查询邀请统计
|
||||
const { invite_record } = await Framework.getModels();
|
||||
|
||||
|
||||
// 查询总邀请数
|
||||
const totalInvites = await invite_record.count({
|
||||
where: {
|
||||
@@ -190,7 +191,7 @@ module.exports = {
|
||||
|
||||
// 生成新的邀请码
|
||||
const invite_code = `INV${user.id}_${Date.now().toString(36).toUpperCase()}`;
|
||||
const invite_link = `https://work.light120.com/invite?code=${invite_code}`;
|
||||
const invite_link = `https://work.light120.com/register.html?code=${invite_code}`;
|
||||
|
||||
return ctx.success({
|
||||
invite_code,
|
||||
@@ -294,6 +295,262 @@ module.exports = {
|
||||
console.error('获取邀请记录列表失败:', error);
|
||||
return ctx.fail('获取邀请记录列表失败: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/invite/register:
|
||||
* post:
|
||||
* summary: 邀请注册
|
||||
* description: 通过邀请码注册新用户,注册成功后给邀请人增加3天试用期
|
||||
* tags: [前端-推广邀请]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - email
|
||||
* - password
|
||||
* - email_code
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* description: 邮箱地址
|
||||
* example: 'user@example.com'
|
||||
* password:
|
||||
* type: string
|
||||
* description: 密码
|
||||
* example: 'password123'
|
||||
* email_code:
|
||||
* type: string
|
||||
* description: 验证码
|
||||
* example: '123456'
|
||||
* invite_code:
|
||||
* type: string
|
||||
* description: 邀请码(选填)
|
||||
* example: 'INV123_ABC123'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 注册成功
|
||||
*/
|
||||
'POST /invite/register': async (ctx) => {
|
||||
try {
|
||||
const body = ctx.getBody();
|
||||
const { email, password, email_code, invite_code } = body;
|
||||
const { hashPassword, maskEmail } = require('../utils/crypto_utils');
|
||||
|
||||
// 验证必填参数
|
||||
if (!email || !password || !email_code) {
|
||||
return ctx.fail('邮箱、密码和验证码不能为空');
|
||||
}
|
||||
|
||||
// 验证邮箱格式
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return ctx.fail('邮箱格式不正确');
|
||||
}
|
||||
|
||||
// 验证密码长度
|
||||
if (password.length < 6 || password.length > 50) {
|
||||
return ctx.fail('密码长度必须在6-50位之间');
|
||||
}
|
||||
|
||||
// 统一邮箱地址为小写
|
||||
const email_normalized = email.toLowerCase().trim();
|
||||
|
||||
// 验证验证码
|
||||
const emailVerifyResult = await email_service.verifyEmailCode(email_normalized, email_code);
|
||||
if (!emailVerifyResult.success) {
|
||||
return ctx.fail(emailVerifyResult.message || '验证码错误或已过期');
|
||||
}
|
||||
|
||||
const { pla_account } = await Framework.getModels();
|
||||
|
||||
// 检查邮箱是否已注册(使用统一的小写邮箱)
|
||||
const existingUser = await pla_account.findOne({
|
||||
where: { login_name: email_normalized }
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
return ctx.fail('该邮箱已被注册');
|
||||
}
|
||||
|
||||
// 验证邀请码(如果提供了邀请码)
|
||||
let inviter = null;
|
||||
let inviter_id = null;
|
||||
|
||||
if (invite_code) {
|
||||
// 解析邀请码,获取邀请人ID
|
||||
// 邀请码格式:INV{user_id}_{timestamp}
|
||||
const inviteMatch = invite_code.match(/^INV(\d+)_/);
|
||||
if (!inviteMatch) {
|
||||
return ctx.fail('邀请码格式不正确');
|
||||
}
|
||||
|
||||
inviter_id = parseInt(inviteMatch[1]);
|
||||
|
||||
// 验证邀请人是否存在
|
||||
inviter = await pla_account.findOne({
|
||||
where: { id: inviter_id }
|
||||
});
|
||||
|
||||
if (!inviter) {
|
||||
return ctx.fail('邀请码无效,邀请人不存在');
|
||||
}
|
||||
}
|
||||
|
||||
// 生成设备SN码(基于邮箱和时间戳)
|
||||
const sn_code = `SN${Date.now()}${Math.random().toString(36).substr(2, 6).toUpperCase()}`;
|
||||
|
||||
// 加密密码
|
||||
const hashedPassword = await hashPassword(password);
|
||||
|
||||
// 创建新用户(使用统一的小写邮箱和加密密码)
|
||||
// 新用户注册赠送3天试用期
|
||||
const newUser = await pla_account.create({
|
||||
name: email_normalized.split('@')[0], // 默认使用邮箱用户名作为名称
|
||||
sn_code: sn_code,
|
||||
device_id: '', // 设备ID由客户端登录时提供
|
||||
platform_type: 'boss', // 默认平台类型
|
||||
login_name: email_normalized,
|
||||
pwd: hashedPassword, // 使用加密后的密码
|
||||
keyword: '',
|
||||
is_enabled: 1,
|
||||
is_delete: 0,
|
||||
authorization_date: new Date(), // 新用户从注册日期开始
|
||||
authorization_days: 3 // 赠送3天试用期
|
||||
});
|
||||
|
||||
// 给邀请人增加3天试用期
|
||||
if (inviter) {
|
||||
const inviterData = inviter.toJSON();
|
||||
const currentAuthDate = inviterData.authorization_date;
|
||||
const currentAuthDays = inviterData.authorization_days || 0;
|
||||
|
||||
let newAuthDate;
|
||||
let newAuthDays;
|
||||
|
||||
// 如果当前没有授权日期,则从今天开始
|
||||
if (!currentAuthDate) {
|
||||
newAuthDate = new Date();
|
||||
newAuthDays = currentAuthDays + 3; // 累加3天
|
||||
} else {
|
||||
// 检查当前授权是否已过期
|
||||
const currentEndDate = dayjs(currentAuthDate).add(currentAuthDays, 'day');
|
||||
const now = dayjs();
|
||||
|
||||
if (currentEndDate.isBefore(now)) {
|
||||
// 授权已过期,重新激活并给予3天试用期
|
||||
newAuthDate = new Date();
|
||||
newAuthDays = 3; // 过期后重新激活给3天
|
||||
} else {
|
||||
// 授权未过期,在现有基础上累加3天
|
||||
newAuthDate = currentAuthDate;
|
||||
newAuthDays = currentAuthDays + 3; // 累加3天
|
||||
}
|
||||
}
|
||||
|
||||
// 更新邀请人的授权信息
|
||||
await pla_account.update(
|
||||
{
|
||||
authorization_date: newAuthDate,
|
||||
authorization_days: newAuthDays
|
||||
},
|
||||
{ where: { id: inviter_id } }
|
||||
);
|
||||
|
||||
// 记录邀请记录
|
||||
const { invite_record } = await Framework.getModels();
|
||||
await invite_record.create({
|
||||
inviter_id: inviter_id,
|
||||
inviter_sn_code: inviter.sn_code,
|
||||
invitee_id: newUser.id,
|
||||
invitee_sn_code: newUser.sn_code,
|
||||
invitee_phone: email, // 使用邮箱代替手机号
|
||||
invite_code: invite_code,
|
||||
register_time: new Date(),
|
||||
reward_status: 1, // 已发放
|
||||
reward_type: 'trial_days',
|
||||
reward_value: 3,
|
||||
is_delete: 0
|
||||
});
|
||||
|
||||
console.log(`[邀请注册] 用户 ${maskEmail(email_normalized)} 通过邀请码 ${invite_code} 注册成功,邀请人 ${inviter.sn_code} 获得3天试用期`);
|
||||
} else {
|
||||
console.log(`[邀请注册] 用户 ${maskEmail(email_normalized)} 注册成功(无邀请码)`);
|
||||
}
|
||||
|
||||
return ctx.success({
|
||||
message: '注册成功',
|
||||
user: {
|
||||
id: newUser.id,
|
||||
sn_code: newUser.sn_code,
|
||||
login_name: newUser.login_name
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('邀请注册失败:', error);
|
||||
return ctx.fail('注册失败: ' + error.message);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/invite/send_email_code:
|
||||
* post:
|
||||
* summary: 发送验证码
|
||||
* description: 发送验证码到指定邮箱地址
|
||||
* tags: [前端-推广邀请]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - email
|
||||
* properties:
|
||||
* email:
|
||||
* type: string
|
||||
* description: 邮箱地址
|
||||
* example: 'user@example.com'
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 发送成功
|
||||
*/
|
||||
'POST /invite/send_email_code': async (ctx) => {
|
||||
try {
|
||||
const body = ctx.getBody();
|
||||
const { email } = body;
|
||||
|
||||
if (!email) {
|
||||
return ctx.fail('邮箱地址不能为空');
|
||||
}
|
||||
|
||||
// 验证邮箱格式
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return ctx.fail('邮箱格式不正确');
|
||||
}
|
||||
|
||||
// 统一邮箱地址为小写
|
||||
const email_normalized = email.toLowerCase().trim();
|
||||
|
||||
// 发送验证码
|
||||
const emailResult = await email_service.sendEmailCode(email_normalized);
|
||||
if (!emailResult.success) {
|
||||
return ctx.fail(emailResult.message || '发送验证码失败');
|
||||
}
|
||||
|
||||
return ctx.success({
|
||||
message: '验证码已发送',
|
||||
expire_time: emailResult.expire_time || 300 // 默认5分钟过期
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('发送验证码失败:', error);
|
||||
return ctx.fail('发送验证码失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ module.exports = {
|
||||
* /api/user/login:
|
||||
* post:
|
||||
* summary: 用户登录
|
||||
* description: 通过手机号和密码登录,返回token、device_id和用户信息
|
||||
* description: 通过邮箱和密码登录,返回token、device_id和用户信息
|
||||
* tags: [前端-用户管理]
|
||||
* requestBody:
|
||||
* required: true
|
||||
@@ -15,13 +15,13 @@ module.exports = {
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - phone
|
||||
* - email
|
||||
* - password
|
||||
* properties:
|
||||
* phone:
|
||||
* email:
|
||||
* type: string
|
||||
* description: 手机号(登录名)
|
||||
* example: '13800138000'
|
||||
* description: 邮箱(登录名)
|
||||
* example: 'user@example.com'
|
||||
* password:
|
||||
* type: string
|
||||
* description: 密码
|
||||
@@ -75,94 +75,163 @@ module.exports = {
|
||||
* example: '用户不存在或密码错误'
|
||||
*/
|
||||
"POST /user/login": async (ctx) => {
|
||||
const { phone, password, device_id: client_device_id } = ctx.getBody();
|
||||
const { login_name:email, password, device_id: client_device_id } = ctx.getBody();
|
||||
const dayjs = require('dayjs');
|
||||
const { verifyPassword, validateDeviceId, maskEmail } = require('../utils/crypto_utils');
|
||||
|
||||
// 验证参数
|
||||
if (!phone || !password) {
|
||||
return ctx.fail('手机号和密码不能为空');
|
||||
// 参数验证
|
||||
if (!email || !password) {
|
||||
return ctx.fail('邮箱和密码不能为空');
|
||||
}
|
||||
|
||||
// 统一邮箱地址为小写
|
||||
const email_normalized = email.toLowerCase().trim();
|
||||
|
||||
// 验证邮箱格式
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email_normalized)) {
|
||||
return ctx.fail('邮箱格式不正确');
|
||||
}
|
||||
|
||||
// 验证密码长度
|
||||
if (password.length < 6 || password.length > 50) {
|
||||
return ctx.fail('密码长度不正确');
|
||||
}
|
||||
|
||||
const { pla_account } = await Framework.getModels();
|
||||
|
||||
// 根据手机号(login_name)和密码查找用户
|
||||
const user = await pla_account.findOne({
|
||||
where: {
|
||||
login_name: phone,
|
||||
pwd: password
|
||||
try {
|
||||
// 根据邮箱查找用户(使用统一的小写邮箱)
|
||||
const user = await pla_account.findOne({
|
||||
where: {
|
||||
login_name: email_normalized
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
// 防止用户枚举攻击:统一返回相同的错误信息
|
||||
return ctx.fail('邮箱或密码错误');
|
||||
}
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
return ctx.fail('手机号或密码错误');
|
||||
}
|
||||
// 验证密码(支持新旧格式)
|
||||
let isPasswordValid = false;
|
||||
|
||||
// 检查账号是否启用
|
||||
if (!user.is_enabled) {
|
||||
return ctx.fail('账号已被禁用');
|
||||
}
|
||||
if (user.pwd && user.pwd.includes('$')) {
|
||||
// 新格式:加密密码
|
||||
isPasswordValid = await verifyPassword(password, user.pwd);
|
||||
} else {
|
||||
// 旧格式:明文密码(临时兼容,建议尽快迁移)
|
||||
isPasswordValid = (user.pwd === password);
|
||||
|
||||
// 检查授权状态
|
||||
const userData = user.toJSON();
|
||||
const authDate = userData.authorization_date;
|
||||
const authDays = userData.authorization_days || 0;
|
||||
|
||||
if (authDate && authDays > 0) {
|
||||
const startDate = dayjs(authDate);
|
||||
const endDate = startDate.add(authDays, 'day');
|
||||
const now = dayjs();
|
||||
const remaining = endDate.diff(now, 'day', true);
|
||||
const remaining_days = Math.max(0, Math.ceil(remaining));
|
||||
|
||||
if (remaining_days <= 0) {
|
||||
return ctx.fail('账号授权已过期,请联系管理员续费');
|
||||
// 如果验证通过,自动升级为加密密码
|
||||
if (isPasswordValid) {
|
||||
const { hashPassword } = require('../utils/crypto_utils');
|
||||
const hashedPassword = await hashPassword(password);
|
||||
await pla_account.update(
|
||||
{ pwd: hashedPassword },
|
||||
{ where: { id: user.id } }
|
||||
);
|
||||
console.log(`[安全升级] 用户 ${maskEmail(email_normalized)} 的密码已自动加密`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPasswordValid) {
|
||||
// 记录失败尝试(用于后续添加防暴力破解功能)
|
||||
console.warn(`[登录失败] 邮箱: ${maskEmail(email_normalized)}, 时间: ${new Date().toISOString()}`);
|
||||
return ctx.fail('邮箱或密码错误');
|
||||
}
|
||||
|
||||
// 检查账号是否启用
|
||||
if (!user.is_enabled) {
|
||||
return ctx.fail('账号已被禁用,请联系管理员');
|
||||
}
|
||||
|
||||
// 检查授权状态(仅记录,不拒绝登录)
|
||||
const userData = user.toJSON();
|
||||
const authDate = userData.authorization_date;
|
||||
const authDays = userData.authorization_days || 0;
|
||||
|
||||
// 使用工具函数计算剩余天数
|
||||
const { calculateRemainingDays } = require('../utils/account_utils');
|
||||
const remaining_days = calculateRemainingDays(authDate, authDays);
|
||||
|
||||
// 处理设备ID
|
||||
let device_id = client_device_id;
|
||||
|
||||
// 验证客户端提供的 device_id 格式
|
||||
if (client_device_id) {
|
||||
// if (!validateDeviceId(client_device_id)) {
|
||||
// return ctx.fail('设备ID格式不正确,请重新登录');
|
||||
// }
|
||||
|
||||
// 如果与数据库不同,需要额外验证(防止设备ID劫持)
|
||||
if (user.device_id && client_device_id !== user.device_id) {
|
||||
// 记录设备更换
|
||||
console.warn(`[设备更换] 用户 ${maskEmail(email_normalized)} 更换设备: ${user.device_id} -> ${client_device_id}`);
|
||||
|
||||
// TODO: 这里可以添加更多安全检查,如:
|
||||
// - 发送验证码确认
|
||||
// - 记录到安全日志
|
||||
// - 限制更换频率
|
||||
}
|
||||
|
||||
// 更新设备ID
|
||||
if (client_device_id !== user.device_id) {
|
||||
await pla_account.update(
|
||||
{ device_id: client_device_id },
|
||||
{ where: { id: user.id } }
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// 使用数据库中的设备ID
|
||||
device_id = user.device_id;
|
||||
}
|
||||
|
||||
// 如果仍然没有设备ID,返回错误
|
||||
if (!device_id) {
|
||||
return ctx.fail('设备ID不能为空,请重新登录');
|
||||
}
|
||||
|
||||
// 创建token
|
||||
const token = Framework.getServices().tokenService.create({
|
||||
sn_code: user.sn_code,
|
||||
device_id: device_id,
|
||||
id: user.id
|
||||
});
|
||||
|
||||
// 构建安全的用户信息响应(白名单模式)
|
||||
const safeUserInfo = {
|
||||
id: user.id,
|
||||
sn_code: user.sn_code,
|
||||
login_name: maskEmail(user.login_name), // 脱敏处理
|
||||
is_enabled: user.is_enabled,
|
||||
authorization_date: user.authorization_date,
|
||||
authorization_days: user.authorization_days,
|
||||
platform_type: user.platform_type,
|
||||
remaining_days: remaining_days,
|
||||
auto_deliver: user.auto_deliver,
|
||||
deliver_config: user.deliver_config,
|
||||
created_at: user.create_time,
|
||||
updated_at: user.last_modify_time
|
||||
};
|
||||
|
||||
// 记录成功登录
|
||||
console.log(`[登录成功] 用户 ${maskEmail(email_normalized)}, 剩余天数: ${remaining_days}`);
|
||||
|
||||
return ctx.success({
|
||||
token,
|
||||
device_id,
|
||||
user: safeUserInfo
|
||||
});
|
||||
} catch (error) {
|
||||
// 记录详细错误但不暴露给客户端
|
||||
console.error('[登录异常]', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
email: maskEmail(email_normalized)
|
||||
});
|
||||
return ctx.fail('登录失败,请稍后重试');
|
||||
}
|
||||
|
||||
// 处理设备ID:优先使用客户端传递的 device_id,如果没有则使用数据库中的
|
||||
let device_id = client_device_id || user.device_id;
|
||||
|
||||
// 如果客户端提供了 device_id 且与数据库中的不同,则更新数据库
|
||||
if (client_device_id && client_device_id !== user.device_id) {
|
||||
await pla_account.update(
|
||||
{ device_id: client_device_id },
|
||||
{ where: { id: user.id } }
|
||||
);
|
||||
device_id = client_device_id;
|
||||
}
|
||||
|
||||
// 如果既没有客户端传递的,数据库中也为空,则返回错误(不应该发生,因为客户端会生成)
|
||||
if (!device_id) {
|
||||
return ctx.fail('设备ID不能为空,请重新登录');
|
||||
}
|
||||
|
||||
// 创建token
|
||||
const token = Framework.getServices().tokenService.create({
|
||||
sn_code: user.sn_code,
|
||||
device_id: device_id,
|
||||
id: user.id
|
||||
});
|
||||
|
||||
// 计算剩余天数
|
||||
let remaining_days = 0;
|
||||
if (authDate && authDays > 0) {
|
||||
const startDate = dayjs(authDate);
|
||||
const endDate = startDate.add(authDays, 'day');
|
||||
const now = dayjs();
|
||||
const remaining = endDate.diff(now, 'day', true);
|
||||
remaining_days = Math.max(0, Math.ceil(remaining));
|
||||
}
|
||||
|
||||
const userInfo = user.toJSON();
|
||||
userInfo.remaining_days = remaining_days;
|
||||
// 不返回密码
|
||||
delete userInfo.pwd;
|
||||
|
||||
return ctx.success({
|
||||
token,
|
||||
device_id,
|
||||
user: userInfo
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -208,10 +277,8 @@ module.exports = {
|
||||
*/
|
||||
'POST /user/delivery-config/get': async (ctx) => {
|
||||
try {
|
||||
console.log('[User Controller] 收到获取投递配置请求');
|
||||
const body = ctx.getBody();
|
||||
const { sn_code } = body;
|
||||
console.log('[User Controller] sn_code:', sn_code);
|
||||
|
||||
if (!sn_code) {
|
||||
return ctx.fail('请提供设备SN码');
|
||||
@@ -233,8 +300,11 @@ module.exports = {
|
||||
|
||||
return ctx.success({ deliver_config });
|
||||
} catch (error) {
|
||||
console.error('获取投递配置失败:', error);
|
||||
return ctx.fail('获取投递配置失败: ' + error.message);
|
||||
console.error('[获取投递配置失败]', {
|
||||
error: error.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
return ctx.fail('获取投递配置失败');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -301,10 +371,10 @@ module.exports = {
|
||||
*/
|
||||
'POST /user/delivery-config/save': async (ctx) => {
|
||||
try {
|
||||
console.log('[User Controller] 收到保存投递配置请求');
|
||||
const body = ctx.getBody();
|
||||
console.log('body', body);
|
||||
const { sn_code, deliver_config } = body;
|
||||
console.log('[User Controller] sn_code:', sn_code, 'deliver_config:', JSON.stringify(deliver_config));
|
||||
|
||||
|
||||
if (!sn_code) {
|
||||
return ctx.fail('请提供设备SN码');
|
||||
@@ -314,6 +384,11 @@ module.exports = {
|
||||
return ctx.fail('请提供 deliver_config 配置对象');
|
||||
}
|
||||
|
||||
// 验证 deliver_config 的基本结构
|
||||
if (typeof deliver_config !== 'object') {
|
||||
return ctx.fail('deliver_config 必须是对象');
|
||||
}
|
||||
|
||||
const { pla_account } = await Framework.getModels();
|
||||
|
||||
// 根据 sn_code 查找账号
|
||||
@@ -326,18 +401,64 @@ module.exports = {
|
||||
}
|
||||
|
||||
// 更新 pla_account 表的 deliver_config 和 auto_deliver 字段
|
||||
|
||||
// 获取原有配置
|
||||
const original_deliver_config = user.deliver_config || {};
|
||||
|
||||
// 深度合并配置(只覆盖传入的字段,保留原有的其他字段)
|
||||
const deepMerge = (target, source) => {
|
||||
const result = { ...target };
|
||||
Object.keys(source).forEach(key => {
|
||||
const sourceValue = source[key];
|
||||
const targetValue = target[key];
|
||||
|
||||
// 如果源值是对象且目标值也是对象,递归合并(排除数组)
|
||||
if (sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue) &&
|
||||
targetValue && typeof targetValue === 'object' && !Array.isArray(targetValue)) {
|
||||
result[key] = deepMerge(targetValue, sourceValue);
|
||||
} else {
|
||||
// 否则直接覆盖
|
||||
result[key] = sourceValue;
|
||||
}
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
const new_deliver_config = deepMerge(original_deliver_config, deliver_config);
|
||||
|
||||
// 处理 auto_deliver 字段(支持 auto_deliver 和 auto_delivery 两种字段名)
|
||||
let auto_deliver_value = 0;
|
||||
if (deliver_config.auto_deliver !== undefined) {
|
||||
auto_deliver_value = deliver_config.auto_deliver ? 1 : 0;
|
||||
} else if (deliver_config.auto_delivery !== undefined) {
|
||||
auto_deliver_value = deliver_config.auto_delivery ? 1 : 0;
|
||||
} else {
|
||||
// 如果没有传入 auto_deliver,保持原有值
|
||||
auto_deliver_value = user.auto_deliver || 0;
|
||||
}
|
||||
|
||||
await pla_account.update(
|
||||
{
|
||||
deliver_config: deliver_config,
|
||||
auto_deliver: deliver_config.auto_delivery ? 1 : 0
|
||||
deliver_config: new_deliver_config,
|
||||
auto_deliver: auto_deliver_value
|
||||
},
|
||||
{ where: { id: user.id } }
|
||||
);
|
||||
|
||||
console.log('[保存投递配置成功]', {
|
||||
sn_code,
|
||||
deliver_config:new_deliver_config,
|
||||
auto_deliver: auto_deliver_value,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
|
||||
return ctx.success({ message: '配置保存成功' });
|
||||
} catch (error) {
|
||||
console.error('保存投递配置失败:', error);
|
||||
return ctx.fail('保存投递配置失败: ' + error.message);
|
||||
console.error('[保存投递配置失败]', {
|
||||
error: error.message,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
return ctx.fail('保存投递配置失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,19 +3,21 @@ const config = require('../../../config/config');
|
||||
const logs = require('../logProxy');
|
||||
|
||||
/**
|
||||
* DeepSeek大模型服务
|
||||
* 集成DeepSeek API,提供智能化的岗位筛选、聊天生成、简历分析等功能
|
||||
* Qwen 2.5 大模型服务
|
||||
* 集成阿里云 DashScope API,提供智能化的岗位筛选、聊天生成、简历分析等功能
|
||||
*/
|
||||
class aiService {
|
||||
constructor() {
|
||||
this.apiKey = config.deepseekApiKey || process.env.DEEPSEEK_API_KEY;
|
||||
this.apiUrl = config.deepseekApiUrl || 'https://api.deepseek.com/v1/chat/completions';
|
||||
this.model = config.deepseekModel || 'deepseek-chat';
|
||||
this.apiKey = config.ai?.apiKey || process.env.DASHSCOPE_API_KEY;
|
||||
// 使用 DashScope 兼容 OpenAI 格式的接口
|
||||
this.apiUrl = 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions';
|
||||
// Qwen 2.5 模型:qwen-turbo(快速)、qwen-plus(增强)、qwen-max(最强)
|
||||
this.model = config.ai?.model || 'qwen-turbo';
|
||||
this.maxRetries = 3;
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用DeepSeek API
|
||||
* 调用 Qwen 2.5 API
|
||||
* @param {string} prompt - 提示词
|
||||
* @param {object} options - 配置选项
|
||||
* @returns {Promise<object>} API响应结果
|
||||
@@ -42,7 +44,7 @@ class aiService {
|
||||
try {
|
||||
const response = await axios.post(this.apiUrl, requestData, {
|
||||
headers: {
|
||||
'Authorization': `${this.apiKey}`,
|
||||
'Authorization': `Bearer ${this.apiKey}`, // DashScope 使用 Bearer token 格式
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
timeout: 30000
|
||||
@@ -53,7 +55,7 @@ class aiService {
|
||||
content: response.data.choices?.[0]?.message?.content || ''
|
||||
};
|
||||
} catch (error) {
|
||||
console.log(`DeepSeek API调用失败 (尝试 ${attempt}/${this.maxRetries}): ${error.message}`);
|
||||
console.log(`Qwen 2.5 API调用失败 (尝试 ${attempt}/${this.maxRetries}): ${error.message}`);
|
||||
|
||||
if (attempt === this.maxRetries) {
|
||||
throw new Error(error.message);
|
||||
@@ -178,34 +180,50 @@ class aiService {
|
||||
*/
|
||||
async analyzeResume(resumeText) {
|
||||
const prompt = `
|
||||
请分析以下简历内容,提取核心要素:
|
||||
请分析以下简历内容,并返回 JSON 格式的分析结果:
|
||||
|
||||
简历内容:
|
||||
${resumeText}
|
||||
|
||||
请提取以下信息:
|
||||
1. 技能标签(编程语言、框架、工具等)
|
||||
2. 工作经验(年限、行业、项目等)
|
||||
3. 教育背景(学历、专业、证书等)
|
||||
4. 期望薪资范围
|
||||
5. 期望工作地点
|
||||
6. 核心优势
|
||||
7. 职业发展方向
|
||||
请按以下格式返回 JSON 结果:
|
||||
{
|
||||
"skillTags": ["技能1", "技能2", "技能3"], // 技能标签数组(编程语言、框架、工具等)
|
||||
"strengths": "核心优势描述", // 简历的优势和亮点
|
||||
"weaknesses": "不足之处描述", // 简历的不足或需要改进的地方
|
||||
"careerSuggestion": "职业发展建议", // 针对该简历的职业发展方向和建议
|
||||
"competitiveness": 75 // 竞争力评分(0-100的整数),综合考虑工作年限、技能、经验等因素
|
||||
}
|
||||
|
||||
请以JSON格式返回结果。
|
||||
要求:
|
||||
1. skillTags 必须是字符串数组
|
||||
2. strengths、weaknesses、careerSuggestion 是字符串描述
|
||||
3. competitiveness 必须是 0-100 之间的整数
|
||||
4. 所有字段都必须返回,如果没有相关信息,使用空数组或空字符串
|
||||
`;
|
||||
|
||||
const result = await this.callAPI(prompt, {
|
||||
systemPrompt: '你是一个专业的简历分析师,擅长提取简历的核心要素和关键信息。',
|
||||
temperature: 0.2
|
||||
systemPrompt: '你是一个专业的简历分析师,擅长分析简历的核心要素、优势劣势、竞争力评分和职业发展建议。请以 JSON 格式返回分析结果,确保格式正确。',
|
||||
temperature: 0.3,
|
||||
maxTokens: 1500
|
||||
});
|
||||
|
||||
try {
|
||||
const analysis = JSON.parse(result.content);
|
||||
// 尝试从返回内容中提取 JSON
|
||||
let content = result.content.trim();
|
||||
|
||||
// 如果返回内容被代码块包裹,提取其中的 JSON
|
||||
const jsonMatch = content.match(/```(?:json)?\s*(\{[\s\S]*\})\s*```/) || content.match(/(\{[\s\S]*\})/);
|
||||
if (jsonMatch) {
|
||||
content = jsonMatch[1];
|
||||
}
|
||||
|
||||
const analysis = JSON.parse(content);
|
||||
return {
|
||||
analysis: analysis
|
||||
};
|
||||
} catch (parseError) {
|
||||
console.error(`[AI服务] 简历分析结果解析失败:`, parseError);
|
||||
console.error(`[AI服务] 原始内容:`, result.content);
|
||||
return {
|
||||
analysis: {
|
||||
content: result.content,
|
||||
|
||||
@@ -136,14 +136,51 @@ class JobFilterService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存匹配分析结果到数据库
|
||||
* @param {string|number} jobPostingId - 职位ID(job_postings表的主键ID)
|
||||
* @param {object} analysisResult - 分析结果
|
||||
* @returns {Promise<boolean>} 是否保存成功
|
||||
*/
|
||||
async saveMatchAnalysisToDatabase(jobPostingId, analysisResult) {
|
||||
try {
|
||||
const job_postings = db.getModel('job_postings');
|
||||
if (!job_postings) {
|
||||
console.warn('[职位过滤服务] job_postings 模型不存在,跳过保存');
|
||||
return false;
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
textMatchScore: analysisResult.overallScore || 0,
|
||||
isOutsourcing: analysisResult.isOutsourcing || false,
|
||||
matchSuggestion: analysisResult.suggestion || '',
|
||||
matchConcerns: JSON.stringify(analysisResult.concerns || []),
|
||||
textMatchAnalysis: JSON.stringify(analysisResult.analysis || analysisResult)
|
||||
};
|
||||
|
||||
await job_postings.update(updateData, {
|
||||
where: { id: jobPostingId }
|
||||
});
|
||||
|
||||
console.log(`[职位过滤服务] 已保存职位 ${jobPostingId} 的匹配分析结果,综合评分: ${analysisResult.overallScore}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[职位过滤服务] 保存匹配分析结果失败:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用文本匹配分析职位与简历的匹配度
|
||||
* @param {object} jobInfo - 职位信息
|
||||
* @param {object} resumeInfo - 简历信息(可选)
|
||||
* @param {number} jobTypeId - 职位类型ID(可选)
|
||||
* @param {object} options - 选项
|
||||
* @param {number} options.jobPostingId - 职位ID(job_postings表的主键ID),如果提供则自动保存到数据库
|
||||
* @param {boolean} options.autoSave - 是否自动保存到数据库(默认false)
|
||||
* @returns {Promise<object>} 匹配度分析结果
|
||||
*/
|
||||
async analyzeJobMatch(jobInfo, resumeInfo = {}, jobTypeId = null) {
|
||||
async analyzeJobMatch(jobInfo, resumeInfo = {}, jobTypeId = null, options = {}) {
|
||||
const jobText = this.buildJobText(jobInfo);
|
||||
const resumeText = this.buildResumeText(resumeInfo);
|
||||
|
||||
@@ -178,7 +215,7 @@ class JobFilterService {
|
||||
// 8. 投递建议
|
||||
const suggestion = this.getSuggestion(overallScore, isOutsourcing, concerns);
|
||||
|
||||
return {
|
||||
const result = {
|
||||
skillMatch: skillScore,
|
||||
experienceMatch: experienceScore,
|
||||
salaryMatch: salaryScore,
|
||||
@@ -198,6 +235,13 @@ class JobFilterService {
|
||||
suggestion: suggestion
|
||||
}
|
||||
};
|
||||
|
||||
// 如果提供了 jobPostingId 且 autoSave 为 true,自动保存到数据库
|
||||
if (options.jobPostingId && options.autoSave !== false) {
|
||||
await this.saveMatchAnalysisToDatabase(options.jobPostingId, result);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -453,21 +497,39 @@ class JobFilterService {
|
||||
* @returns {string} 投递建议
|
||||
*/
|
||||
getSuggestion(overallScore, isOutsourcing, concerns) {
|
||||
const concernsCount = concerns ? concerns.length : 0;
|
||||
|
||||
// 不推荐:外包 或 关注点过多(>2)
|
||||
if (isOutsourcing) {
|
||||
return '谨慎投递:可能是外包岗位';
|
||||
return '不推荐投递:外包岗位';
|
||||
}
|
||||
|
||||
if (concerns.length > 2) {
|
||||
if (concernsCount > 2) {
|
||||
return '不推荐投递:存在多个关注点';
|
||||
}
|
||||
|
||||
if (overallScore >= 80) {
|
||||
return '强烈推荐投递:匹配度很高';
|
||||
} else if (overallScore >= 60) {
|
||||
return '可以投递:匹配度中等';
|
||||
} else {
|
||||
return '谨慎投递:匹配度较低';
|
||||
// 不推荐:< 60 分
|
||||
if (overallScore < 60) {
|
||||
return '不推荐投递:匹配度较低';
|
||||
}
|
||||
|
||||
// 优先级1(必须投递):≥ 80 分,且无关注点,非外包
|
||||
if (overallScore >= 80 && concernsCount === 0) {
|
||||
return '必须投递:匹配度很高,无关注点';
|
||||
}
|
||||
|
||||
// 优先级2(可以投递):60-79 分,关注点 ≤ 2 个,非外包(优先处理无关注点情况)
|
||||
if (overallScore >= 60 && overallScore < 80 && concernsCount === 0) {
|
||||
return '可以投递:匹配度中等,无关注点';
|
||||
}
|
||||
|
||||
// 优先级3(谨慎考虑):60-79 分但有关注点,或 ≥ 80 分但有关注点
|
||||
if (overallScore >= 60 && concernsCount > 0 && concernsCount <= 2) {
|
||||
return '谨慎考虑:存在关注点或特殊情况';
|
||||
}
|
||||
|
||||
// 兜底情况
|
||||
return '谨慎考虑:需要进一步评估';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -599,15 +661,19 @@ class JobFilterService {
|
||||
* @param {object} filterRules - 过滤规则
|
||||
* @param {object} resumeInfo - 简历信息(可选)
|
||||
* @param {number} jobTypeId - 职位类型ID(可选)
|
||||
* @param {object} options - 选项
|
||||
* @param {boolean} options.autoSave - 是否自动保存评分结果到数据库(默认false)
|
||||
* @returns {Promise<Array>} 过滤后的职位列表(带匹配分数)
|
||||
*/
|
||||
async filterJobs(jobs, filterRules = {}, resumeInfo = {}, jobTypeId = null) {
|
||||
async filterJobs(jobs, filterRules = {}, resumeInfo = {}, jobTypeId = null, options = {}) {
|
||||
const {
|
||||
minScore = 60, // 最低匹配分数
|
||||
excludeOutsourcing = true, // 是否排除外包
|
||||
excludeKeywords = [] // 额外排除关键词
|
||||
} = filterRules;
|
||||
|
||||
const { autoSave = false } = options;
|
||||
|
||||
// 获取职位类型配置
|
||||
const { excludeKeywords: typeExcludeKeywords } = await this.getJobTypeConfig(jobTypeId);
|
||||
const allExcludeKeywords = [...typeExcludeKeywords, ...excludeKeywords];
|
||||
@@ -616,8 +682,13 @@ class JobFilterService {
|
||||
for (const job of jobs) {
|
||||
const jobData = job.toJSON ? job.toJSON() : job;
|
||||
|
||||
// 分析匹配度
|
||||
const analysis = await this.analyzeJobMatch(jobData, resumeInfo, jobTypeId);
|
||||
// 分析匹配度(如果 autoSave 为 true 且 job 有 id,则自动保存)
|
||||
const analysisOptions = autoSave && jobData.id ? {
|
||||
jobPostingId: jobData.id,
|
||||
autoSave: true
|
||||
} : {};
|
||||
|
||||
const analysis = await this.analyzeJobMatch(jobData, resumeInfo, jobTypeId, analysisOptions);
|
||||
|
||||
results.push({
|
||||
...jobData,
|
||||
@@ -662,11 +733,29 @@ class JobFilterService {
|
||||
const scores = {};
|
||||
let totalScore = 0;
|
||||
|
||||
// 解析权重配置
|
||||
// 解析权重配置,根据排序优先级规范化权重(确保总和为100)
|
||||
const weights = {};
|
||||
priorityWeights.forEach(item => {
|
||||
weights[item.key] = item.weight / 100; // 转换为小数
|
||||
});
|
||||
if (Array.isArray(priorityWeights) && priorityWeights.length > 0) {
|
||||
// 计算权重总和
|
||||
const totalWeight = priorityWeights.reduce((sum, item) => sum + (Number(item.weight) || 0), 0);
|
||||
|
||||
// 如果总和大于0,按比例规范化权重(总和归一化为1,即100%)
|
||||
if (totalWeight > 0) {
|
||||
priorityWeights.forEach(item => {
|
||||
weights[item.key] = (Number(item.weight) || 0) / totalWeight; // 规范化权重(0-1之间)
|
||||
});
|
||||
} else {
|
||||
// 如果权重总和为0或未设置,按照排序顺序分配权重(第一个优先级最高)
|
||||
// 使用等差数列递减:第一个权重最高,依次递减
|
||||
const n = priorityWeights.length;
|
||||
const totalSequentialWeight = (n * (n + 1)) / 2; // 1+2+...+n
|
||||
priorityWeights.forEach((item, index) => {
|
||||
// 按照排序顺序,第一个权重最高,依次递减(权重比例为 n, n-1, n-2, ..., 1)
|
||||
const sequentialWeight = n - index;
|
||||
weights[item.key] = sequentialWeight / totalSequentialWeight;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 1. 距离评分(基于经纬度)
|
||||
if (weights.distance) {
|
||||
|
||||
651
api/middleware/job/job_filter_service.md
Normal file
651
api/middleware/job/job_filter_service.md
Normal file
@@ -0,0 +1,651 @@
|
||||
# 职位过滤服务文档
|
||||
|
||||
## 概述
|
||||
|
||||
`job_filter_service.js` 是一个职位文本匹配过滤服务,使用简单的文本匹配规则来过滤和分析职位信息。该服务支持从数据库动态获取职位类型的技能关键词和排除关键词,能够分析职位与简历的匹配度,并提供过滤和评分功能。
|
||||
|
||||
## 主要功能
|
||||
|
||||
1. **职位类型配置管理**:从数据库获取或使用默认配置的技能关键词和排除关键词
|
||||
2. **匹配度分析**:分析职位与简历的匹配度(技能、经验、薪资)
|
||||
3. **职位过滤**:根据匹配分数、外包标识、排除关键词等条件过滤职位列表
|
||||
4. **自定义权重评分**:支持根据自定义权重配置计算职位评分(距离、薪资、工作年限、学历、技能)
|
||||
|
||||
## 类结构
|
||||
|
||||
```javascript
|
||||
class JobFilterService {
|
||||
// 默认技能关键词
|
||||
defaultCommonSkills: Array<string>
|
||||
|
||||
// 默认排除关键词
|
||||
defaultExcludeKeywords: Array<string>
|
||||
|
||||
// 职位类型配置缓存
|
||||
jobTypeCache: Map<number, Object>
|
||||
}
|
||||
```
|
||||
|
||||
## API 文档
|
||||
|
||||
### 1. getJobTypeConfig(jobTypeId)
|
||||
|
||||
根据职位类型ID获取技能关键词和排除关键词配置。
|
||||
|
||||
**参数:**
|
||||
- `jobTypeId` (number, 可选): 职位类型ID
|
||||
|
||||
**返回值:**
|
||||
```javascript
|
||||
{
|
||||
commonSkills: Array<string>, // 技能关键词列表
|
||||
excludeKeywords: Array<string> // 排除关键词列表
|
||||
}
|
||||
```
|
||||
|
||||
**说明:**
|
||||
- 如果未提供 `jobTypeId` 或配置获取失败,返回默认配置
|
||||
- 配置结果会缓存5分钟,避免频繁查询数据库
|
||||
- 从 `job_types` 表读取 `commonSkills` 和 `excludeKeywords` 字段(JSON格式)
|
||||
|
||||
**使用示例:**
|
||||
```javascript
|
||||
const config = await jobFilterService.getJobTypeConfig(1);
|
||||
console.log(config.commonSkills); // ['Vue', 'React', ...]
|
||||
console.log(config.excludeKeywords); // ['外包', '外派', ...]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. clearCache(jobTypeId)
|
||||
|
||||
清除职位类型配置缓存。
|
||||
|
||||
**参数:**
|
||||
- `jobTypeId` (number, 可选): 职位类型ID,不传则清除所有缓存
|
||||
|
||||
**使用示例:**
|
||||
```javascript
|
||||
// 清除特定职位类型的缓存
|
||||
jobFilterService.clearCache(1);
|
||||
|
||||
// 清除所有缓存
|
||||
jobFilterService.clearCache();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. analyzeJobMatch(jobInfo, resumeInfo, jobTypeId)
|
||||
|
||||
使用文本匹配分析职位与简历的匹配度。
|
||||
|
||||
**参数:**
|
||||
- `jobInfo` (object, 必需): 职位信息对象
|
||||
- `jobTitle` (string): 职位名称
|
||||
- `companyName` (string): 公司名称
|
||||
- `description` (string): 职位描述
|
||||
- `skills` (string): 技能要求
|
||||
- `requirements` (string): 职位要求
|
||||
- `salary` (string): 薪资范围
|
||||
- `experience` (string): 经验要求
|
||||
- `education` (string): 学历要求
|
||||
- `longitude` (number): 经度
|
||||
- `latitude` (number): 纬度
|
||||
- `resumeInfo` (object, 可选): 简历信息对象
|
||||
- `skills` (string|Array): 技能列表
|
||||
- `skillDescription` (string): 技能描述
|
||||
- `currentPosition` (string): 当前职位
|
||||
- `expectedPosition` (string): 期望职位
|
||||
- `workYears` (number|string): 工作年限
|
||||
- `expectedSalary` (string): 期望薪资
|
||||
- `education` (string): 学历
|
||||
- `jobTypeId` (number, 可选): 职位类型ID
|
||||
|
||||
**返回值:**
|
||||
```javascript
|
||||
{
|
||||
skillMatch: number, // 技能匹配度(0-100)
|
||||
experienceMatch: number, // 经验匹配度(0-100)
|
||||
salaryMatch: number, // 薪资匹配度(0-100)
|
||||
isOutsourcing: boolean, // 是否为外包岗位
|
||||
overallScore: number, // 综合推荐指数(0-100)
|
||||
matchReasons: Array<string>, // 匹配原因列表
|
||||
concerns: Array<string>, // 关注点列表
|
||||
suggestion: string, // 投递建议
|
||||
analysis: Object // 完整的分析结果(同上)
|
||||
}
|
||||
```
|
||||
|
||||
**评分规则:**
|
||||
|
||||
1. **综合推荐指数计算**:
|
||||
- 技能匹配度 × 40% + 经验匹配度 × 30% + 薪资匹配度 × 30%
|
||||
|
||||
2. **技能匹配度**:
|
||||
- 从职位描述中提取技能关键词
|
||||
- 计算简历中匹配的技能数量占比
|
||||
- 无简历信息时,基于职位关键词数量评分
|
||||
|
||||
3. **经验匹配度**:
|
||||
- 从职位描述中提取经验要求(应届、1年、2年、3年、5年、10年)
|
||||
- 根据简历工作年限与要求的匹配程度评分
|
||||
|
||||
4. **薪资匹配度**:
|
||||
- 解析职位薪资范围和期望薪资
|
||||
- 期望薪资低于职位薪资时得高分,高于职位薪资时得低分
|
||||
|
||||
5. **外包检测**:
|
||||
- 检测职位描述中是否包含:外包、外派、驻场、人力外包、项目外包
|
||||
|
||||
**使用示例:**
|
||||
```javascript
|
||||
const jobInfo = {
|
||||
jobTitle: '前端开发工程师',
|
||||
description: '要求3年以上Vue开发经验,熟悉React',
|
||||
salary: '15-25K',
|
||||
experience: '3-5年'
|
||||
};
|
||||
|
||||
const resumeInfo = {
|
||||
skills: 'Vue, React, JavaScript',
|
||||
workYears: 4,
|
||||
expectedSalary: '20K'
|
||||
};
|
||||
|
||||
const result = await jobFilterService.analyzeJobMatch(jobInfo, resumeInfo, 1);
|
||||
console.log(result.overallScore); // 85
|
||||
console.log(result.matchReasons); // ['技能匹配度高', '工作经验符合要求', ...]
|
||||
console.log(result.suggestion); // '强烈推荐投递:匹配度很高'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. filterJobs(jobs, filterRules, resumeInfo, jobTypeId)
|
||||
|
||||
过滤职位列表,返回匹配的职位(带匹配分数)。
|
||||
|
||||
**参数:**
|
||||
- `jobs` (Array, 必需): 职位列表(可以是 Sequelize 模型实例或普通对象)
|
||||
- `filterRules` (object, 可选): 过滤规则
|
||||
- `minScore` (number, 默认60): 最低匹配分数
|
||||
- `excludeOutsourcing` (boolean, 默认true): 是否排除外包岗位
|
||||
- `excludeKeywords` (Array<string>, 默认[]): 额外排除关键词列表
|
||||
- `resumeInfo` (object, 可选): 简历信息
|
||||
- `jobTypeId` (number, 可选): 职位类型ID
|
||||
|
||||
**返回值:**
|
||||
```javascript
|
||||
Array<{
|
||||
...jobData, // 原始职位数据
|
||||
matchScore: number, // 匹配分数
|
||||
matchAnalysis: Object // 完整的匹配分析结果
|
||||
}>
|
||||
```
|
||||
|
||||
**过滤逻辑:**
|
||||
1. 对每个职位进行匹配度分析
|
||||
2. 过滤掉匹配分数低于 `minScore` 的职位
|
||||
3. 如果 `excludeOutsourcing` 为 true,过滤掉外包岗位
|
||||
4. 过滤掉包含排除关键词的职位
|
||||
5. 按匹配分数降序排序
|
||||
|
||||
**使用示例:**
|
||||
```javascript
|
||||
const jobs = [
|
||||
{ jobTitle: '前端开发', description: 'Vue开发...', salary: '20K' },
|
||||
{ jobTitle: '后端开发', description: 'Java开发...', salary: '25K' }
|
||||
];
|
||||
|
||||
const resumeInfo = {
|
||||
skills: 'Vue, JavaScript',
|
||||
workYears: 3
|
||||
};
|
||||
|
||||
const filterRules = {
|
||||
minScore: 70,
|
||||
excludeOutsourcing: true,
|
||||
excludeKeywords: ['销售']
|
||||
};
|
||||
|
||||
const filteredJobs = await jobFilterService.filterJobs(
|
||||
jobs,
|
||||
filterRules,
|
||||
resumeInfo,
|
||||
1
|
||||
);
|
||||
|
||||
console.log(filteredJobs.length); // 过滤后的职位数量
|
||||
console.log(filteredJobs[0].matchScore); // 第一个职位的匹配分数
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. calculateJobScoreWithWeights(jobData, resumeInfo, accountConfig, jobTypeConfig, priorityWeights)
|
||||
|
||||
根据自定义权重配置计算职位评分。
|
||||
|
||||
**参数:**
|
||||
- `jobData` (object, 必需): 职位数据
|
||||
- `longitude` (number): 经度
|
||||
- `latitude` (number): 纬度
|
||||
- `salary` (string): 薪资范围
|
||||
- `experience` (string): 经验要求
|
||||
- `education` (string): 学历要求
|
||||
- `resumeInfo` (object, 必需): 简历信息
|
||||
- `expectedSalary` (string): 期望薪资
|
||||
- `workYears` (string|number): 工作年限
|
||||
- `education` (string): 学历
|
||||
- `skills` (string|Array): 技能列表
|
||||
- `accountConfig` (object, 必需): 账号配置
|
||||
- `user_longitude` (number): 用户经度
|
||||
- `user_latitude` (number): 用户纬度
|
||||
- `jobTypeConfig` (object, 可选): 职位类型配置(包含 commonSkills)
|
||||
- `priorityWeights` (Array, 必需): 权重配置
|
||||
```javascript
|
||||
[
|
||||
{ key: 'distance', weight: 30 }, // 距离权重(0-100)
|
||||
{ key: 'salary', weight: 40 }, // 薪资权重
|
||||
{ key: 'work_years', weight: 20 }, // 工作年限权重
|
||||
{ key: 'education', weight: 10 } // 学历权重
|
||||
]
|
||||
```
|
||||
|
||||
**返回值:**
|
||||
```javascript
|
||||
{
|
||||
totalScore: number, // 总分(0-100+,技能评分作为额外加分项)
|
||||
scores: {
|
||||
distance: number, // 距离评分
|
||||
salary: number, // 薪资评分
|
||||
work_years: number, // 工作年限评分
|
||||
education: number, // 学历评分
|
||||
skills: number // 技能评分(如果有 jobTypeConfig)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**评分规则:**
|
||||
|
||||
1. **距离评分**:
|
||||
- 0-5km: 100分
|
||||
- 5-10km: 90分
|
||||
- 10-20km: 80分
|
||||
- 20-50km: 60分
|
||||
- 50km以上: 30分
|
||||
|
||||
2. **薪资评分**:
|
||||
- 职位薪资 ≥ 期望薪资: 100分
|
||||
- 职位薪资 ≥ 期望薪资 × 0.8: 80分
|
||||
- 职位薪资 ≥ 期望薪资 × 0.6: 60分
|
||||
- 其他: 40分
|
||||
|
||||
3. **工作年限评分**:
|
||||
- 简历年限 ≥ 职位要求: 100分
|
||||
- 简历年限 ≥ 职位要求 × 0.8: 80分
|
||||
- 简历年限 ≥ 职位要求 × 0.6: 60分
|
||||
- 其他: 40分
|
||||
|
||||
4. **学历评分**:
|
||||
- 简历学历 ≥ 职位要求: 100分
|
||||
- 简历学历 = 职位要求 - 1级: 70分
|
||||
- 其他: 40分
|
||||
|
||||
5. **技能评分**:
|
||||
- 计算简历技能与职位类型配置的技能关键词匹配度
|
||||
- 作为额外加分项,固定权重10%
|
||||
|
||||
**使用示例:**
|
||||
```javascript
|
||||
const jobData = {
|
||||
longitude: 116.3974,
|
||||
latitude: 39.9093,
|
||||
salary: '20-30K',
|
||||
experience: '3-5年',
|
||||
education: '本科'
|
||||
};
|
||||
|
||||
const resumeInfo = {
|
||||
expectedSalary: '25K',
|
||||
workYears: 4,
|
||||
education: '本科',
|
||||
skills: ['Vue', 'React', 'JavaScript']
|
||||
};
|
||||
|
||||
const accountConfig = {
|
||||
user_longitude: 116.4074,
|
||||
user_latitude: 39.9042
|
||||
};
|
||||
|
||||
const jobTypeConfig = {
|
||||
commonSkills: ['Vue', 'React', 'JavaScript', 'Node.js']
|
||||
};
|
||||
|
||||
const priorityWeights = [
|
||||
{ key: 'distance', weight: 30 },
|
||||
{ key: 'salary', weight: 40 },
|
||||
{ key: 'work_years', weight: 20 },
|
||||
{ key: 'education', weight: 10 }
|
||||
];
|
||||
|
||||
const result = jobFilterService.calculateJobScoreWithWeights(
|
||||
jobData,
|
||||
resumeInfo,
|
||||
accountConfig,
|
||||
jobTypeConfig,
|
||||
priorityWeights
|
||||
);
|
||||
|
||||
console.log(result.totalScore); // 85
|
||||
console.log(result.scores); // { distance: 100, salary: 90, work_years: 100, ... }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. parseSalaryRange(salaryDesc)
|
||||
|
||||
解析薪资范围字符串。
|
||||
|
||||
**参数:**
|
||||
- `salaryDesc` (string): 薪资描述字符串
|
||||
- 支持格式:`15-25K`、`25K`、`5000-6000元/月`、`2-3万`、`20000-30000` 等
|
||||
|
||||
**返回值:**
|
||||
```javascript
|
||||
{
|
||||
min: number, // 最低薪资(元)
|
||||
max: number // 最高薪资(元)
|
||||
}
|
||||
```
|
||||
或 `null`(解析失败时)
|
||||
|
||||
**使用示例:**
|
||||
```javascript
|
||||
const range1 = jobFilterService.parseSalaryRange('15-25K');
|
||||
console.log(range1); // { min: 15000, max: 25000 }
|
||||
|
||||
const range2 = jobFilterService.parseSalaryRange('2-3万');
|
||||
console.log(range2); // { min: 20000, max: 30000 }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. parseExpectedSalary(expectedSalary)
|
||||
|
||||
解析期望薪资字符串,返回平均值。
|
||||
|
||||
**参数:**
|
||||
- `expectedSalary` (string): 期望薪资描述字符串
|
||||
|
||||
**返回值:**
|
||||
- `number`: 期望薪资数值(元),如果是范围则返回平均值
|
||||
- `null`: 解析失败时
|
||||
|
||||
**使用示例:**
|
||||
```javascript
|
||||
const salary1 = jobFilterService.parseExpectedSalary('20-30K');
|
||||
console.log(salary1); // 25000(平均值)
|
||||
|
||||
const salary2 = jobFilterService.parseExpectedSalary('25K');
|
||||
console.log(salary2); // 25000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. parseWorkYears(workYearsStr)
|
||||
|
||||
解析工作年限字符串为数字。
|
||||
|
||||
**参数:**
|
||||
- `workYearsStr` (string): 工作年限字符串(如 "3年"、"5年以上")
|
||||
|
||||
**返回值:**
|
||||
- `number`: 工作年限数字
|
||||
- `null`: 解析失败时
|
||||
|
||||
**使用示例:**
|
||||
```javascript
|
||||
const years = jobFilterService.parseWorkYears('3-5年');
|
||||
console.log(years); // 3(提取第一个数字)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 辅助方法
|
||||
|
||||
### buildJobText(jobInfo)
|
||||
|
||||
构建职位文本(用于匹配)。
|
||||
|
||||
**参数:**
|
||||
- `jobInfo` (object): 职位信息对象
|
||||
|
||||
**返回值:**
|
||||
- `string`: 合并后的职位文本(小写)
|
||||
|
||||
---
|
||||
|
||||
### buildResumeText(resumeInfo)
|
||||
|
||||
构建简历文本(用于匹配)。
|
||||
|
||||
**参数:**
|
||||
- `resumeInfo` (object): 简历信息对象
|
||||
|
||||
**返回值:**
|
||||
- `string`: 合并后的简历文本(小写)
|
||||
|
||||
---
|
||||
|
||||
### calculateSkillMatch(jobText, resumeText, commonSkills)
|
||||
|
||||
计算技能匹配度(0-100分)。
|
||||
|
||||
**参数:**
|
||||
- `jobText` (string): 职位文本
|
||||
- `resumeText` (string): 简历文本
|
||||
- `commonSkills` (Array<string>): 技能关键词列表
|
||||
|
||||
**返回值:**
|
||||
- `number`: 匹配度分数(0-100)
|
||||
|
||||
---
|
||||
|
||||
### calculateExperienceMatch(jobInfo, resumeInfo)
|
||||
|
||||
计算经验匹配度(0-100分)。
|
||||
|
||||
**参数:**
|
||||
- `jobInfo` (object): 职位信息
|
||||
- `resumeInfo` (object): 简历信息
|
||||
|
||||
**返回值:**
|
||||
- `number`: 匹配度分数(0-100)
|
||||
|
||||
---
|
||||
|
||||
### calculateSalaryMatch(jobInfo, resumeInfo)
|
||||
|
||||
计算薪资合理性(0-100分)。
|
||||
|
||||
**参数:**
|
||||
- `jobInfo` (object): 职位信息
|
||||
- `resumeInfo` (object): 简历信息
|
||||
|
||||
**返回值:**
|
||||
- `number`: 匹配度分数(0-100)
|
||||
|
||||
---
|
||||
|
||||
### checkOutsourcing(jobText)
|
||||
|
||||
检查是否为外包岗位。
|
||||
|
||||
**参数:**
|
||||
- `jobText` (string): 职位文本
|
||||
|
||||
**返回值:**
|
||||
- `boolean`: 是否为外包
|
||||
|
||||
---
|
||||
|
||||
## 默认配置
|
||||
|
||||
### 默认技能关键词
|
||||
|
||||
```javascript
|
||||
[
|
||||
'Vue', 'React', 'Angular', 'JavaScript', 'TypeScript', 'Node.js',
|
||||
'Python', 'Java', 'C#', '.NET', 'Flutter', 'React Native',
|
||||
'Webpack', 'Vite', 'Redux', 'MobX', 'Express', 'Koa',
|
||||
'Django', 'Flask', 'MySQL', 'MongoDB', 'Redis',
|
||||
'WebRTC', 'FFmpeg', 'Canvas', 'WebSocket', 'HTML5', 'CSS3',
|
||||
'jQuery', 'Bootstrap', 'Element UI', 'Ant Design',
|
||||
'Git', 'Docker', 'Kubernetes', 'AWS', 'Azure',
|
||||
'Selenium', 'Jest', 'Mocha', 'Cypress'
|
||||
]
|
||||
```
|
||||
|
||||
### 默认排除关键词
|
||||
|
||||
```javascript
|
||||
[
|
||||
'外包', '外派', '驻场', '销售', '客服', '电话销售',
|
||||
'地推', '推广', '市场', '运营', '行政', '文员'
|
||||
]
|
||||
```
|
||||
|
||||
## 数据库依赖
|
||||
|
||||
该服务依赖以下数据库表:
|
||||
|
||||
### job_types 表
|
||||
|
||||
存储职位类型配置,需要包含以下字段:
|
||||
- `id` (number): 职位类型ID
|
||||
- `is_enabled` (number): 是否启用(1=启用,0=禁用)
|
||||
- `commonSkills` (string|JSON): 技能关键词列表(JSON数组字符串)
|
||||
- `excludeKeywords` (string|JSON): 排除关键词列表(JSON数组字符串)
|
||||
|
||||
**示例数据:**
|
||||
```json
|
||||
{
|
||||
"id": 1,
|
||||
"is_enabled": 1,
|
||||
"commonSkills": "[\"Vue\", \"React\", \"JavaScript\"]",
|
||||
"excludeKeywords": "[\"外包\", \"外派\"]"
|
||||
}
|
||||
```
|
||||
|
||||
## 使用示例
|
||||
|
||||
### 完整使用流程
|
||||
|
||||
```javascript
|
||||
const jobFilterService = require('./job_filter_service');
|
||||
|
||||
// 1. 分析单个职位的匹配度
|
||||
const jobInfo = {
|
||||
jobTitle: '高级前端开发工程师',
|
||||
companyName: 'XX科技有限公司',
|
||||
description: '负责前端架构设计,要求5年以上Vue/React开发经验',
|
||||
skills: 'Vue, React, TypeScript, Node.js',
|
||||
requirements: '本科及以上学历,有大型项目经验',
|
||||
salary: '25-40K·14薪'
|
||||
};
|
||||
|
||||
const resumeInfo = {
|
||||
skills: 'Vue, React, JavaScript, TypeScript, Node.js, Webpack',
|
||||
skillDescription: '精通Vue生态,熟悉React,有5年+前端开发经验',
|
||||
currentPosition: '高级前端开发工程师',
|
||||
expectedPosition: '前端架构师',
|
||||
workYears: 6,
|
||||
expectedSalary: '30K',
|
||||
education: '本科'
|
||||
};
|
||||
|
||||
const analysis = await jobFilterService.analyzeJobMatch(
|
||||
jobInfo,
|
||||
resumeInfo,
|
||||
1 // 职位类型ID
|
||||
);
|
||||
|
||||
console.log('匹配分析结果:');
|
||||
console.log('综合评分:', analysis.overallScore);
|
||||
console.log('技能匹配度:', analysis.skillMatch);
|
||||
console.log('经验匹配度:', analysis.experienceMatch);
|
||||
console.log('薪资匹配度:', analysis.salaryMatch);
|
||||
console.log('是否外包:', analysis.isOutsourcing);
|
||||
console.log('匹配原因:', analysis.matchReasons);
|
||||
console.log('关注点:', analysis.concerns);
|
||||
console.log('投递建议:', analysis.suggestion);
|
||||
|
||||
// 2. 过滤职位列表
|
||||
const jobs = await Job.findAll({ where: { status: 1 } });
|
||||
|
||||
const filteredJobs = await jobFilterService.filterJobs(
|
||||
jobs,
|
||||
{
|
||||
minScore: 70,
|
||||
excludeOutsourcing: true,
|
||||
excludeKeywords: ['销售', '客服']
|
||||
},
|
||||
resumeInfo,
|
||||
1
|
||||
);
|
||||
|
||||
console.log(`共找到 ${filteredJobs.length} 个匹配的职位`);
|
||||
filteredJobs.forEach((job, index) => {
|
||||
console.log(`${index + 1}. ${job.jobTitle} - 匹配分数:${job.matchScore}`);
|
||||
});
|
||||
|
||||
// 3. 自定义权重评分
|
||||
const accountConfig = {
|
||||
user_longitude: 116.4074,
|
||||
user_latitude: 39.9042
|
||||
};
|
||||
|
||||
const priorityWeights = [
|
||||
{ key: 'distance', weight: 25 },
|
||||
{ key: 'salary', weight: 35 },
|
||||
{ key: 'work_years', weight: 25 },
|
||||
{ key: 'education', weight: 15 }
|
||||
];
|
||||
|
||||
const scoreResult = jobFilterService.calculateJobScoreWithWeights(
|
||||
jobInfo,
|
||||
resumeInfo,
|
||||
accountConfig,
|
||||
{ commonSkills: ['Vue', 'React', 'TypeScript'] },
|
||||
priorityWeights
|
||||
);
|
||||
|
||||
console.log('自定义权重评分:', scoreResult.totalScore);
|
||||
console.log('各项评分:', scoreResult.scores);
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **缓存机制**:职位类型配置会缓存5分钟,修改数据库配置后需要调用 `clearCache()` 清除缓存
|
||||
|
||||
2. **性能优化**:
|
||||
- 大量职位过滤时,建议分批处理
|
||||
- 避免在循环中频繁调用 `getJobTypeConfig()`,配置会被自动缓存
|
||||
|
||||
3. **文本匹配**:
|
||||
- 所有文本匹配均为小写匹配,不区分大小写
|
||||
- 匹配逻辑较为简单,如需更精确的匹配,建议使用 AI 分析(二期规划)
|
||||
|
||||
4. **薪资解析**:
|
||||
- 支持多种薪资格式,但可能无法解析所有格式
|
||||
- 解析失败时返回默认分数或 null
|
||||
|
||||
5. **错误处理**:
|
||||
- 所有方法都包含错误处理,失败时返回默认值或空结果
|
||||
- 建议在生产环境中监控日志输出
|
||||
|
||||
## 版本历史
|
||||
|
||||
- **v1.0.0**: 初始版本,支持基础文本匹配和过滤功能
|
||||
- 支持从数据库动态获取职位类型配置
|
||||
- 支持自定义权重评分计算
|
||||
|
||||
@@ -181,12 +181,103 @@ class ResumeManager {
|
||||
console.log(`[简历管理] 简历已创建 - ID: ${resumeId}`);
|
||||
}
|
||||
|
||||
// 二期规划:AI 分析暂时禁用,使用简单的文本匹配
|
||||
console.log(`[简历管理] AI分析已禁用(二期规划),使用文本匹配过滤`);
|
||||
// 调用 AI 分析简历并更新 AI 字段
|
||||
|
||||
try {
|
||||
await this.analyze_resume_with_ai(resumeId, resumeInfo);
|
||||
console.log(`[简历管理] AI 分析完成并已更新到数据库`);
|
||||
} catch (error) {
|
||||
console.error(`[简历管理] AI 分析失败:`, error);
|
||||
// AI 分析失败不影响主流程,继续返回成功
|
||||
}
|
||||
|
||||
return { resumeId, message: existingResume ? '简历更新成功' : '简历创建成功' };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 构建用于 AI 分析的简历文本
|
||||
* @param {object} resumeInfo - 简历信息对象
|
||||
* @returns {string} 简历文本内容
|
||||
*/
|
||||
build_resume_text_for_ai(resumeInfo) {
|
||||
const parts = [];
|
||||
|
||||
// 基本信息
|
||||
if (resumeInfo.fullName) parts.push(`姓名:${resumeInfo.fullName}`);
|
||||
if (resumeInfo.gender) parts.push(`性别:${resumeInfo.gender}`);
|
||||
if (resumeInfo.age) parts.push(`年龄:${resumeInfo.age}`);
|
||||
if (resumeInfo.location) parts.push(`所在地:${resumeInfo.location}`);
|
||||
|
||||
// 教育背景
|
||||
if (resumeInfo.education) parts.push(`学历:${resumeInfo.education}`);
|
||||
if (resumeInfo.school) parts.push(`毕业院校:${resumeInfo.school}`);
|
||||
if (resumeInfo.major) parts.push(`专业:${resumeInfo.major}`);
|
||||
if (resumeInfo.graduationYear) parts.push(`毕业年份:${resumeInfo.graduationYear}`);
|
||||
|
||||
// 工作经验
|
||||
if (resumeInfo.workYears) parts.push(`工作年限:${resumeInfo.workYears}`);
|
||||
if (resumeInfo.currentPosition) parts.push(`当前职位:${resumeInfo.currentPosition}`);
|
||||
if (resumeInfo.currentCompany) parts.push(`当前公司:${resumeInfo.currentCompany}`);
|
||||
|
||||
// 期望信息
|
||||
if (resumeInfo.expectedPosition) parts.push(`期望职位:${resumeInfo.expectedPosition}`);
|
||||
if (resumeInfo.expectedSalary) parts.push(`期望薪资:${resumeInfo.expectedSalary}`);
|
||||
if (resumeInfo.expectedLocation) parts.push(`期望地点:${resumeInfo.expectedLocation}`);
|
||||
|
||||
// 技能描述
|
||||
if (resumeInfo.skillDescription) parts.push(`技能描述:${resumeInfo.skillDescription}`);
|
||||
|
||||
// 工作经历
|
||||
if (resumeInfo.workExperience) {
|
||||
try {
|
||||
const workExp = JSON.parse(resumeInfo.workExperience);
|
||||
if (Array.isArray(workExp) && workExp.length > 0) {
|
||||
parts.push('\n工作经历:');
|
||||
workExp.forEach(work => {
|
||||
const workText = [
|
||||
work.company && `公司:${work.company}`,
|
||||
work.position && `职位:${work.position}`,
|
||||
work.startDate && work.endDate && `时间:${work.startDate} - ${work.endDate}`,
|
||||
work.content && `工作内容:${work.content}`
|
||||
].filter(Boolean).join(',');
|
||||
if (workText) parts.push(workText);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败,忽略
|
||||
}
|
||||
}
|
||||
|
||||
// 项目经验
|
||||
if (resumeInfo.projectExperience) {
|
||||
try {
|
||||
const projectExp = JSON.parse(resumeInfo.projectExperience);
|
||||
if (Array.isArray(projectExp) && projectExp.length > 0) {
|
||||
parts.push('\n项目经验:');
|
||||
projectExp.forEach(project => {
|
||||
const projectText = [
|
||||
project.name && `项目名称:${project.name}`,
|
||||
project.role && `角色:${project.role}`,
|
||||
project.description && `描述:${project.description}`
|
||||
].filter(Boolean).join(',');
|
||||
if (projectText) parts.push(projectText);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败,忽略
|
||||
}
|
||||
}
|
||||
|
||||
// 简历完整内容
|
||||
if (resumeInfo.resumeContent) {
|
||||
parts.push('\n简历详细内容:');
|
||||
parts.push(resumeInfo.resumeContent);
|
||||
}
|
||||
|
||||
return parts.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 从描述中提取技能标签
|
||||
* @param {string} description - 描述文本
|
||||
@@ -225,105 +316,168 @@ class ResumeManager {
|
||||
|
||||
const resume_info = db.getModel('resume_info');
|
||||
|
||||
// 解析 JSON 字段
|
||||
let skillsArray = [];
|
||||
let workExpArray = [];
|
||||
let projectExpArray = [];
|
||||
|
||||
try {
|
||||
if (resumeInfo.skills) {
|
||||
skillsArray = typeof resumeInfo.skills === 'string' ? JSON.parse(resumeInfo.skills) : resumeInfo.skills;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[简历管理] 解析技能字段失败:`, e);
|
||||
}
|
||||
|
||||
try {
|
||||
if (resumeInfo.workExperience) {
|
||||
workExpArray = typeof resumeInfo.workExperience === 'string' ? JSON.parse(resumeInfo.workExperience) : resumeInfo.workExperience;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[简历管理] 解析工作经历字段失败:`, e);
|
||||
}
|
||||
|
||||
try {
|
||||
if (resumeInfo.projectExperience) {
|
||||
projectExpArray = typeof resumeInfo.projectExperience === 'string' ? JSON.parse(resumeInfo.projectExperience) : resumeInfo.projectExperience;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[简历管理] 解析项目经历字段失败:`, e);
|
||||
}
|
||||
|
||||
// 构建工作经历文本
|
||||
const workExpText = workExpArray.map((work, index) => {
|
||||
return `${index + 1}. ${work.company || ''} - ${work.position || ''} (${work.startDate || ''} ~ ${work.endDate || '至今'})
|
||||
工作内容:${work.content || ''}
|
||||
行业:${work.industry || ''}`;
|
||||
}).join('\n\n');
|
||||
|
||||
// 构建项目经历文本
|
||||
const projectExpText = projectExpArray.map((project, index) => {
|
||||
return `${index + 1}. ${project.name || ''} - ${project.role || ''} (${project.startDate || ''} ~ ${project.endDate || '至今'})
|
||||
项目描述:${project.description || ''}
|
||||
项目成果:${project.performance || ''}`;
|
||||
}).join('\n\n');
|
||||
|
||||
// 构建分析提示词
|
||||
const prompt = `请分析以下简历,提供专业的评估:
|
||||
|
||||
姓名:${resumeInfo.fullName}
|
||||
工作年限:${resumeInfo.workYears}
|
||||
当前职位:${resumeInfo.currentPosition}
|
||||
期望职位:${resumeInfo.expectedPosition}
|
||||
期望薪资:${resumeInfo.expectedSalary}
|
||||
学历:${resumeInfo.education}
|
||||
技能:${resumeInfo.skills}
|
||||
【基本信息】
|
||||
姓名:${resumeInfo.fullName || ''}
|
||||
性别:${resumeInfo.gender || ''}
|
||||
年龄:${resumeInfo.age || ''}
|
||||
所在地:${resumeInfo.location || ''}
|
||||
工作年限:${resumeInfo.workYears || ''}
|
||||
|
||||
个人优势:
|
||||
${resumeInfo.skillDescription}
|
||||
【教育背景】
|
||||
学历:${resumeInfo.education || ''}
|
||||
专业:${resumeInfo.major || ''}
|
||||
毕业院校:${resumeInfo.school || ''}
|
||||
毕业年份:${resumeInfo.graduationYear || ''}
|
||||
|
||||
请从以下几个方面进行分析:
|
||||
1. 核心技能标签(提取5-10个关键技能)
|
||||
2. 优势分析(100字以内)
|
||||
3. 劣势分析(100字以内)
|
||||
4. 职业建议(150字以内)
|
||||
5. 竞争力评分(0-100分)`;
|
||||
【当前状态】
|
||||
当前职位:${resumeInfo.currentPosition || ''}
|
||||
当前公司:${resumeInfo.currentCompany || ''}
|
||||
当前薪资:${resumeInfo.currentSalary || ''}
|
||||
|
||||
try {
|
||||
// 调用AI服务进行分析
|
||||
const aiAnalysis = await aiService.analyzeResume(prompt);
|
||||
【求职期望】
|
||||
期望职位:${resumeInfo.expectedPosition || ''}
|
||||
期望薪资:${resumeInfo.expectedSalary || ''}
|
||||
期望地点:${resumeInfo.expectedLocation || ''}
|
||||
期望行业:${resumeInfo.expectedIndustry || ''}
|
||||
|
||||
// 解析AI返回的结果
|
||||
const analysis = this.parse_ai_analysis(aiAnalysis, resumeInfo);
|
||||
【技能标签】
|
||||
${skillsArray.length > 0 ? skillsArray.join('、') : '无'}
|
||||
|
||||
// 更新简历的AI分析字段
|
||||
await resume_info.update({
|
||||
aiSkillTags: JSON.stringify(analysis.skillTags),
|
||||
aiStrengths: analysis.strengths,
|
||||
aiWeaknesses: analysis.weaknesses,
|
||||
aiCareerSuggestion: analysis.careerSuggestion,
|
||||
aiCompetitiveness: analysis.competitiveness
|
||||
}, { where: { id: resumeId } });
|
||||
【个人优势描述】
|
||||
${resumeInfo.skillDescription || ''}
|
||||
|
||||
console.log(`[简历管理] AI分析完成 - 竞争力评分: ${analysis.competitiveness}`);
|
||||
【工作经历】
|
||||
${workExpText || '无'}
|
||||
|
||||
return analysis;
|
||||
} catch (error) {
|
||||
console.error(`[简历管理] AI分析失败:`, error, {
|
||||
resumeId: resumeId,
|
||||
fullName: resumeInfo.fullName
|
||||
});
|
||||
【项目经历】
|
||||
${projectExpText || '无'}
|
||||
|
||||
// 如果AI分析失败,使用基于规则的默认分析
|
||||
const defaultAnalysis = this.get_default_analysis(resumeInfo);
|
||||
请从以下几个方面进行专业分析,并返回 JSON 格式结果:
|
||||
1. skillTags: 核心技能标签数组(提取5-10个最关键的技术技能,如:Vue、React、Node.js等)
|
||||
2. strengths: 优势分析(100字以内,基于工作经历、项目经历、技能水平等综合评估)
|
||||
3. weaknesses: 劣势分析(100字以内,指出需要改进的地方或不足)
|
||||
4. careerSuggestion: 职业建议(150字以内,基于期望职位和当前能力给出职业发展建议)
|
||||
5. competitiveness: 竞争力评分(0-100的整数,综合考虑工作年限、技能深度、项目经验、学历等因素)
|
||||
|
||||
return defaultAnalysis;
|
||||
要求:
|
||||
- 竞争力评分要客观公正,基于实际能力评估
|
||||
- 优势分析要突出核心亮点和技术能力
|
||||
- 劣势分析要指出真实存在的问题
|
||||
- 职业建议要具有针对性和可操作性`;
|
||||
|
||||
// 调用AI服务进行分析
|
||||
const aiAnalysis = await aiService.analyzeResume(prompt);
|
||||
|
||||
// 解析AI返回的结果,如果AI没返回有效数据,直接抛出错误
|
||||
const analysis = this.parse_ai_analysis(aiAnalysis, resumeInfo);
|
||||
|
||||
if (!analysis) {
|
||||
throw new Error('AI分析结果为空,无法处理');
|
||||
}
|
||||
|
||||
// 确保所有字段都有值
|
||||
const updateData = {
|
||||
aiSkillTags: JSON.stringify(analysis.skillTags || []),
|
||||
aiStrengths: analysis.strengths || '',
|
||||
aiWeaknesses: analysis.weaknesses || '',
|
||||
aiCareerSuggestion: analysis.careerSuggestion || '',
|
||||
aiCompetitiveness: parseInt(analysis.competitiveness || 0, 10)
|
||||
};
|
||||
|
||||
// 确保竞争力评分在 0-100 范围内
|
||||
if (updateData.aiCompetitiveness < 0) updateData.aiCompetitiveness = 0;
|
||||
if (updateData.aiCompetitiveness > 100) updateData.aiCompetitiveness = 100;
|
||||
|
||||
// 更新简历的AI分析字段
|
||||
await resume_info.update(updateData, { where: { resumeId: resumeId } });
|
||||
|
||||
console.log(`[简历管理] AI分析完成 - 竞争力评分: ${updateData.aiCompetitiveness}, 技能标签: ${updateData.aiSkillTags}`);
|
||||
|
||||
return analysis;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析AI分析结果
|
||||
* @param {object} aiResponse - AI响应对象
|
||||
* @param {object} resumeInfo - 简历信息
|
||||
* @returns {object} 解析后的分析结果
|
||||
* @returns {object|null} 解析后的分析结果,如果AI没返回有效数据则返回null
|
||||
*/
|
||||
parse_ai_analysis(aiResponse, resumeInfo) {
|
||||
try {
|
||||
// 尝试从AI响应中解析JSON
|
||||
const content = aiResponse.content || aiResponse.analysis?.content || '';
|
||||
// aiService.analyzeResume 返回格式: { analysis: {...} } 或 { analysis: { content: "...", parseError: true } }
|
||||
const analysis = aiResponse?.analysis;
|
||||
|
||||
// 如果AI返回的是JSON格式
|
||||
if (content.includes('{') && content.includes('}')) {
|
||||
const jsonMatch = content.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
// 如果解析失败,analysis 会有 parseError 标记,直接返回 null
|
||||
if (!analysis || analysis.parseError) {
|
||||
console.warn(`[简历管理] AI分析结果解析失败或为空`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
skillTags: parsed.skillTags || parsed.技能标签 || [],
|
||||
strengths: parsed.strengths || parsed.优势 || parsed.优势分析 || '',
|
||||
weaknesses: parsed.weaknesses || parsed.劣势 || parsed.劣势分析 || '',
|
||||
careerSuggestion: parsed.careerSuggestion || parsed.职业建议 || '',
|
||||
competitiveness: parsed.competitiveness || parsed.竞争力评分 || 70
|
||||
};
|
||||
}
|
||||
// 如果解析成功,analysis 直接是解析后的对象
|
||||
if (typeof analysis === 'object' && !analysis.parseError) {
|
||||
// 检查是否有必要的字段
|
||||
if (analysis.competitiveness === undefined && analysis.竞争力评分 === undefined) {
|
||||
console.warn(`[简历管理] AI分析结果缺少竞争力评分字段`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// 如果无法解析JSON,尝试从文本中提取信息
|
||||
const skillTagsMatch = content.match(/技能标签[::](.*?)(?:\n|$)/);
|
||||
const strengthsMatch = content.match(/优势[分析]*[::](.*?)(?:\n|劣势)/s);
|
||||
const weaknessesMatch = content.match(/劣势[分析]*[::](.*?)(?:\n|职业)/s);
|
||||
const suggestionMatch = content.match(/职业建议[::](.*?)(?:\n|竞争力)/s);
|
||||
const scoreMatch = content.match(/竞争力评分[::](\d+)/);
|
||||
|
||||
return {
|
||||
skillTags: skillTagsMatch ? skillTagsMatch[1].split(/[,,、]/).map(s => s.trim()) : [],
|
||||
strengths: strengthsMatch ? strengthsMatch[1].trim() : '',
|
||||
weaknesses: weaknessesMatch ? weaknessesMatch[1].trim() : '',
|
||||
careerSuggestion: suggestionMatch ? suggestionMatch[1].trim() : '',
|
||||
competitiveness: scoreMatch ? parseInt(scoreMatch[1]) : 70
|
||||
skillTags: analysis.skillTags || analysis.技能标签 || [],
|
||||
strengths: analysis.strengths || analysis.优势 || analysis.优势分析 || '',
|
||||
weaknesses: analysis.weaknesses || analysis.劣势 || analysis.劣势分析 || '',
|
||||
careerSuggestion: analysis.careerSuggestion || analysis.职业建议 || '',
|
||||
competitiveness: parseInt(analysis.competitiveness || analysis.竞争力评分 || 0, 10)
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[简历管理] 解析AI分析结果失败:`, error);
|
||||
// 解析失败时使用默认分析
|
||||
return this.get_default_analysis(resumeInfo);
|
||||
}
|
||||
|
||||
// 如果格式不对,返回 null
|
||||
console.warn(`[简历管理] AI分析结果格式不正确`);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,7 +532,7 @@ ${resumeInfo.skillDescription}
|
||||
|
||||
// 二期规划:AI 分析暂时禁用,使用简单的文本匹配
|
||||
// const analysis = await aiService.analyzeResume(resumeText);
|
||||
|
||||
|
||||
// 使用简单的文本匹配提取技能
|
||||
const skills = this.extract_skills_from_desc(resumeData.skillDescription || resumeText);
|
||||
|
||||
@@ -505,7 +659,7 @@ ${resumeInfo.skillDescription}
|
||||
throw new Error('MQTT客户端未初始化');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 重新获取和分析
|
||||
const resumeData = await this.get_online_resume(sn_code, mqttClient);
|
||||
return await this.analyze_resume(sn_code, resumeData);
|
||||
|
||||
@@ -2,13 +2,16 @@ const mqtt = require('mqtt')
|
||||
const { v4: uuidv4 } = require('uuid'); // 顶部添加
|
||||
const Framework = require('../../../framework/node-core-framework');
|
||||
const logs = require('../logProxy');
|
||||
|
||||
// 获取logsService
|
||||
class MqttSyncClient {
|
||||
constructor(brokerUrl, options = {}) {
|
||||
this.client = mqtt.connect(brokerUrl, options)
|
||||
this.isConnected = false
|
||||
|
||||
this.messageListeners = []
|
||||
// 使用 Map 结构优化消息监听器,按 topic 分组
|
||||
this.messageListeners = new Map(); // Map<topic, Set<listener>>
|
||||
this.globalListeners = new Set(); // 全局监听器(监听所有 topic)
|
||||
|
||||
this.client.on('connect', () => {
|
||||
this.isConnected = true
|
||||
@@ -18,11 +21,42 @@ class MqttSyncClient {
|
||||
|
||||
this.client.on('message', (topic, message) => {
|
||||
|
||||
message = JSON.parse(message.toString())
|
||||
let messageObj;
|
||||
try {
|
||||
messageObj = JSON.parse(message.toString());
|
||||
} catch (error) {
|
||||
console.warn('[MQTT] 消息解析失败:', error.message);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('MQTT 收到消息', topic, message)
|
||||
// 记录日志但不包含敏感信息
|
||||
const { maskSensitiveData } = require('../../utils/crypto_utils');
|
||||
const safeMessage = maskSensitiveData(messageObj, ['password', 'pwd', 'token', 'secret', 'key', 'cookie']);
|
||||
console.log('[MQTT] 收到消息', topic, '类型:', messageObj.action || messageObj.type || 'unknown');
|
||||
|
||||
this.messageListeners.forEach(listener => listener(topic, message))
|
||||
// 优化:只通知相关 topic 的监听器,而不是所有监听器
|
||||
// 1. 触发该 topic 的专用监听器
|
||||
const topicListeners = this.messageListeners.get(topic);
|
||||
if (topicListeners && topicListeners.size > 0) {
|
||||
topicListeners.forEach(listener => {
|
||||
try {
|
||||
listener(topic, messageObj);
|
||||
} catch (error) {
|
||||
console.error('[MQTT] Topic监听器执行失败:', error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 2. 触发全局监听器
|
||||
if (this.globalListeners.size > 0) {
|
||||
this.globalListeners.forEach(listener => {
|
||||
try {
|
||||
listener(topic, messageObj);
|
||||
} catch (error) {
|
||||
console.error('[MQTT] 全局监听器执行失败:', error.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
@@ -139,12 +173,56 @@ class MqttSyncClient {
|
||||
});
|
||||
}
|
||||
|
||||
addMessageListener(fn) {
|
||||
this.messageListeners.push(fn)
|
||||
/**
|
||||
* 添加消息监听器
|
||||
* @param {Function} fn - 监听器函数
|
||||
* @param {string} topic - 可选,指定监听的 topic,不指定则监听所有
|
||||
*/
|
||||
addMessageListener(fn, topic = null) {
|
||||
if (typeof fn !== 'function') {
|
||||
throw new Error('监听器必须是函数');
|
||||
}
|
||||
|
||||
if (topic) {
|
||||
// 添加到特定 topic 的监听器
|
||||
if (!this.messageListeners.has(topic)) {
|
||||
this.messageListeners.set(topic, new Set());
|
||||
}
|
||||
this.messageListeners.get(topic).add(fn);
|
||||
} else {
|
||||
// 添加到全局监听器
|
||||
this.globalListeners.add(fn);
|
||||
}
|
||||
}
|
||||
|
||||
removeMessageListener(fn) {
|
||||
this.messageListeners = this.messageListeners.filter(f => f !== fn)
|
||||
/**
|
||||
* 移除消息监听器
|
||||
* @param {Function} fn - 监听器函数
|
||||
* @param {string} topic - 可选,指定从哪个 topic 移除
|
||||
*/
|
||||
removeMessageListener(fn, topic = null) {
|
||||
if (topic) {
|
||||
// 从特定 topic 移除
|
||||
const topicListeners = this.messageListeners.get(topic);
|
||||
if (topicListeners) {
|
||||
topicListeners.delete(fn);
|
||||
// 如果该 topic 没有监听器了,删除整个 Set
|
||||
if (topicListeners.size === 0) {
|
||||
this.messageListeners.delete(topic);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 从全局监听器移除
|
||||
this.globalListeners.delete(fn);
|
||||
|
||||
// 也尝试从所有 topic 中移除(兼容旧代码)
|
||||
for (const [topicKey, listeners] of this.messageListeners.entries()) {
|
||||
listeners.delete(fn);
|
||||
if (listeners.size === 0) {
|
||||
this.messageListeners.delete(topicKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -276,24 +276,27 @@ class MqttDispatcher {
|
||||
}
|
||||
}
|
||||
|
||||
// 移除 device_status 更新逻辑
|
||||
// 如果需要在 pla_account 表中添加在线状态字段,可以在这里更新
|
||||
console.log(`[MQTT心跳] 设备 ${sn_code} 心跳已接收 - 登录: ${updateData.isLoggedIn || false}`);
|
||||
|
||||
// if (device) {
|
||||
// await device_status.update(updateData, { where: { sn_code } });
|
||||
// console.log(`[MQTT心跳] 设备 ${sn_code} 状态已更新 - 在线: true, 登录: ${updateData.isLoggedIn}`);
|
||||
// } else {
|
||||
// logProxy.error('[MQTT心跳] 设备 ${sn_code} 不存在', { sn_code });
|
||||
// return;
|
||||
// }
|
||||
// 更新 pla_account 表中的在线和登录状态
|
||||
try {
|
||||
const pla_account = db.getModel('pla_account');
|
||||
await pla_account.update(
|
||||
{
|
||||
is_online: 1,
|
||||
is_logged_in: updateData.isLoggedIn ? 1 : 0
|
||||
},
|
||||
{ where: { sn_code } }
|
||||
);
|
||||
console.log(`[MQTT心跳] 设备 ${sn_code} 状态已更新到数据库 - 在线: true, 登录: ${updateData.isLoggedIn || false}`);
|
||||
} catch (error) {
|
||||
console.error(`[MQTT心跳] 更新数据库状态失败:`, error);
|
||||
}
|
||||
|
||||
// 记录心跳到设备管理器(包含登录状态)
|
||||
const heartbeatPayload = {
|
||||
isLoggedIn: updateData.isLoggedIn || false,
|
||||
...heartbeatData
|
||||
};
|
||||
console.log(`[MQTT心跳] 传递给 deviceManager 的数据:`, { sn_code, isLoggedIn: heartbeatPayload.isLoggedIn });
|
||||
|
||||
await deviceManager.recordHeartbeat(sn_code, heartbeatPayload);
|
||||
} catch (error) {
|
||||
console.error('[MQTT心跳] 处理心跳消息失败:', error);
|
||||
|
||||
22
api/middleware/redis_proxy.js
Normal file
22
api/middleware/redis_proxy.js
Normal file
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Redis 服务代理
|
||||
* 从 Framework 获取 redisService
|
||||
*/
|
||||
|
||||
const Framework = require("../../framework/node-core-framework.js");
|
||||
|
||||
module.exports = new Proxy({}, {
|
||||
get(_, prop) {
|
||||
const services = Framework.getServices();
|
||||
const redisService = services?.redisService;
|
||||
|
||||
if (!redisService) {
|
||||
throw new Error('Redis service not available. Framework may not be initialized.');
|
||||
}
|
||||
|
||||
return typeof redisService[prop] === 'function'
|
||||
? redisService[prop].bind(redisService)
|
||||
: redisService[prop];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,18 +6,7 @@ const dayjs = require('dayjs');
|
||||
*/
|
||||
class ScheduleConfig {
|
||||
constructor() {
|
||||
// 工作时间配置
|
||||
this.workHours = {
|
||||
start: 6,
|
||||
end: 23
|
||||
};
|
||||
|
||||
// 频率限制配置(毫秒)
|
||||
this.rateLimits = {
|
||||
search: 30 * 60 * 1000, // 搜索间隔:30分钟
|
||||
apply: 5 * 60 * 1000, // 投递间隔:5分钟
|
||||
chat: 1 * 60 * 1000, // 聊天间隔:1分钟
|
||||
};
|
||||
|
||||
|
||||
// 单日操作限制
|
||||
this.dailyLimits = {
|
||||
@@ -62,15 +51,6 @@ class ScheduleConfig {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查是否在工作时间
|
||||
* @returns {boolean}
|
||||
*/
|
||||
isWorkingHours() {
|
||||
const now = dayjs();
|
||||
const hour = now.hour();
|
||||
return hour >= this.workHours.start && hour < this.workHours.end;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务超时时间
|
||||
@@ -97,15 +77,6 @@ class ScheduleConfig {
|
||||
return priority;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取操作频率限制
|
||||
* @param {string} operation - 操作类型
|
||||
* @returns {number} 间隔时间(毫秒)
|
||||
*/
|
||||
getRateLimit(operation) {
|
||||
return this.rateLimits[operation] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取日限制
|
||||
* @param {string} operation - 操作类型
|
||||
|
||||
@@ -100,22 +100,8 @@ class DeviceManager {
|
||||
* 检查是否可以执行操作
|
||||
*/
|
||||
canExecuteOperation(sn_code, operation_type) {
|
||||
// 检查工作时间
|
||||
if (!config.isWorkingHours()) {
|
||||
return { allowed: false, reason: '不在工作时间内' };
|
||||
}
|
||||
|
||||
// 检查频率限制
|
||||
// 检查日限制(频率限制已由各任务使用账号配置中的间隔时间,不再使用全局配置)
|
||||
const device = this.devices.get(sn_code);
|
||||
if (device) {
|
||||
const lastTime = device[`last${operation_type.charAt(0).toUpperCase() + operation_type.slice(1)}`] || 0;
|
||||
const interval = config.getRateLimit(operation_type);
|
||||
if (Date.now() - lastTime < interval) {
|
||||
return { allowed: false, reason: '操作过于频繁' };
|
||||
}
|
||||
}
|
||||
|
||||
// 检查日限制
|
||||
if (device && device.dailyCounts) {
|
||||
const today = utils.getTodayString();
|
||||
if (device.dailyCounts.date !== today) {
|
||||
|
||||
@@ -93,7 +93,7 @@ class DeviceWorkStatusNotifier {
|
||||
commandType: cmd.command_type || cmd.type || '',
|
||||
commandParams: cmd.command_params || cmd.params || {}
|
||||
};
|
||||
}
|
||||
}
|
||||
// 如果有当前执行的任务,显示任务状态
|
||||
else if (taskStatusSummary.currentTask) {
|
||||
const task = taskStatusSummary.currentTask;
|
||||
@@ -109,6 +109,11 @@ class DeviceWorkStatusNotifier {
|
||||
};
|
||||
}
|
||||
|
||||
// 如果有等待消息(投递间隔等待等),添加到工作状态
|
||||
if (options.waitingMessage) {
|
||||
workStatus.waitingMessage = options.waitingMessage;
|
||||
}
|
||||
|
||||
// 格式化显示文案(服务端统一处理,客户端直接显示)
|
||||
workStatus.displayText = this._formatDisplayText(workStatus);
|
||||
|
||||
@@ -141,24 +146,16 @@ class DeviceWorkStatusNotifier {
|
||||
// 通过MQTT发布设备工作状态
|
||||
// 主题格式: device_work_status_{sn_code}
|
||||
const topic = `device_work_status_${sn_code}`;
|
||||
const message = JSON.stringify({
|
||||
const messagePayload = {
|
||||
action: 'device_work_status',
|
||||
data: workStatus,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
};
|
||||
const message = JSON.stringify(messagePayload);
|
||||
|
||||
await mqttClient.publish(topic, message);
|
||||
|
||||
// 输出详细日志
|
||||
if (workStatus.currentActivity) {
|
||||
const activity = workStatus.currentActivity;
|
||||
const activityInfo = activity.type === 'command'
|
||||
? `指令[${activity.name}]`
|
||||
: `任务[${activity.name}]`;
|
||||
console.log(`[设备工作状态] 已推送到 ${sn_code}: ${activityInfo} - ${workStatus.displayText}`);
|
||||
} else {
|
||||
console.log(`[设备工作状态] 已推送到 ${sn_code}: ${workStatus.displayText}`);
|
||||
}
|
||||
|
||||
|
||||
} catch (error) {
|
||||
// 通知失败不影响任务执行,只记录日志
|
||||
console.warn(`[设备工作状态] 推送失败:`, error.message);
|
||||
@@ -235,37 +232,52 @@ class DeviceWorkStatusNotifier {
|
||||
*/
|
||||
_formatDisplayText(workStatus) {
|
||||
const parts = [];
|
||||
|
||||
|
||||
// 当前活动
|
||||
if (workStatus.currentActivity) {
|
||||
const activity = workStatus.currentActivity;
|
||||
const typeText = activity.type === 'command' ? '指令' : '任务';
|
||||
const statusText = activity.status === 'running' ? '执行中' :
|
||||
activity.status === 'completed' ? '已完成' :
|
||||
const statusText = activity.status === 'running' ? '执行中' :
|
||||
activity.status === 'completed' ? '已完成' :
|
||||
activity.status === 'failed' ? '失败' : '未知';
|
||||
|
||||
// 构建详细描述:包含指令/任务名称和描述
|
||||
let activityDesc = activity.description || activity.name;
|
||||
|
||||
// 对于指令,显示指令类型和名称的详细信息
|
||||
if (activity.type === 'command') {
|
||||
const cmdType = activity.commandType || '';
|
||||
if (cmdType && cmdType !== activity.name) {
|
||||
activityDesc = `${activityDesc} [${cmdType}]`;
|
||||
|
||||
// 使用 description 或 name,避免重复显示
|
||||
// 如果 description 存在且不等于 name,优先使用 description
|
||||
// 否则,使用 name 并可能添加 commandType
|
||||
let activityDesc = '';
|
||||
|
||||
if (activity.description && activity.description !== activity.name) {
|
||||
// 有独立的 description,直接使用,不再添加 commandType
|
||||
activityDesc = activity.description;
|
||||
} else {
|
||||
// 使用 name
|
||||
activityDesc = activity.name;
|
||||
|
||||
// 对于指令,如果 commandType 存在且与 name 不同,添加详细信息
|
||||
if (activity.type === 'command') {
|
||||
const cmdType = activity.commandType || '';
|
||||
if (cmdType && cmdType !== activity.name && !activity.name.includes(cmdType)) {
|
||||
activityDesc = `${activityDesc} [${cmdType}]`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
parts.push(`${typeText}: ${activityDesc} (${statusText}${activity.progress > 0 ? `, 进度: ${activity.progress}%` : ''})`);
|
||||
} else {
|
||||
parts.push('当前活动: 无');
|
||||
}
|
||||
|
||||
|
||||
// 如果有等待消息,添加到显示文本
|
||||
if (workStatus.waitingMessage) {
|
||||
parts.push(workStatus.waitingMessage.message);
|
||||
}
|
||||
|
||||
// 待执行数量
|
||||
parts.push(`待执行: ${workStatus.pendingQueue.totalCount}个`);
|
||||
|
||||
|
||||
// 下次执行时间
|
||||
parts.push(`下次执行: ${workStatus.pendingQueue.nextExecuteTimeText || '暂无'}`);
|
||||
|
||||
|
||||
return parts.join(' | ');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,8 +54,8 @@ class ScheduleManager {
|
||||
console.log('[调度管理器] 心跳监听已启动');
|
||||
|
||||
// 5. 启动定时任务
|
||||
this.scheduledJobs.start();
|
||||
console.log('[调度管理器] 定时任务已启动');
|
||||
// this.scheduledJobs.start();
|
||||
// console.log('[调度管理器] 定时任务已启动');
|
||||
|
||||
this.isInitialized = true;
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ const config = require('./config.js');
|
||||
const deviceManager = require('./deviceManager.js');
|
||||
const command = require('./command.js');
|
||||
const db = require('../dbProxy');
|
||||
const authorizationService = require('../../services/authorization_service.js');
|
||||
|
||||
const Framework = require("../../../framework/node-core-framework.js");
|
||||
/**
|
||||
* 检查当前时间是否在指定的时间范围内
|
||||
@@ -26,8 +28,8 @@ function checkTimeRange(timeRange) {
|
||||
const startTime = startHour * 60 + startMinute;
|
||||
const endTime = endHour * 60 + endMinute;
|
||||
|
||||
// 检查是否仅工作日
|
||||
if (timeRange.workdays_only === 1) {
|
||||
// 检查是否仅工作日(使用宽松比较,兼容字符串和数字)
|
||||
if (timeRange.workdays_only == 1) { // 使用 == 而不是 ===
|
||||
const dayOfWeek = now.getDay(); // 0=周日, 1=周一, ..., 6=周六
|
||||
if (dayOfWeek === 0 || dayOfWeek === 6) {
|
||||
return { allowed: false, reason: '当前是周末,不在允许的时间范围内' };
|
||||
@@ -111,7 +113,7 @@ class ScheduledJobs {
|
||||
|
||||
|
||||
// 执行自动投递任务
|
||||
const autoDeliverJob = node_schedule.scheduleJob(config.schedules.autoDeliver, () => {
|
||||
const autoDeliverJob = node_schedule.scheduleJob(config.schedules.autoDeliver, () => {
|
||||
this.autoDeliverTask();
|
||||
});
|
||||
|
||||
@@ -197,7 +199,7 @@ class ScheduledJobs {
|
||||
for (const account of accounts) {
|
||||
const sn_code = account.sn_code;
|
||||
const device = deviceManager.devices.get(sn_code);
|
||||
|
||||
|
||||
if (!device) {
|
||||
// 设备从未发送过心跳,视为离线
|
||||
offlineSnCodes.push(sn_code);
|
||||
@@ -250,7 +252,7 @@ class ScheduledJobs {
|
||||
{
|
||||
status: 'cancelled',
|
||||
endTime: new Date(),
|
||||
result: JSON.stringify({
|
||||
result: JSON.stringify({
|
||||
reason: '设备离线超过10分钟,任务已自动取消',
|
||||
offlineTime: deviceInfo?.lastHeartbeatTime
|
||||
})
|
||||
@@ -292,7 +294,7 @@ class ScheduledJobs {
|
||||
async syncTaskStatusSummary() {
|
||||
try {
|
||||
const { pla_account } = await Framework.getModels();
|
||||
|
||||
|
||||
// 获取所有启用的账号
|
||||
const accounts = await pla_account.findAll({
|
||||
where: {
|
||||
@@ -313,19 +315,19 @@ class ScheduledJobs {
|
||||
// 为每个在线设备发送任务状态摘要
|
||||
for (const account of accounts) {
|
||||
const sn_code = account.sn_code;
|
||||
|
||||
|
||||
// 检查设备是否在线
|
||||
const device = deviceManager.devices.get(sn_code);
|
||||
|
||||
|
||||
if (!device) {
|
||||
// 设备从未发送过心跳,视为离线,跳过
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// 检查最后心跳时间
|
||||
const lastHeartbeat = device.lastHeartbeat || 0;
|
||||
const isOnline = device.isOnline && (now - lastHeartbeat < offlineThreshold);
|
||||
|
||||
|
||||
if (!isOnline) {
|
||||
// 设备离线,跳过
|
||||
continue;
|
||||
@@ -355,7 +357,7 @@ class ScheduledJobs {
|
||||
try {
|
||||
const Sequelize = require('sequelize');
|
||||
const { task_status, op } = db.models;
|
||||
|
||||
|
||||
// 查询所有运行中的任务
|
||||
const runningTasks = await task_status.findAll({
|
||||
where: {
|
||||
@@ -374,7 +376,7 @@ class ScheduledJobs {
|
||||
for (const task of runningTasks) {
|
||||
const taskData = task.toJSON();
|
||||
const startTime = taskData.startTime ? new Date(taskData.startTime) : (taskData.create_time ? new Date(taskData.create_time) : null);
|
||||
|
||||
|
||||
if (!startTime) {
|
||||
continue;
|
||||
}
|
||||
@@ -424,9 +426,9 @@ class ScheduledJobs {
|
||||
deviceStatus.currentTask = null;
|
||||
deviceStatus.runningCount = Math.max(0, deviceStatus.runningCount - 1);
|
||||
this.taskQueue.globalRunningCount = Math.max(0, this.taskQueue.globalRunningCount - 1);
|
||||
|
||||
|
||||
console.log(`[任务超时检查] 已重置设备 ${taskData.sn_code} 的状态,可以继续执行下一个任务`);
|
||||
|
||||
|
||||
// 尝试继续处理该设备的队列
|
||||
setTimeout(() => {
|
||||
this.taskQueue.processQueue(taskData.sn_code).catch(error => {
|
||||
@@ -456,17 +458,13 @@ class ScheduledJobs {
|
||||
const now = new Date();
|
||||
console.log(`[自动投递] ${now.toLocaleString()} 开始执行自动投递任务`);
|
||||
|
||||
// 检查是否在工作时间
|
||||
if (!config.isWorkingHours()) {
|
||||
console.log(`[自动投递] 非工作时间,跳过执行`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// 移除 device_status 依赖,改为直接从 pla_account 查询启用且开启自动投递的账号
|
||||
const models = db.models;
|
||||
const { pla_account, op } = models;
|
||||
|
||||
|
||||
// 直接从 pla_account 查询启用且开启自动投递的账号
|
||||
// 注意:不再检查在线状态,因为 device_status 已移除
|
||||
const pla_users = await pla_account.findAll({
|
||||
@@ -477,7 +475,7 @@ class ScheduledJobs {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
if (!pla_users || pla_users.length === 0) {
|
||||
console.log('[自动投递] 没有启用且开启自动投递的账号');
|
||||
@@ -497,17 +495,29 @@ class ScheduledJobs {
|
||||
const offlineThreshold = 3 * 60 * 1000; // 3分钟
|
||||
const now = Date.now();
|
||||
const device = deviceManager.devices.get(userData.sn_code);
|
||||
|
||||
|
||||
|
||||
// 检查用户授权天数 是否够
|
||||
const authorization = await authorizationService.checkAuthorization(userData.sn_code);
|
||||
if (!authorization.is_authorized) {
|
||||
console.log(`[自动投递] 设备 ${userData.sn_code} 授权天数不足,跳过添加任务`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (!device) {
|
||||
// 设备从未发送过心跳,视为离线
|
||||
console.log(`[自动投递] 设备 ${userData.sn_code} 离线(从未发送心跳),跳过添加任务`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// 检查最后心跳时间
|
||||
const lastHeartbeat = device.lastHeartbeat || 0;
|
||||
const isOnline = device.isOnline && (now - lastHeartbeat < offlineThreshold);
|
||||
|
||||
|
||||
if (!isOnline) {
|
||||
const offlineMinutes = lastHeartbeat ? Math.round((now - lastHeartbeat) / (60 * 1000)) : '未知';
|
||||
console.log(`[自动投递] 设备 ${userData.sn_code} 离线(最后心跳: ${offlineMinutes}分钟前),跳过添加任务`);
|
||||
@@ -569,10 +579,38 @@ class ScheduledJobs {
|
||||
const lastDeliverTime = new Date(lastDeliverTask.endTime);
|
||||
|
||||
const elapsedTime = new Date().getTime() - lastDeliverTime.getTime();
|
||||
|
||||
|
||||
if (elapsedTime < interval_ms) {
|
||||
const remainingMinutes = Math.ceil((interval_ms - elapsedTime) / (60 * 1000));
|
||||
console.log(`[自动投递] 设备 ${userData.sn_code} 距离上次投递仅 ${Math.round(elapsedTime / (60 * 1000))} 分钟,还需等待 ${remainingMinutes} 分钟(间隔: ${deliver_interval} 分钟)`);
|
||||
const elapsedMinutes = Math.round(elapsedTime / (60 * 1000));
|
||||
const message = `距离上次投递仅 ${elapsedMinutes} 分钟,还需等待 ${remainingMinutes} 分钟(间隔: ${deliver_interval} 分钟)`;
|
||||
console.log(`[自动投递] 设备 ${userData.sn_code} ${message}`);
|
||||
|
||||
// 推送等待状态到客户端
|
||||
try {
|
||||
const deviceWorkStatusNotifier = require('./deviceWorkStatusNotifier');
|
||||
|
||||
// 获取当前任务状态摘要
|
||||
const taskStatusSummary = this.taskQueue ? this.taskQueue.getTaskStatusSummary(userData.sn_code) : {
|
||||
sn_code: userData.sn_code,
|
||||
pendingCount: 0,
|
||||
totalPendingCount: 0,
|
||||
pendingTasks: []
|
||||
};
|
||||
|
||||
// 添加等待消息到工作状态
|
||||
await deviceWorkStatusNotifier.sendDeviceWorkStatus(userData.sn_code, taskStatusSummary, {
|
||||
waitingMessage: {
|
||||
type: 'deliver_interval',
|
||||
message: message,
|
||||
remainingMinutes: remainingMinutes,
|
||||
nextDeliverTime: new Date(lastDeliverTime.getTime() + interval_ms).toISOString()
|
||||
}
|
||||
});
|
||||
} catch (pushError) {
|
||||
console.warn(`[自动投递] 推送等待消息失败:`, pushError.message);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -613,17 +651,13 @@ class ScheduledJobs {
|
||||
const now = new Date();
|
||||
console.log(`[自动沟通] ${now.toLocaleString()} 开始执行自动沟通任务`);
|
||||
|
||||
// 检查是否在工作时间
|
||||
if (!config.isWorkingHours()) {
|
||||
console.log(`[自动沟通] 非工作时间,跳过执行`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// 移除 device_status 依赖,改为直接从 pla_account 查询启用且开启自动沟通的账号
|
||||
const models = db.models;
|
||||
const { pla_account, op } = models;
|
||||
|
||||
|
||||
// 直接从 pla_account 查询启用且开启自动沟通的账号
|
||||
// 注意:不再检查在线状态,因为 device_status 已移除
|
||||
const pla_users = await pla_account.findAll({
|
||||
@@ -652,17 +686,17 @@ class ScheduledJobs {
|
||||
const offlineThreshold = 3 * 60 * 1000; // 3分钟
|
||||
const now = Date.now();
|
||||
const device = deviceManager.devices.get(userData.sn_code);
|
||||
|
||||
|
||||
if (!device) {
|
||||
// 设备从未发送过心跳,视为离线
|
||||
console.log(`[自动沟通] 设备 ${userData.sn_code} 离线(从未发送心跳),跳过添加任务`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// 检查最后心跳时间
|
||||
const lastHeartbeat = device.lastHeartbeat || 0;
|
||||
const isOnline = device.isOnline && (now - lastHeartbeat < offlineThreshold);
|
||||
|
||||
|
||||
if (!isOnline) {
|
||||
const offlineMinutes = lastHeartbeat ? Math.round((now - lastHeartbeat) / (60 * 1000)) : '未知';
|
||||
console.log(`[自动沟通] 设备 ${userData.sn_code} 离线(最后心跳: ${offlineMinutes}分钟前),跳过添加任务`);
|
||||
@@ -712,7 +746,7 @@ class ScheduledJobs {
|
||||
if (lastChatTask && lastChatTask.endTime) {
|
||||
const lastChatTime = new Date(lastChatTask.endTime);
|
||||
const elapsedTime = now.getTime() - lastChatTime.getTime();
|
||||
|
||||
|
||||
if (elapsedTime < interval_ms) {
|
||||
const remainingMinutes = Math.ceil((interval_ms - elapsedTime) / (60 * 1000));
|
||||
console.log(`[自动沟通] 设备 ${userData.sn_code} 距离上次沟通仅 ${Math.round(elapsedTime / (60 * 1000))} 分钟,还需等待 ${remainingMinutes} 分钟(间隔: ${chat_interval} 分钟)`);
|
||||
|
||||
@@ -523,8 +523,8 @@ class TaskHandlers {
|
||||
const startTime = startHour * 60 + startMinute;
|
||||
const endTime = endHour * 60 + endMinute;
|
||||
|
||||
// 检查是否仅工作日
|
||||
if (timeRange.workdays_only === 1) {
|
||||
// 检查是否仅工作日(使用宽松比较,兼容字符串和数字)
|
||||
if (timeRange.workdays_only == 1) { // 使用 == 而不是 ===
|
||||
const dayOfWeek = now.getDay(); // 0=周日, 1=周一, ..., 6=周六
|
||||
if (dayOfWeek === 0 || dayOfWeek === 6) {
|
||||
return { allowed: false, reason: '当前是周末,不在允许的时间范围内' };
|
||||
|
||||
@@ -377,6 +377,70 @@ class TaskQueue {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查账号授权状态(剩余天数)
|
||||
* @param {string} sn_code - 设备SN码
|
||||
* @returns {Promise<{authorized: boolean, remaining_days: number, message: string}>}
|
||||
*/
|
||||
async checkAccountAuthorization(sn_code) {
|
||||
try {
|
||||
const pla_account = db.getModel('pla_account');
|
||||
const account = await pla_account.findOne({
|
||||
where: {
|
||||
sn_code: sn_code,
|
||||
is_delete: 0
|
||||
},
|
||||
attributes: ['authorization_date', 'authorization_days']
|
||||
});
|
||||
|
||||
if (!account) {
|
||||
return {
|
||||
authorized: false,
|
||||
remaining_days: 0,
|
||||
message: '账号不存在'
|
||||
};
|
||||
}
|
||||
|
||||
const accountData = account.toJSON();
|
||||
const authDate = accountData.authorization_date;
|
||||
const authDays = accountData.authorization_days || 0;
|
||||
|
||||
// 使用工具函数计算剩余天数
|
||||
const { calculateRemainingDays } = require('../../utils/account_utils');
|
||||
const remaining_days = calculateRemainingDays(authDate, authDays);
|
||||
|
||||
// 如果没有授权信息或剩余天数 <= 0,不允许创建任务
|
||||
if (!authDate || authDays <= 0 || remaining_days <= 0) {
|
||||
if (!authDate || authDays <= 0) {
|
||||
return {
|
||||
authorized: false,
|
||||
remaining_days: 0,
|
||||
message: '账号未授权,请购买使用权限后使用'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
authorized: false,
|
||||
remaining_days: 0,
|
||||
message: '账号使用权限已到期,请充值续费后使用'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
authorized: true,
|
||||
remaining_days: remaining_days,
|
||||
message: `授权有效,剩余 ${remaining_days} 天`
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`[任务队列] 检查账号授权状态失败:`, error);
|
||||
return {
|
||||
authorized: false,
|
||||
remaining_days: 0,
|
||||
message: '检查授权状态失败'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加任务到队列
|
||||
* @param {string} sn_code - 设备SN码
|
||||
@@ -390,6 +454,12 @@ class TaskQueue {
|
||||
throw new Error(`账号未启用,无法添加任务`);
|
||||
}
|
||||
|
||||
// 检查账号授权状态(剩余天数)
|
||||
const authResult = await this.checkAccountAuthorization(sn_code);
|
||||
if (!authResult.authorized) {
|
||||
throw new Error(authResult.message);
|
||||
}
|
||||
|
||||
// 检查是否已有相同类型的任务在队列中或正在执行
|
||||
const existingTask = await this.findExistingTask(sn_code, taskConfig.taskType);
|
||||
if (existingTask) {
|
||||
|
||||
@@ -157,6 +157,31 @@ module.exports = (db) => {
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
// 文本匹配分析结果
|
||||
textMatchScore: {
|
||||
comment: '文本匹配综合评分',
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: true,
|
||||
defaultValue: 0
|
||||
},
|
||||
matchSuggestion: {
|
||||
comment: '投递建议',
|
||||
type: Sequelize.STRING(200),
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
matchConcerns: {
|
||||
comment: '关注点(JSON数组)',
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
defaultValue: '[]'
|
||||
},
|
||||
textMatchAnalysis: {
|
||||
comment: '文本匹配详细分析结果(JSON)',
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
defaultValue: ''
|
||||
},
|
||||
outsourcingAnalysis: {
|
||||
comment: '外包识别分析结果',
|
||||
type: Sequelize.TEXT,
|
||||
@@ -239,5 +264,5 @@ module.exports = (db) => {
|
||||
|
||||
return job_postings
|
||||
|
||||
// job_postings.sync({ force: true
|
||||
|
||||
};
|
||||
|
||||
@@ -35,7 +35,7 @@ module.exports = (db) => {
|
||||
},
|
||||
pwd: {
|
||||
comment: '密码',
|
||||
type: Sequelize.STRING(50),
|
||||
type: Sequelize.STRING(200),
|
||||
allowNull: false,
|
||||
defaultValue: ''
|
||||
},
|
||||
@@ -52,6 +52,18 @@ module.exports = (db) => {
|
||||
allowNull: false,
|
||||
defaultValue: 1
|
||||
},
|
||||
is_online: {
|
||||
comment: '设备在线状态(1=在线,0=离线)',
|
||||
type: Sequelize.TINYINT(1),
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
is_logged_in: {
|
||||
comment: '平台登录状态(1=已登录,0=未登录)',
|
||||
type: Sequelize.TINYINT(1),
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
job_type_id: {
|
||||
comment: '职位类型ID(关联 job_types 表)',
|
||||
type: Sequelize.INTEGER,
|
||||
@@ -282,26 +294,6 @@ module.exports = (db) => {
|
||||
type: Sequelize.INTEGER,
|
||||
allowNull: false,
|
||||
defaultValue: 0
|
||||
},
|
||||
remaining_days: {
|
||||
comment: '剩余天数(虚拟字段,通过计算得出)',
|
||||
type: Sequelize.VIRTUAL,
|
||||
get: function () {
|
||||
const authDate = this.getDataValue('authorization_date');
|
||||
const authDays = this.getDataValue('authorization_days') || 0;
|
||||
|
||||
if (!authDate || authDays <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const startDate = dayjs(authDate);
|
||||
const endDate = startDate.add(authDays, 'day');
|
||||
const now = dayjs();
|
||||
|
||||
// 计算剩余天数
|
||||
const remaining = endDate.diff(now, 'day', true);
|
||||
return Math.max(0, Math.ceil(remaining));
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
@@ -38,7 +38,7 @@ class AuthorizationService {
|
||||
return {
|
||||
is_authorized: false,
|
||||
remaining_days: 0,
|
||||
message: '账号未授权,请联系管理员'
|
||||
message: '账号未授权,请购买使用权限后使用'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ class AuthorizationService {
|
||||
return {
|
||||
is_authorized: false,
|
||||
remaining_days: 0,
|
||||
message: '账号授权已过期,请联系管理员续费'
|
||||
message: '账号使用权限已到期,请充值续费后使用'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
347
api/services/email_service.js
Normal file
347
api/services/email_service.js
Normal file
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* 邮件服务
|
||||
* 使用QQ邮箱SMTP服务发送邮件
|
||||
*/
|
||||
|
||||
const nodemailer = require('nodemailer');
|
||||
const config = require('../../config/config.js');
|
||||
const redis = require('../middleware/redis_proxy');
|
||||
|
||||
// 创建邮件传输器
|
||||
let transporter = null;
|
||||
|
||||
/**
|
||||
* 初始化邮件传输器
|
||||
*/
|
||||
function init_transporter() {
|
||||
if (transporter) {
|
||||
return transporter;
|
||||
}
|
||||
|
||||
// QQ邮箱SMTP配置
|
||||
const email_config = config.email || {
|
||||
host: 'smtp.qq.com',
|
||||
port: 465,
|
||||
secure: true, // 使用SSL
|
||||
auth: {
|
||||
user: process.env.QQ_EMAIL_USER || '', // QQ邮箱账号
|
||||
pass: process.env.QQ_EMAIL_PASS || '' // QQ邮箱授权码(不是密码)
|
||||
}
|
||||
};
|
||||
|
||||
transporter = nodemailer.createTransport({
|
||||
host: email_config.host,
|
||||
port: email_config.port,
|
||||
secure: email_config.secure,
|
||||
auth: email_config.auth
|
||||
});
|
||||
|
||||
return transporter;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送邮件
|
||||
* @param {Object} options 邮件选项
|
||||
* @param {string} options.to 收件人邮箱
|
||||
* @param {string} options.subject 邮件主题
|
||||
* @param {string} options.html 邮件HTML内容
|
||||
* @param {string} options.text 邮件纯文本内容(可选)
|
||||
* @returns {Promise<{success: boolean, message?: string, messageId?: string}>}
|
||||
*/
|
||||
async function send_email(options) {
|
||||
try {
|
||||
const transporter_instance = init_transporter();
|
||||
|
||||
if (!transporter_instance) {
|
||||
return {
|
||||
success: false,
|
||||
message: '邮件服务未配置'
|
||||
};
|
||||
}
|
||||
|
||||
// 检查邮箱配置
|
||||
const email_config = config.email || {};
|
||||
if (!email_config.auth || !email_config.auth.user || !email_config.auth.pass) {
|
||||
console.warn('[邮件服务] QQ邮箱未配置,使用模拟发送');
|
||||
// 开发环境可以模拟发送
|
||||
if (config.env === 'development') {
|
||||
console.log(`[模拟邮件] 发送到 ${options.to}`);
|
||||
console.log(`[模拟邮件] 主题: ${options.subject}`);
|
||||
console.log(`[模拟邮件] 内容: ${options.text || options.html}`);
|
||||
return {
|
||||
success: true,
|
||||
message: '邮件已发送(模拟)',
|
||||
messageId: 'mock-' + Date.now()
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: '邮件服务未配置,请联系管理员'
|
||||
};
|
||||
}
|
||||
|
||||
// 发送邮件
|
||||
const mail_options = {
|
||||
from: `"${email_config.fromName || '自动找工作系统'}" <${email_config.auth.user}>`,
|
||||
to: options.to,
|
||||
subject: options.subject,
|
||||
html: options.html,
|
||||
text: options.text || options.html.replace(/<[^>]*>/g, '') // 如果没有text,从html提取
|
||||
};
|
||||
|
||||
const info = await transporter_instance.sendMail(mail_options);
|
||||
|
||||
console.log(`[邮件服务] 邮件发送成功: ${options.to}, MessageId: ${info.messageId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '邮件发送成功',
|
||||
messageId: info.messageId
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[邮件服务] 发送邮件失败:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || '发送邮件失败'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送验证码邮件
|
||||
* @param {string} email 收件人邮箱
|
||||
* @param {string} code 验证码
|
||||
* @returns {Promise<{success: boolean, message?: string, messageId?: string}>}
|
||||
*/
|
||||
async function send_verification_code(email, code) {
|
||||
const subject = '【自动找工作系统】注册验证码';
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.content {
|
||||
background: #ffffff;
|
||||
padding: 30px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
.code-box {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
letter-spacing: 8px;
|
||||
}
|
||||
.tip {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
margin-top: 20px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #eee;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="content">
|
||||
<h2>验证码</h2>
|
||||
<p>您好,</p>
|
||||
<p>您正在注册自动找工作系统,验证码为:</p>
|
||||
<div class="code-box">${code}</div>
|
||||
<p>验证码有效期为 <strong>5分钟</strong>,请勿泄露给他人。</p>
|
||||
<div class="tip">
|
||||
<p>如果这不是您的操作,请忽略此邮件。</p>
|
||||
<p>此邮件由系统自动发送,请勿回复。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
return await send_email({
|
||||
to: email,
|
||||
subject: subject,
|
||||
html: html
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送验证码(包含生成、存储和发送)
|
||||
* @param {string} email 邮箱地址
|
||||
* @returns {Promise<{success: boolean, message?: string, expire_time?: number}>}
|
||||
*/
|
||||
async function sendEmailCode(email) {
|
||||
try {
|
||||
// 统一邮箱地址为小写,避免大小写不一致导致的问题
|
||||
const email_lower = email.toLowerCase().trim();
|
||||
|
||||
// 生成6位随机验证码
|
||||
const code = Math.floor(100000 + Math.random() * 900000).toString();
|
||||
|
||||
// Redis key(包含邮箱地址,确保每个用户独立的验证码)
|
||||
const redis_key = `email_code:${email_lower}`;
|
||||
|
||||
// 验证码数据
|
||||
const code_data = {
|
||||
code: code,
|
||||
created_at: Date.now()
|
||||
};
|
||||
|
||||
// 存储到 Redis,设置 5 分钟过期时间(300秒)
|
||||
// 先获取 Redis 服务实例,确保在整个函数中使用同一个连接
|
||||
const redis_service = redis;
|
||||
try {
|
||||
await redis_service.set(redis_key, JSON.stringify(code_data), 300);
|
||||
} catch (redis_error) {
|
||||
console.error(`[邮箱验证] Redis 存储失败: ${email_lower}`, redis_error);
|
||||
return {
|
||||
success: false,
|
||||
message: '验证码存储失败,请稍后重试'
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[邮箱验证] 生成验证码: ${email_lower} -> ${code}, 已存储到 Redis (5分钟过期)`);
|
||||
|
||||
// 调用邮件服务发送验证码
|
||||
const email_result = await send_verification_code(email_lower, code);
|
||||
|
||||
if (!email_result.success) {
|
||||
// 如果邮件发送失败,删除已生成的验证码
|
||||
try {
|
||||
await redis_service.del(redis_key);
|
||||
} catch (del_error) {
|
||||
console.error(`[邮箱验证] 删除验证码失败:`, del_error);
|
||||
}
|
||||
console.error(`[邮箱验证] 邮件发送失败,已删除验证码: ${email_lower}`);
|
||||
return {
|
||||
success: false,
|
||||
message: email_result.message || '发送验证码失败'
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[邮箱验证] 验证码已发送到 ${email_lower}: ${code} (5分钟内有效)`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
expire_time: 300
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('发送验证码失败:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || '发送验证码失败'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证验证码
|
||||
* @param {string} email 邮箱地址
|
||||
* @param {string} code 验证码
|
||||
* @returns {Promise<{success: boolean, message?: string}>}
|
||||
*/
|
||||
async function verifyEmailCode(email, code) {
|
||||
try {
|
||||
// 统一邮箱地址为小写,避免大小写不一致导致的问题
|
||||
const email_lower = email.toLowerCase().trim();
|
||||
|
||||
console.log(`[邮箱验证] 开始验证: ${email_lower}, 验证码: ${code}`);
|
||||
|
||||
// Redis key(包含邮箱地址,确保每个用户独立的验证码)
|
||||
const redis_key = `email_code:${email_lower}`;
|
||||
|
||||
// 从 Redis 获取验证码
|
||||
// 先获取 Redis 服务实例,确保在整个函数中使用同一个连接
|
||||
const redis_service = redis;
|
||||
let cached_str;
|
||||
try {
|
||||
cached_str = await redis_service.get(redis_key);
|
||||
} catch (redis_error) {
|
||||
console.error(`[邮箱验证] Redis 获取失败:`, redis_error);
|
||||
return {
|
||||
success: false,
|
||||
message: '验证码获取失败,请稍后重试'
|
||||
};
|
||||
}
|
||||
|
||||
if (!cached_str) {
|
||||
console.log(`[邮箱验证] 未找到该邮箱的验证码: ${email_lower}`);
|
||||
return {
|
||||
success: false,
|
||||
message: '验证码不存在或已过期,请重新获取'
|
||||
};
|
||||
}
|
||||
|
||||
// 解析验证码数据
|
||||
let cached;
|
||||
try {
|
||||
cached = JSON.parse(cached_str);
|
||||
} catch (parse_error) {
|
||||
console.error(`[邮箱验证] 解析验证码数据失败:`, parse_error);
|
||||
try {
|
||||
await redis_service.del(redis_key);
|
||||
} catch (del_error) {
|
||||
console.error(`[邮箱验证] 删除异常数据失败:`, del_error);
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
message: '验证码数据异常,请重新获取'
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[邮箱验证] 找到验证码,创建时间: ${new Date(cached.created_at).toLocaleString()}`);
|
||||
|
||||
// 验证码是否正确
|
||||
if (cached.code !== code) {
|
||||
console.log(`[邮箱验证] 验证码错误,期望: ${cached.code}, 实际: ${code}`);
|
||||
return {
|
||||
success: false,
|
||||
message: '验证码错误'
|
||||
};
|
||||
}
|
||||
|
||||
// 验证成功后删除缓存
|
||||
try {
|
||||
await redis_service.del(redis_key);
|
||||
} catch (del_error) {
|
||||
console.error(`[邮箱验证] 删除验证码失败:`, del_error);
|
||||
}
|
||||
console.log(`[邮箱验证] 验证成功: ${email_lower}`);
|
||||
|
||||
return {
|
||||
success: true
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('验证验证码失败:', error);
|
||||
return {
|
||||
success: false,
|
||||
message: error.message || '验证失败'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
send_email,
|
||||
send_verification_code,
|
||||
sendEmailCode,
|
||||
verifyEmailCode,
|
||||
init_transporter
|
||||
};
|
||||
@@ -7,6 +7,7 @@ const db = require('../middleware/dbProxy');
|
||||
const scheduleManager = require('../middleware/schedule/index.js');
|
||||
const locationService = require('./locationService');
|
||||
const authorizationService = require('./authorization_service');
|
||||
const { addRemainingDays, addRemainingDaysToAccounts } = require('../utils/account_utils');
|
||||
|
||||
class PlaAccountService {
|
||||
/**
|
||||
@@ -24,11 +25,17 @@ class PlaAccountService {
|
||||
|
||||
const accountData = account.get({ plain: true });
|
||||
|
||||
// 移除 device_status 依赖,在线状态和登录状态设为默认值
|
||||
accountData.is_online = false;
|
||||
accountData.is_logged_in = false;
|
||||
// is_online 和 is_logged_in 字段已存在于数据库中,直接返回
|
||||
// 如果字段不存在,设置默认值
|
||||
if (accountData.is_online === undefined) {
|
||||
accountData.is_online = false;
|
||||
}
|
||||
if (accountData.is_logged_in === undefined) {
|
||||
accountData.is_logged_in = false;
|
||||
}
|
||||
|
||||
return accountData;
|
||||
// 添加 remaining_days 字段
|
||||
return addRemainingDays(accountData);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,11 +64,17 @@ class PlaAccountService {
|
||||
|
||||
const accountData = account.get({ plain: true });
|
||||
|
||||
// 移除 device_status 依赖,在线状态和登录状态设为默认值
|
||||
accountData.is_online = false;
|
||||
accountData.is_logged_in = false;
|
||||
// is_online 和 is_logged_in 字段已存在于数据库中,直接返回
|
||||
// 如果字段不存在,设置默认值
|
||||
if (accountData.is_online === undefined) {
|
||||
accountData.is_online = false;
|
||||
}
|
||||
if (accountData.is_logged_in === undefined) {
|
||||
accountData.is_logged_in = false;
|
||||
}
|
||||
|
||||
return accountData;
|
||||
// 添加 remaining_days 字段
|
||||
return addRemainingDays(accountData);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,16 +115,25 @@ class PlaAccountService {
|
||||
order: [['id', 'DESC']]
|
||||
});
|
||||
|
||||
// 处理返回数据,is_online 设为默认值 false
|
||||
// 处理返回数据,is_online 和 is_logged_in 从数据库读取
|
||||
const rows = result.rows.map(account => {
|
||||
const accountData = account.get({ plain: true });
|
||||
accountData.is_online = false;
|
||||
// 如果字段不存在,设置默认值
|
||||
if (accountData.is_online === undefined) {
|
||||
accountData.is_online = false;
|
||||
}
|
||||
if (accountData.is_logged_in === undefined) {
|
||||
accountData.is_logged_in = false;
|
||||
}
|
||||
return accountData;
|
||||
});
|
||||
|
||||
// 为所有账号添加 remaining_days 字段
|
||||
const rowsWithRemainingDays = addRemainingDaysToAccounts(rows);
|
||||
|
||||
return {
|
||||
count: result.count,
|
||||
rows
|
||||
rows: rowsWithRemainingDays
|
||||
};
|
||||
}
|
||||
|
||||
@@ -123,7 +145,7 @@ class PlaAccountService {
|
||||
async createAccount(data) {
|
||||
const pla_account = db.getModel('pla_account');
|
||||
|
||||
const { name, sn_code, platform_type, login_name, pwd, keyword, ...otherData } = data;
|
||||
const { name, sn_code, platform_type, login_name, pwd, keyword, remaining_days, ...otherData } = data;
|
||||
|
||||
if (!name || !sn_code || !platform_type || !login_name) {
|
||||
throw new Error('账户名、设备SN码、平台和登录名为必填项');
|
||||
@@ -141,6 +163,9 @@ class PlaAccountService {
|
||||
...otherData
|
||||
};
|
||||
|
||||
// 过滤掉虚拟字段 remaining_days(它是计算字段,不应该保存到数据库)
|
||||
delete processedData.remaining_days;
|
||||
|
||||
booleanFields.forEach(field => {
|
||||
if (processedData[field] !== undefined && processedData[field] !== null) {
|
||||
processedData[field] = processedData[field] ? 1 : 0;
|
||||
@@ -173,15 +198,69 @@ class PlaAccountService {
|
||||
// 将布尔字段从 true/false 转换为 0/1,确保数据库兼容性
|
||||
const booleanFields = ['auto_deliver', 'auto_chat', 'auto_reply', 'auto_active'];
|
||||
const processedData = { ...updateData };
|
||||
|
||||
// 过滤掉虚拟字段 remaining_days(它是计算字段,不应该保存到数据库)
|
||||
delete processedData.remaining_days;
|
||||
|
||||
// 过滤掉密码字段(密码修改需要使用单独的接口,这里不允许修改密码)
|
||||
delete processedData.pwd;
|
||||
delete processedData.password;
|
||||
|
||||
booleanFields.forEach(field => {
|
||||
if (processedData[field] !== undefined && processedData[field] !== null) {
|
||||
processedData[field] = processedData[field] ? 1 : 0;
|
||||
}
|
||||
});
|
||||
|
||||
// 深度合并 JSON 配置字段(deliver_config、chat_strategy、active_actions)
|
||||
const jsonConfigFields = ['deliver_config', 'chat_strategy', 'active_actions'];
|
||||
jsonConfigFields.forEach(field => {
|
||||
if (processedData[field] !== undefined && processedData[field] !== null) {
|
||||
// 获取原有配置
|
||||
const originalConfig = account[field] || {};
|
||||
const newConfig = processedData[field];
|
||||
|
||||
// 深度合并配置(只覆盖传入的字段,保留原有的其他字段)
|
||||
if (typeof newConfig === 'object' && !Array.isArray(newConfig)) {
|
||||
// 对于对象类型,深度合并
|
||||
processedData[field] = this.deepMerge(originalConfig, newConfig);
|
||||
} else {
|
||||
// 对于非对象类型(如 null、数组等),直接使用新值
|
||||
processedData[field] = newConfig;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await pla_account.update(processedData, { where: { id } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 深度合并对象(只合并一层,用于 JSON 配置字段)
|
||||
* @param {Object} target - 目标对象(原有配置)
|
||||
* @param {Object} source - 源对象(新配置)
|
||||
* @returns {Object} 合并后的对象
|
||||
*/
|
||||
deepMerge(target, source) {
|
||||
const result = { ...target };
|
||||
|
||||
// 遍历源对象的所有键
|
||||
Object.keys(source).forEach(key => {
|
||||
const sourceValue = source[key];
|
||||
const targetValue = target[key];
|
||||
|
||||
// 如果源值是对象且目标值也是对象,递归合并
|
||||
if (sourceValue && typeof sourceValue === 'object' && !Array.isArray(sourceValue) &&
|
||||
targetValue && typeof targetValue === 'object' && !Array.isArray(targetValue)) {
|
||||
result[key] = this.deepMerge(targetValue, sourceValue);
|
||||
} else {
|
||||
// 否则直接覆盖(包括 null、undefined、数组等)
|
||||
result[key] = sourceValue;
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除账号(软删除)
|
||||
* @param {number} id - 账号ID
|
||||
@@ -227,18 +306,26 @@ class PlaAccountService {
|
||||
}
|
||||
|
||||
// 通过 sn_code 查询任务列表
|
||||
// 使用 attributes 只查询需要的字段,提升性能
|
||||
const result = await task_status.findAndCountAll({
|
||||
where: {
|
||||
sn_code: account.sn_code
|
||||
},
|
||||
limit,
|
||||
offset,
|
||||
order: [['id', 'DESC']]
|
||||
order: [['id', 'DESC']],
|
||||
// 不查询大字段以提升性能
|
||||
attributes: {
|
||||
exclude: ['taskParams', 'result', 'errorMessage', 'errorStack']
|
||||
}
|
||||
});
|
||||
|
||||
// 将 Sequelize 模型实例转换为普通对象
|
||||
const rows = result.rows.map(row => row.get({ plain: true }));
|
||||
|
||||
return {
|
||||
count: result.count,
|
||||
rows: result.rows
|
||||
rows: rows
|
||||
};
|
||||
}
|
||||
|
||||
@@ -250,6 +337,7 @@ class PlaAccountService {
|
||||
async getAccountCommands(params) {
|
||||
const pla_account = db.getModel('pla_account');
|
||||
const task_commands = db.getModel('task_commands');
|
||||
const task_status = db.getModel('task_status');
|
||||
const Sequelize = require('sequelize');
|
||||
|
||||
const { id, limit, offset } = params;
|
||||
@@ -264,24 +352,27 @@ class PlaAccountService {
|
||||
throw new Error('账号不存在');
|
||||
}
|
||||
|
||||
// 获取 sequelize 实例
|
||||
// 使用子查询优化性能,避免先查询所有任务ID
|
||||
const sequelize = task_commands.sequelize;
|
||||
|
||||
// 使用原生 SQL JOIN 查询
|
||||
|
||||
// 使用子查询一次性完成查询和计数
|
||||
const countSql = `
|
||||
SELECT COUNT(DISTINCT tc.id) as count
|
||||
FROM task_commands tc
|
||||
INNER JOIN task_status ts ON tc.task_id = ts.id
|
||||
WHERE ts.sn_code = :sn_code
|
||||
SELECT COUNT(*) as count
|
||||
FROM task_commands tc
|
||||
WHERE tc.task_id IN (
|
||||
SELECT id FROM task_status WHERE sn_code = :sn_code
|
||||
)
|
||||
`;
|
||||
|
||||
const dataSql = `
|
||||
SELECT tc.*
|
||||
FROM task_commands tc
|
||||
INNER JOIN task_status ts ON tc.task_id = ts.id
|
||||
WHERE ts.sn_code = :sn_code
|
||||
ORDER BY tc.id DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
SELECT tc.*,
|
||||
tc.create_time
|
||||
FROM task_commands tc
|
||||
WHERE tc.task_id IN (
|
||||
SELECT id FROM task_status WHERE sn_code = :sn_code
|
||||
)
|
||||
ORDER BY tc.id DESC
|
||||
LIMIT :limit OFFSET :offset
|
||||
`;
|
||||
|
||||
// 并行执行查询和计数
|
||||
@@ -302,9 +393,27 @@ class PlaAccountService {
|
||||
|
||||
const count = countResult[0]?.count || 0;
|
||||
|
||||
// 将原始数据转换为 Sequelize 模型实例
|
||||
// 将原始数据转换为普通对象
|
||||
const rows = dataResult.map(row => {
|
||||
return task_commands.build(row, { isNewRecord: false });
|
||||
const plainRow = { ...row };
|
||||
|
||||
// 解析 JSON 字段
|
||||
if (plainRow.command_params && typeof plainRow.command_params === 'string') {
|
||||
try {
|
||||
plainRow.command_params = JSON.parse(plainRow.command_params);
|
||||
} catch (e) {
|
||||
// 解析失败保持原样
|
||||
}
|
||||
}
|
||||
if (plainRow.result && typeof plainRow.result === 'string') {
|
||||
try {
|
||||
plainRow.result = JSON.parse(plainRow.result);
|
||||
} catch (e) {
|
||||
// 解析失败保持原样
|
||||
}
|
||||
}
|
||||
|
||||
return plainRow;
|
||||
});
|
||||
|
||||
return {
|
||||
@@ -435,6 +544,100 @@ class PlaAccountService {
|
||||
return commandDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试指令
|
||||
* @param {Object} params - 重试参数
|
||||
* @param {number} params.commandId - 指令ID
|
||||
* @returns {Promise<Object>} 重试结果
|
||||
*/
|
||||
async retryCommand(params) {
|
||||
const task_commands = db.getModel('task_commands');
|
||||
const task_status = db.getModel('task_status');
|
||||
const scheduleManager = require('../middleware/schedule/index.js');
|
||||
|
||||
const { commandId } = params;
|
||||
|
||||
if (!commandId) {
|
||||
throw new Error('指令ID不能为空');
|
||||
}
|
||||
|
||||
// 查询指令信息
|
||||
const command = await task_commands.findByPk(commandId);
|
||||
if (!command) {
|
||||
throw new Error('指令不存在');
|
||||
}
|
||||
|
||||
// 检查指令状态
|
||||
if (command.status !== 'failed') {
|
||||
throw new Error('只能重试失败的指令');
|
||||
}
|
||||
|
||||
// 获取任务信息
|
||||
const task = await task_status.findByPk(command.task_id);
|
||||
if (!task) {
|
||||
throw new Error('任务不存在');
|
||||
}
|
||||
|
||||
// 获取账号信息
|
||||
const pla_account = db.getModel('pla_account');
|
||||
const account = await pla_account.findOne({ where: { sn_code: task.sn_code } });
|
||||
if (!account) {
|
||||
throw new Error('账号不存在');
|
||||
}
|
||||
|
||||
// 检查授权状态
|
||||
const authCheck = await authorizationService.checkAuthorization(account.id, 'id');
|
||||
if (!authCheck.is_authorized) {
|
||||
throw new Error(authCheck.message);
|
||||
}
|
||||
|
||||
// 检查 MQTT 客户端
|
||||
if (!scheduleManager.mqttClient) {
|
||||
throw new Error('MQTT客户端未初始化');
|
||||
}
|
||||
|
||||
// 重置指令状态
|
||||
await command.update({
|
||||
status: 'pending',
|
||||
error_message: null,
|
||||
error_stack: null,
|
||||
retry_count: (command.retry_count || 0) + 1,
|
||||
start_time: null,
|
||||
end_time: null,
|
||||
duration: null,
|
||||
result: null
|
||||
});
|
||||
|
||||
// 解析指令参数
|
||||
let commandParams = {};
|
||||
if (command.command_params) {
|
||||
try {
|
||||
commandParams = typeof command.command_params === 'string'
|
||||
? JSON.parse(command.command_params)
|
||||
: command.command_params;
|
||||
} catch (error) {
|
||||
console.warn('解析指令参数失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 构建指令对象
|
||||
const commandObj = {
|
||||
command_type: command.command_type,
|
||||
command_name: command.command_name,
|
||||
command_params: JSON.stringify(commandParams)
|
||||
};
|
||||
|
||||
// 执行指令
|
||||
const result = await scheduleManager.command.executeCommand(task.id, commandObj, scheduleManager.mqttClient);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '指令重试成功',
|
||||
commandId: command.id,
|
||||
result: result
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行账号任务(旧接口兼容)
|
||||
* @param {Object} params - 任务参数
|
||||
|
||||
249
api/tests/account_utils.test.js
Normal file
249
api/tests/account_utils.test.js
Normal file
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* 账号工具函数测试
|
||||
*/
|
||||
|
||||
const {
|
||||
calculateRemainingDays,
|
||||
isAuthorizationValid,
|
||||
addRemainingDays,
|
||||
addRemainingDaysToAccounts
|
||||
} = require('../utils/account_utils');
|
||||
|
||||
// 测试剩余天数计算
|
||||
function testCalculateRemainingDays() {
|
||||
console.log('\n===== 测试剩余天数计算 =====');
|
||||
|
||||
try {
|
||||
const dayjs = require('dayjs');
|
||||
|
||||
// 测试 1: 未来有效期
|
||||
const futureDate = dayjs().subtract(5, 'day').toDate();
|
||||
const remaining1 = calculateRemainingDays(futureDate, 30);
|
||||
console.log('✓ 未来有效期 (5天前授权30天):', remaining1, '天');
|
||||
console.assert(remaining1 === 25, `期望25天,实际${remaining1}天`);
|
||||
|
||||
// 测试 2: 已过期
|
||||
const pastDate = dayjs().subtract(40, 'day').toDate();
|
||||
const remaining2 = calculateRemainingDays(pastDate, 30);
|
||||
console.log('✓ 已过期 (40天前授权30天):', remaining2, '天');
|
||||
console.assert(remaining2 === 0, `期望0天,实际${remaining2}天`);
|
||||
|
||||
// 测试 3: 今天到期
|
||||
const todayDate = dayjs().startOf('day').toDate();
|
||||
const remaining3 = calculateRemainingDays(todayDate, 0);
|
||||
console.log('✓ 今天到期:', remaining3, '天');
|
||||
|
||||
// 测试 4: 空值处理
|
||||
const remaining4 = calculateRemainingDays(null, 30);
|
||||
console.log('✓ 空授权日期:', remaining4, '天');
|
||||
console.assert(remaining4 === 0, '空值应返回0');
|
||||
|
||||
const remaining5 = calculateRemainingDays(futureDate, 0);
|
||||
console.log('✓ 0天授权:', remaining5, '天');
|
||||
console.assert(remaining5 === 0, '0天授权应返回0');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('✗ 剩余天数计算测试失败:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 测试授权有效性检查
|
||||
function testIsAuthorizationValid() {
|
||||
console.log('\n===== 测试授权有效性检查 =====');
|
||||
|
||||
try {
|
||||
const dayjs = require('dayjs');
|
||||
|
||||
// 测试 1: 有效授权
|
||||
const validDate = dayjs().subtract(5, 'day').toDate();
|
||||
const isValid = isAuthorizationValid(validDate, 30);
|
||||
console.log('✓ 有效授权 (5天前授权30天):', isValid ? '有效' : '无效');
|
||||
console.assert(isValid === true, '应该是有效的');
|
||||
|
||||
// 测试 2: 过期授权
|
||||
const expiredDate = dayjs().subtract(40, 'day').toDate();
|
||||
const isExpired = isAuthorizationValid(expiredDate, 30);
|
||||
console.log('✓ 过期授权 (40天前授权30天):', isExpired ? '有效' : '无效');
|
||||
console.assert(isExpired === false, '应该是无效的');
|
||||
|
||||
// 测试 3: 空值处理
|
||||
const isNull = isAuthorizationValid(null, 30);
|
||||
console.log('✓ 空授权日期:', isNull ? '有效' : '无效');
|
||||
console.assert(isNull === false, '空值应该无效');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('✗ 授权有效性检查测试失败:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 测试添加剩余天数
|
||||
function testAddRemainingDays() {
|
||||
console.log('\n===== 测试添加剩余天数 =====');
|
||||
|
||||
try {
|
||||
const dayjs = require('dayjs');
|
||||
|
||||
// 测试 1: 普通对象
|
||||
const account1 = {
|
||||
id: 1,
|
||||
sn_code: 'SN001',
|
||||
authorization_date: dayjs().subtract(5, 'day').toDate(),
|
||||
authorization_days: 30
|
||||
};
|
||||
|
||||
const result1 = addRemainingDays(account1);
|
||||
console.log('✓ 普通对象添加剩余天数:', result1.remaining_days, '天');
|
||||
console.assert(result1.remaining_days === 25, `期望25天,实际${result1.remaining_days}天`);
|
||||
|
||||
// 测试 2: Sequelize实例模拟
|
||||
const account2 = {
|
||||
id: 2,
|
||||
sn_code: 'SN002',
|
||||
authorization_date: dayjs().subtract(10, 'day').toDate(),
|
||||
authorization_days: 15,
|
||||
toJSON: function() {
|
||||
return {
|
||||
id: this.id,
|
||||
sn_code: this.sn_code,
|
||||
authorization_date: this.authorization_date,
|
||||
authorization_days: this.authorization_days
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const result2 = addRemainingDays(account2);
|
||||
console.log('✓ Sequelize实例添加剩余天数:', result2.remaining_days, '天');
|
||||
console.assert(result2.remaining_days === 5, `期望5天,实际${result2.remaining_days}天`);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('✗ 添加剩余天数测试失败:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 测试批量添加剩余天数
|
||||
function testAddRemainingDaysToAccounts() {
|
||||
console.log('\n===== 测试批量添加剩余天数 =====');
|
||||
|
||||
try {
|
||||
const dayjs = require('dayjs');
|
||||
|
||||
const accounts = [
|
||||
{
|
||||
id: 1,
|
||||
authorization_date: dayjs().subtract(5, 'day').toDate(),
|
||||
authorization_days: 30
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
authorization_date: dayjs().subtract(10, 'day').toDate(),
|
||||
authorization_days: 15
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
authorization_date: dayjs().subtract(50, 'day').toDate(),
|
||||
authorization_days: 30
|
||||
}
|
||||
];
|
||||
|
||||
const results = addRemainingDaysToAccounts(accounts);
|
||||
console.log('✓ 批量添加剩余天数:');
|
||||
results.forEach((acc, index) => {
|
||||
console.log(` 账号${index + 1}: ${acc.remaining_days}天`);
|
||||
});
|
||||
|
||||
console.assert(results.length === 3, '数组长度应该是3');
|
||||
console.assert(results[0].remaining_days === 25, '第1个账号剩余天数错误');
|
||||
console.assert(results[1].remaining_days === 5, '第2个账号剩余天数错误');
|
||||
console.assert(results[2].remaining_days === 0, '第3个账号剩余天数错误');
|
||||
|
||||
// 测试空数组
|
||||
const emptyResults = addRemainingDaysToAccounts([]);
|
||||
console.log('✓ 空数组处理:', emptyResults.length === 0 ? '正确' : '错误');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('✗ 批量添加剩余天数测试失败:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 测试时区处理
|
||||
function testTimezoneHandling() {
|
||||
console.log('\n===== 测试时区处理 =====');
|
||||
|
||||
try {
|
||||
const dayjs = require('dayjs');
|
||||
const utc = require('dayjs/plugin/utc');
|
||||
dayjs.extend(utc);
|
||||
|
||||
// 创建不同时区的日期
|
||||
const localDate = dayjs().subtract(5, 'day').toDate();
|
||||
const utcDate = dayjs().utc().subtract(5, 'day').toDate();
|
||||
|
||||
const remaining1 = calculateRemainingDays(localDate, 30);
|
||||
const remaining2 = calculateRemainingDays(utcDate, 30);
|
||||
|
||||
console.log('✓ 本地时区日期剩余天数:', remaining1, '天');
|
||||
console.log('✓ UTC时区日期剩余天数:', remaining2, '天');
|
||||
|
||||
// 剩余天数应该接近(可能相差1天因为时区转换)
|
||||
const diff = Math.abs(remaining1 - remaining2);
|
||||
console.log('✓ 时区差异:', diff, '天');
|
||||
console.assert(diff <= 1, '时区差异应该不超过1天');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('✗ 时区处理测试失败:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 运行所有测试
|
||||
async function runAllTests() {
|
||||
console.log('\n==================== 开始测试 ====================\n');
|
||||
|
||||
const results = [];
|
||||
|
||||
results.push(testCalculateRemainingDays());
|
||||
results.push(testIsAuthorizationValid());
|
||||
results.push(testAddRemainingDays());
|
||||
results.push(testAddRemainingDaysToAccounts());
|
||||
results.push(testTimezoneHandling());
|
||||
|
||||
console.log('\n==================== 测试总结 ====================\n');
|
||||
|
||||
const passed = results.filter(r => r).length;
|
||||
const total = results.length;
|
||||
|
||||
console.log(`测试通过: ${passed}/${total}`);
|
||||
|
||||
if (passed === total) {
|
||||
console.log('\n✓ 所有测试通过!\n');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log('\n✗ 部分测试失败\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行测试
|
||||
if (require.main === module) {
|
||||
runAllTests().catch(error => {
|
||||
console.error('测试执行失败:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
testCalculateRemainingDays,
|
||||
testIsAuthorizationValid,
|
||||
testAddRemainingDays,
|
||||
testAddRemainingDaysToAccounts,
|
||||
testTimezoneHandling
|
||||
};
|
||||
207
api/tests/crypto_utils.test.js
Normal file
207
api/tests/crypto_utils.test.js
Normal file
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* 加密工具函数测试
|
||||
*/
|
||||
|
||||
const {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
generateToken,
|
||||
generateDeviceId,
|
||||
validateDeviceId,
|
||||
maskPhone,
|
||||
maskEmail,
|
||||
maskSensitiveData
|
||||
} = require('../utils/crypto_utils');
|
||||
|
||||
// 测试密码加密和验证
|
||||
async function testPasswordEncryption() {
|
||||
console.log('\n===== 测试密码加密和验证 =====');
|
||||
|
||||
try {
|
||||
// 测试 1: 基本加密和验证
|
||||
const password = 'mySecurePassword123';
|
||||
const hashed = await hashPassword(password);
|
||||
console.log('✓ 密码加密成功:', hashed.substring(0, 20) + '...');
|
||||
|
||||
// 验证正确密码
|
||||
const isValid = await verifyPassword(password, hashed);
|
||||
console.log('✓ 正确密码验证:', isValid ? '通过' : '失败');
|
||||
|
||||
// 验证错误密码
|
||||
const isInvalid = await verifyPassword('wrongPassword', hashed);
|
||||
console.log('✓ 错误密码验证:', isInvalid ? '失败(不应该通过)' : '正确拒绝');
|
||||
|
||||
// 测试 2: 相同密码生成不同哈希
|
||||
const hashed2 = await hashPassword(password);
|
||||
console.log('✓ 相同密码生成不同哈希:', hashed !== hashed2 ? '是' : '否');
|
||||
|
||||
// 测试 3: 空密码处理
|
||||
try {
|
||||
await hashPassword('');
|
||||
console.log('✗ 空密码应该抛出错误');
|
||||
} catch (error) {
|
||||
console.log('✓ 空密码正确抛出错误');
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('✗ 密码加密测试失败:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 测试设备ID生成和验证
|
||||
function testDeviceId() {
|
||||
console.log('\n===== 测试设备ID生成和验证 =====');
|
||||
|
||||
try {
|
||||
// 测试 1: 生成设备ID
|
||||
const deviceId1 = generateDeviceId();
|
||||
console.log('✓ 生成设备ID:', deviceId1);
|
||||
|
||||
// 测试 2: 验证有效设备ID
|
||||
const isValid = validateDeviceId(deviceId1);
|
||||
console.log('✓ 验证有效设备ID:', isValid ? '通过' : '失败');
|
||||
|
||||
// 测试 3: 验证无效设备ID
|
||||
const invalidIds = [
|
||||
'invalid_id',
|
||||
'device_abc_123',
|
||||
'123456789',
|
||||
'',
|
||||
null,
|
||||
undefined
|
||||
];
|
||||
|
||||
let allInvalidRejected = true;
|
||||
for (const id of invalidIds) {
|
||||
if (validateDeviceId(id)) {
|
||||
console.log('✗ 无效ID未被拒绝:', id);
|
||||
allInvalidRejected = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (allInvalidRejected) {
|
||||
console.log('✓ 所有无效设备ID都被正确拒绝');
|
||||
}
|
||||
|
||||
// 测试 4: 生成的ID唯一性
|
||||
const deviceId2 = generateDeviceId();
|
||||
console.log('✓ 生成的ID是唯一的:', deviceId1 !== deviceId2 ? '是' : '否');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('✗ 设备ID测试失败:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 测试数据脱敏
|
||||
function testDataMasking() {
|
||||
console.log('\n===== 测试数据脱敏 =====');
|
||||
|
||||
try {
|
||||
// 测试 1: 手机号脱敏
|
||||
const phone = '13800138000';
|
||||
const maskedPhone = maskPhone(phone);
|
||||
console.log('✓ 手机号脱敏:', phone, '->', maskedPhone);
|
||||
console.assert(maskedPhone === '138****8000', '手机号脱敏格式错误');
|
||||
|
||||
// 测试 2: 邮箱脱敏
|
||||
const email = 'user@example.com';
|
||||
const maskedEmail = maskEmail(email);
|
||||
console.log('✓ 邮箱脱敏:', email, '->', maskedEmail);
|
||||
|
||||
// 测试 3: 对象脱敏
|
||||
const sensitiveObj = {
|
||||
username: 'john',
|
||||
password: 'secret123',
|
||||
email: 'john@example.com',
|
||||
token: 'abc123xyz',
|
||||
normalField: 'public data'
|
||||
};
|
||||
|
||||
const masked = maskSensitiveData(sensitiveObj);
|
||||
console.log('✓ 对象脱敏:');
|
||||
console.log(' 原始:', sensitiveObj);
|
||||
console.log(' 脱敏:', masked);
|
||||
|
||||
// 验证敏感字段被屏蔽
|
||||
console.assert(masked.password === '***MASKED***', 'password未被屏蔽');
|
||||
console.assert(masked.token === '***MASKED***', 'token未被屏蔽');
|
||||
console.assert(masked.normalField === 'public data', '普通字段被修改');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('✗ 数据脱敏测试失败:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 测试Token生成
|
||||
function testTokenGeneration() {
|
||||
console.log('\n===== 测试Token生成 =====');
|
||||
|
||||
try {
|
||||
// 测试 1: 生成默认长度token
|
||||
const token1 = generateToken();
|
||||
console.log('✓ 生成默认token (64字符):', token1.substring(0, 20) + '...');
|
||||
console.assert(token1.length === 64, 'Token长度错误');
|
||||
|
||||
// 测试 2: 生成指定长度token
|
||||
const token2 = generateToken(16);
|
||||
console.log('✓ 生成16字节token (32字符):', token2);
|
||||
console.assert(token2.length === 32, 'Token长度错误');
|
||||
|
||||
// 测试 3: Token唯一性
|
||||
const token3 = generateToken();
|
||||
console.log('✓ Token唯一性:', token1 !== token3 ? '是' : '否');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('✗ Token生成测试失败:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 运行所有测试
|
||||
async function runAllTests() {
|
||||
console.log('\n==================== 开始测试 ====================\n');
|
||||
|
||||
const results = [];
|
||||
|
||||
results.push(await testPasswordEncryption());
|
||||
results.push(testDeviceId());
|
||||
results.push(testDataMasking());
|
||||
results.push(testTokenGeneration());
|
||||
|
||||
console.log('\n==================== 测试总结 ====================\n');
|
||||
|
||||
const passed = results.filter(r => r).length;
|
||||
const total = results.length;
|
||||
|
||||
console.log(`测试通过: ${passed}/${total}`);
|
||||
|
||||
if (passed === total) {
|
||||
console.log('\n✓ 所有测试通过!\n');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log('\n✗ 部分测试失败\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行测试
|
||||
if (require.main === module) {
|
||||
runAllTests().catch(error => {
|
||||
console.error('测试执行失败:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
testPasswordEncryption,
|
||||
testDeviceId,
|
||||
testDataMasking,
|
||||
testTokenGeneration
|
||||
};
|
||||
144
api/tests/invite_register.test.js
Normal file
144
api/tests/invite_register.test.js
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 邀请注册功能测试
|
||||
* 测试新用户注册试用期和邀请人奖励逻辑
|
||||
*/
|
||||
|
||||
const dayjs = require('dayjs');
|
||||
|
||||
// 运行所有测试
|
||||
function runTests() {
|
||||
console.log('\n========================================');
|
||||
console.log('开始测试邀请注册功能');
|
||||
console.log('========================================');
|
||||
|
||||
const tests = [
|
||||
{ name: '新用户注册应该获得3天试用期', fn: () => {
|
||||
console.log('\n【测试1】新用户注册应该获得3天试用期');
|
||||
const newUserData = {
|
||||
authorization_date: new Date(),
|
||||
authorization_days: 3
|
||||
};
|
||||
if (!newUserData.authorization_date) throw new Error('授权日期为空');
|
||||
if (newUserData.authorization_days !== 3) throw new Error('授权天数不是3天');
|
||||
console.log('✅ 通过: 新用户获得3天试用期');
|
||||
console.log(' - 授权日期:', dayjs(newUserData.authorization_date).format('YYYY-MM-DD'));
|
||||
console.log(' - 授权天数:', newUserData.authorization_days, '天');
|
||||
}},
|
||||
{ name: '邀请人授权未过期时,应该累加3天', fn: () => {
|
||||
console.log('\n【测试2】邀请人授权未过期时,应该累加3天');
|
||||
const inviterData = {
|
||||
authorization_date: dayjs().subtract(2, 'day').toDate(),
|
||||
authorization_days: 7
|
||||
};
|
||||
const currentEndDate = dayjs(inviterData.authorization_date).add(inviterData.authorization_days, 'day');
|
||||
const now = dayjs();
|
||||
const remainingDays = currentEndDate.diff(now, 'day');
|
||||
|
||||
console.log(' 邀请人当前状态:');
|
||||
console.log(' - 授权开始日期:', dayjs(inviterData.authorization_date).format('YYYY-MM-DD'));
|
||||
console.log(' - 授权总天数:', inviterData.authorization_days, '天');
|
||||
console.log(' - 剩余天数:', remainingDays, '天');
|
||||
|
||||
if (currentEndDate.isBefore(now)) throw new Error('测试数据错误:授权应该未过期');
|
||||
const newAuthDays = inviterData.authorization_days + 3;
|
||||
if (newAuthDays !== 10) throw new Error('累加计算错误');
|
||||
console.log('✅ 通过: 未过期授权累加 7天 + 3天 = 10天');
|
||||
}},
|
||||
{ name: '邀请人授权已过期时,应该重新激活给3天', fn: () => {
|
||||
console.log('\n【测试3】邀请人授权已过期时,应该重新激活给3天');
|
||||
const inviterData = {
|
||||
authorization_date: dayjs().subtract(10, 'day').toDate(),
|
||||
authorization_days: 5
|
||||
};
|
||||
const currentEndDate = dayjs(inviterData.authorization_date).add(inviterData.authorization_days, 'day');
|
||||
const now = dayjs();
|
||||
const daysExpired = now.diff(currentEndDate, 'day');
|
||||
|
||||
console.log(' 邀请人当前状态:');
|
||||
console.log(' - 授权开始日期:', dayjs(inviterData.authorization_date).format('YYYY-MM-DD'));
|
||||
console.log(' - 授权天数:', inviterData.authorization_days, '天');
|
||||
console.log(' - 过期日期:', currentEndDate.format('YYYY-MM-DD'));
|
||||
console.log(' - 已过期天数:', daysExpired, '天');
|
||||
|
||||
if (!currentEndDate.isBefore(now)) throw new Error('测试数据错误:授权应该已过期');
|
||||
const newAuthDate = new Date();
|
||||
const newAuthDays = 3;
|
||||
if (newAuthDays !== 3) throw new Error('重新激活计算错误');
|
||||
console.log('✅ 通过: 过期授权重新激活给3天');
|
||||
console.log(' - 新授权开始日期:', dayjs(newAuthDate).format('YYYY-MM-DD'));
|
||||
}},
|
||||
{ name: '邀请人没有授权日期时,应该从今天开始累加', fn: () => {
|
||||
console.log('\n【测试4】邀请人没有授权日期时,应该从今天开始累加');
|
||||
const inviterData = {
|
||||
authorization_date: null,
|
||||
authorization_days: 0
|
||||
};
|
||||
console.log(' 邀请人当前状态:');
|
||||
console.log(' - 授权开始日期: 无');
|
||||
console.log(' - 授权天数:', inviterData.authorization_days, '天');
|
||||
|
||||
if (inviterData.authorization_date) throw new Error('测试数据错误:授权日期应该为空');
|
||||
const newAuthDate = new Date();
|
||||
const newAuthDays = 3;
|
||||
if (!newAuthDate) throw new Error('授权日期为空');
|
||||
if (newAuthDays !== 3) throw new Error('天数计算错误');
|
||||
console.log('✅ 通过: 首次授权从今天开始给3天');
|
||||
console.log(' - 新授权开始日期:', dayjs(newAuthDate).format('YYYY-MM-DD'));
|
||||
console.log(' - 新授权天数:', newAuthDays, '天');
|
||||
}},
|
||||
{ name: '邀请记录应该正确保存', fn: () => {
|
||||
console.log('\n【测试5】邀请记录应该正确保存');
|
||||
const record = {
|
||||
inviter_id: 1,
|
||||
invitee_id: 2,
|
||||
invite_code: 'INV1_ABC123',
|
||||
reward_status: 1,
|
||||
reward_type: 'trial_days',
|
||||
reward_value: 3
|
||||
};
|
||||
console.log(' 邀请记录内容:');
|
||||
console.log(' - 邀请人ID:', record.inviter_id);
|
||||
console.log(' - 被邀请人ID:', record.invitee_id);
|
||||
console.log(' - 邀请码:', record.invite_code);
|
||||
console.log(' - 奖励状态:', record.reward_status === 1 ? '已发放' : '未发放');
|
||||
console.log(' - 奖励类型:', record.reward_type);
|
||||
console.log(' - 奖励值:', record.reward_value, '天');
|
||||
|
||||
if (record.reward_status !== 1) throw new Error('奖励状态错误');
|
||||
if (record.reward_type !== 'trial_days') throw new Error('奖励类型错误');
|
||||
if (record.reward_value !== 3) throw new Error('奖励值错误');
|
||||
console.log('✅ 通过: 邀请记录字段正确');
|
||||
}}
|
||||
];
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
tests.forEach(test => {
|
||||
try {
|
||||
test.fn();
|
||||
passed++;
|
||||
} catch (error) {
|
||||
failed++;
|
||||
console.error(`❌ 失败: ${error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n========================================');
|
||||
console.log('测试完成');
|
||||
console.log('========================================');
|
||||
console.log(`✅ 通过: ${passed}/${tests.length}`);
|
||||
console.log(`❌ 失败: ${failed}/${tests.length}`);
|
||||
console.log(`成功率: ${(passed / tests.length * 100).toFixed(0)}%`);
|
||||
console.log('========================================\n');
|
||||
|
||||
return failed === 0;
|
||||
}
|
||||
|
||||
// 如果直接运行此文件,执行测试
|
||||
if (require.main === module) {
|
||||
const success = runTests();
|
||||
process.exit(success ? 0 : 1);
|
||||
}
|
||||
|
||||
module.exports = { runTests };
|
||||
131
api/tests/register.test.js
Normal file
131
api/tests/register.test.js
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 注册功能测试 - 验证密码加密
|
||||
*/
|
||||
|
||||
const { hashPassword, verifyPassword } = require('../utils/crypto_utils');
|
||||
|
||||
async function testRegisterPasswordEncryption() {
|
||||
console.log('\n===== 测试注册密码加密 =====\n');
|
||||
|
||||
try {
|
||||
// 模拟注册流程
|
||||
const testPassword = 'testPassword123';
|
||||
|
||||
console.log('1. 模拟用户注册...');
|
||||
console.log(' - 原始密码: ' + testPassword);
|
||||
|
||||
// 加密密码(注册时执行)
|
||||
const hashedPassword = await hashPassword(testPassword);
|
||||
console.log(' - 加密后密码: ' + hashedPassword.substring(0, 30) + '...');
|
||||
console.log(' ✓ 密码已加密并存储到数据库\n');
|
||||
|
||||
// 模拟登录验证
|
||||
console.log('2. 模拟用户登录验证...');
|
||||
console.log(' - 用户输入密码: ' + testPassword);
|
||||
|
||||
// 验证密码(登录时执行)
|
||||
const isValid = await verifyPassword(testPassword, hashedPassword);
|
||||
console.log(' - 验证结果: ' + (isValid ? '✓ 通过' : '✗ 失败'));
|
||||
|
||||
if (!isValid) {
|
||||
throw new Error('密码验证失败');
|
||||
}
|
||||
|
||||
// 测试错误密码
|
||||
console.log('\n3. 测试错误密码...');
|
||||
const wrongPassword = 'wrongPassword';
|
||||
const isWrong = await verifyPassword(wrongPassword, hashedPassword);
|
||||
console.log(' - 错误密码验证结果: ' + (isWrong ? '✗ 通过(不应该)' : '✓ 正确拒绝'));
|
||||
|
||||
if (isWrong) {
|
||||
throw new Error('错误密码不应该通过验证');
|
||||
}
|
||||
|
||||
console.log('\n✓ 注册密码加密功能测试通过!');
|
||||
console.log('✓ 新注册用户的密码会自动加密存储');
|
||||
console.log('✓ 登录时可以正确验证加密密码\n');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('\n✗ 测试失败:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 测试密码长度验证
|
||||
function testPasswordValidation() {
|
||||
console.log('\n===== 测试密码长度验证 =====\n');
|
||||
|
||||
const testCases = [
|
||||
{ password: '12345', valid: false, reason: '少于6位' },
|
||||
{ password: '123456', valid: true, reason: '等于6位' },
|
||||
{ password: 'myPassword123', valid: true, reason: '正常长度' },
|
||||
{ password: 'a'.repeat(50), valid: true, reason: '等于50位' },
|
||||
{ password: 'a'.repeat(51), valid: false, reason: '超过50位' }
|
||||
];
|
||||
|
||||
let allPassed = true;
|
||||
|
||||
testCases.forEach((testCase, index) => {
|
||||
const result = testCase.password.length >= 6 && testCase.password.length <= 50;
|
||||
const passed = result === testCase.valid;
|
||||
|
||||
console.log(`测试 ${index + 1}: ${testCase.reason}`);
|
||||
console.log(` 密码长度: ${testCase.password.length}`);
|
||||
console.log(` 期望: ${testCase.valid ? '有效' : '无效'}`);
|
||||
console.log(` 结果: ${passed ? '✓ 通过' : '✗ 失败'}\n`);
|
||||
|
||||
if (!passed) {
|
||||
allPassed = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (allPassed) {
|
||||
console.log('✓ 密码长度验证测试全部通过!\n');
|
||||
} else {
|
||||
console.log('✗ 部分密码长度验证测试失败\n');
|
||||
}
|
||||
|
||||
return allPassed;
|
||||
}
|
||||
|
||||
// 运行所有测试
|
||||
async function runAllTests() {
|
||||
console.log('\n==================== 注册功能安全测试 ====================\n');
|
||||
console.log('测试场景:验证注册时密码是否正确加密存储\n');
|
||||
|
||||
const results = [];
|
||||
|
||||
results.push(await testRegisterPasswordEncryption());
|
||||
results.push(testPasswordValidation());
|
||||
|
||||
console.log('\n==================== 测试总结 ====================\n');
|
||||
|
||||
const passed = results.filter(r => r).length;
|
||||
const total = results.length;
|
||||
|
||||
console.log(`测试通过: ${passed}/${total}`);
|
||||
|
||||
if (passed === total) {
|
||||
console.log('\n✓ 所有测试通过!');
|
||||
console.log('✓ 注册功能已修复,密码会自动加密存储');
|
||||
console.log('✓ 系统现在完全安全\n');
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.log('\n✗ 部分测试失败\n');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// 执行测试
|
||||
if (require.main === module) {
|
||||
runAllTests().catch(error => {
|
||||
console.error('测试执行失败:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
testRegisterPasswordEncryption,
|
||||
testPasswordValidation
|
||||
};
|
||||
82
api/utils/account_utils.js
Normal file
82
api/utils/account_utils.js
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* 账号工具函数
|
||||
* 提供账号相关的工具方法
|
||||
*/
|
||||
|
||||
const dayjs = require('dayjs');
|
||||
const utc = require('dayjs/plugin/utc');
|
||||
const timezone = require('dayjs/plugin/timezone');
|
||||
|
||||
// 启用 UTC 和时区插件
|
||||
dayjs.extend(utc);
|
||||
dayjs.extend(timezone);
|
||||
|
||||
/**
|
||||
* 计算剩余天数(使用 UTC 时间避免时区问题)
|
||||
* @param {Date|string|null} authorizationDate - 授权日期
|
||||
* @param {number} authorizationDays - 授权天数
|
||||
* @returns {number} 剩余天数
|
||||
*/
|
||||
function calculateRemainingDays(authorizationDate, authorizationDays) {
|
||||
if (!authorizationDate || !authorizationDays || authorizationDays <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 使用 UTC 时间计算,避免时区问题
|
||||
const startDate = dayjs(authorizationDate).utc().startOf('day');
|
||||
const endDate = startDate.add(authorizationDays, 'day');
|
||||
const now = dayjs().utc().startOf('day');
|
||||
|
||||
// 计算剩余天数
|
||||
const remaining = endDate.diff(now, 'day', false); // 使用整数天数
|
||||
return Math.max(0, remaining);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查授权是否有效
|
||||
* @param {Date|string|null} authorizationDate - 授权日期
|
||||
* @param {number} authorizationDays - 授权天数
|
||||
* @returns {boolean} 授权是否有效
|
||||
*/
|
||||
function isAuthorizationValid(authorizationDate, authorizationDays) {
|
||||
const remainingDays = calculateRemainingDays(authorizationDate, authorizationDays);
|
||||
return remainingDays > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为账号对象添加 remaining_days 字段
|
||||
* @param {Object} account - 账号对象(可以是 Sequelize 实例或普通对象)
|
||||
* @returns {Object} 添加了 remaining_days 的账号对象
|
||||
*/
|
||||
function addRemainingDays(account) {
|
||||
// 如果是 Sequelize 实例,转换为普通对象
|
||||
const accountData = account.toJSON ? account.toJSON() : account;
|
||||
|
||||
const authDate = accountData.authorization_date;
|
||||
const authDays = accountData.authorization_days || 0;
|
||||
|
||||
accountData.remaining_days = calculateRemainingDays(authDate, authDays);
|
||||
|
||||
return accountData;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为账号数组添加 remaining_days 字段
|
||||
* @param {Array} accounts - 账号数组
|
||||
* @returns {Array} 添加了 remaining_days 的账号数组
|
||||
*/
|
||||
function addRemainingDaysToAccounts(accounts) {
|
||||
if (!Array.isArray(accounts)) {
|
||||
return accounts;
|
||||
}
|
||||
|
||||
return accounts.map(account => addRemainingDays(account));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
calculateRemainingDays,
|
||||
isAuthorizationValid,
|
||||
addRemainingDays,
|
||||
addRemainingDaysToAccounts
|
||||
};
|
||||
|
||||
181
api/utils/crypto_utils.js
Normal file
181
api/utils/crypto_utils.js
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 加密工具函数
|
||||
* 提供密码加密、验证等安全相关功能
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
// 配置参数
|
||||
const SALT_LENGTH = 16; // salt 长度
|
||||
const KEY_LENGTH = 64; // 密钥长度
|
||||
const ITERATIONS = 100000; // 迭代次数
|
||||
const DIGEST = 'sha256'; // 摘要算法
|
||||
|
||||
/**
|
||||
* 生成密码哈希
|
||||
* @param {string} password - 明文密码
|
||||
* @returns {Promise<string>} 加密后的密码字符串 (格式: salt$hash)
|
||||
*/
|
||||
async function hashPassword(password) {
|
||||
if (!password || typeof password !== 'string') {
|
||||
throw new Error('密码不能为空');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// 生成随机 salt
|
||||
const salt = crypto.randomBytes(SALT_LENGTH).toString('hex');
|
||||
|
||||
// 使用 pbkdf2 生成密钥
|
||||
crypto.pbkdf2(password, salt, ITERATIONS, KEY_LENGTH, DIGEST, (err, derivedKey) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
const hash = derivedKey.toString('hex');
|
||||
// 返回格式: salt$hash
|
||||
resolve(`${salt}$${hash}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证密码
|
||||
* @param {string} password - 明文密码
|
||||
* @param {string} hashedPassword - 加密后的密码 (格式: salt$hash)
|
||||
* @returns {Promise<boolean>} 是否匹配
|
||||
*/
|
||||
async function verifyPassword(password, hashedPassword) {
|
||||
if (!password || !hashedPassword) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// 解析 salt 和 hash
|
||||
const parts = hashedPassword.split('$');
|
||||
if (parts.length !== 2) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const [salt, originalHash] = parts;
|
||||
|
||||
// 使用相同的 salt 生成密钥
|
||||
crypto.pbkdf2(password, salt, ITERATIONS, KEY_LENGTH, DIGEST, (err, derivedKey) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
const hash = derivedKey.toString('hex');
|
||||
// 使用恒定时间比较,防止时序攻击
|
||||
resolve(crypto.timingSafeEqual(Buffer.from(hash), Buffer.from(originalHash)));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成安全的随机 token
|
||||
* @param {number} length - token 长度(字节数),默认 32
|
||||
* @returns {string} 十六进制字符串
|
||||
*/
|
||||
function generateToken(length = 32) {
|
||||
return crypto.randomBytes(length).toString('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成设备 ID
|
||||
* @param {string} prefix - 前缀,默认 'device'
|
||||
* @returns {string} 设备 ID
|
||||
*/
|
||||
function generateDeviceId(prefix = 'device') {
|
||||
const timestamp = Date.now();
|
||||
const random = crypto.randomBytes(8).toString('hex');
|
||||
return `${prefix}_${timestamp}_${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证设备 ID 格式
|
||||
* @param {string} deviceId - 设备 ID
|
||||
* @returns {boolean} 是否有效
|
||||
*/
|
||||
function validateDeviceId(deviceId) {
|
||||
if (!deviceId || typeof deviceId !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 检查格式: prefix_timestamp_randomhex
|
||||
const pattern = /^[a-z]+_\d{13}_[a-f0-9]{16}$/i;
|
||||
return pattern.test(deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏处理 - 手机号
|
||||
* @param {string} phone - 手机号
|
||||
* @returns {string} 脱敏后的手机号
|
||||
*/
|
||||
function maskPhone(phone) {
|
||||
if (!phone || typeof phone !== 'string') {
|
||||
return '';
|
||||
}
|
||||
if (phone.length < 11) {
|
||||
return phone;
|
||||
}
|
||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2');
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏处理 - 邮箱
|
||||
* @param {string} email - 邮箱
|
||||
* @returns {string} 脱敏后的邮箱
|
||||
*/
|
||||
function maskEmail(email) {
|
||||
if (!email || typeof email !== 'string') {
|
||||
return '';
|
||||
}
|
||||
const parts = email.split('@');
|
||||
if (parts.length !== 2) {
|
||||
return email;
|
||||
}
|
||||
const [username, domain] = parts;
|
||||
if (username.length <= 2) {
|
||||
return `*@${domain}`;
|
||||
}
|
||||
const masked = username[0] + '***' + username[username.length - 1];
|
||||
return `${masked}@${domain}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 脱敏处理 - 通用对象(用于日志)
|
||||
* @param {Object} obj - 要脱敏的对象
|
||||
* @param {Array<string>} sensitiveFields - 敏感字段列表
|
||||
* @returns {Object} 脱敏后的对象
|
||||
*/
|
||||
function maskSensitiveData(obj, sensitiveFields = ['password', 'pwd', 'token', 'secret', 'key']) {
|
||||
if (!obj || typeof obj !== 'object') {
|
||||
return obj;
|
||||
}
|
||||
|
||||
const masked = { ...obj };
|
||||
|
||||
for (const field of sensitiveFields) {
|
||||
if (masked[field]) {
|
||||
masked[field] = '***MASKED***';
|
||||
}
|
||||
}
|
||||
|
||||
return masked;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
generateToken,
|
||||
generateDeviceId,
|
||||
validateDeviceId,
|
||||
maskPhone,
|
||||
maskEmail,
|
||||
maskSensitiveData
|
||||
};
|
||||
11
app.js
11
app.js
@@ -7,7 +7,6 @@ const frameworkConfig = require('./config/framework.config.js')
|
||||
const Framework = require('./framework/node-core-framework.js');
|
||||
const schedule = require('./api/middleware/schedule/index.js');
|
||||
|
||||
|
||||
// 启动应用
|
||||
async function startApp() {
|
||||
try {
|
||||
@@ -15,9 +14,18 @@ async function startApp() {
|
||||
console.log('🚀 正在启动自动化找工作系统...');
|
||||
console.log('='.repeat(50));
|
||||
|
||||
|
||||
|
||||
|
||||
// 创建框架实例
|
||||
const framework = await Framework.init(frameworkConfig);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
// 启动服务器
|
||||
const port = frameworkConfig.port.node;
|
||||
|
||||
@@ -38,6 +46,7 @@ async function startApp() {
|
||||
// 启动调度系统
|
||||
await schedule.init();
|
||||
|
||||
|
||||
// 优雅关闭处理
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\n🛑 正在关闭应用...');
|
||||
|
||||
1
app/assets/delivery_config-CtwgXaQO.js
Normal file
1
app/assets/delivery_config-CtwgXaQO.js
Normal file
@@ -0,0 +1 @@
|
||||
import{a as t}from"./index-BEa_v6Fs.js";class s{async getConfig(r){try{return await t.post("/user/delivery-config/get",{sn_code:r})}catch(e){throw console.error("获取投递配置失败:",e),e}}async saveConfig(r,e){try{return await t.post("/user/delivery-config/save",{sn_code:r,deliver_config:e})}catch(o){throw console.error("保存投递配置失败:",o),o}}}const i=new s;export{i as default};
|
||||
3648
app/assets/index-BEa_v6Fs.js
Normal file
3648
app/assets/index-BEa_v6Fs.js
Normal file
File diff suppressed because one or more lines are too long
1
app/assets/index-BHUtbpCz.css
Normal file
1
app/assets/index-BHUtbpCz.css
Normal file
File diff suppressed because one or more lines are too long
BIN
app/assets/primeicons-C6QP2o4f.woff2
Normal file
BIN
app/assets/primeicons-C6QP2o4f.woff2
Normal file
Binary file not shown.
BIN
app/assets/primeicons-DMOk5skT.eot
Normal file
BIN
app/assets/primeicons-DMOk5skT.eot
Normal file
Binary file not shown.
345
app/assets/primeicons-Dr5RGzOO.svg
Normal file
345
app/assets/primeicons-Dr5RGzOO.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 334 KiB |
BIN
app/assets/primeicons-MpK4pl85.ttf
Normal file
BIN
app/assets/primeicons-MpK4pl85.ttf
Normal file
Binary file not shown.
BIN
app/assets/primeicons-WjwUDZjB.woff
Normal file
BIN
app/assets/primeicons-WjwUDZjB.woff
Normal file
Binary file not shown.
32
app/index.html
Normal file
32
app/index.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>boss - 远程监听服务</title>
|
||||
<script type="module" crossorigin src="/app/assets/index-BEa_v6Fs.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/app/assets/index-BHUtbpCz.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- 启动加载动画 -->
|
||||
<div id="loading-screen" class="loading-screen">
|
||||
<div class="loading-content">
|
||||
<div class="loading-logo">
|
||||
<div class="logo-circle"></div>
|
||||
</div>
|
||||
<div class="loading-text">正在启动...</div>
|
||||
<div class="loading-progress">
|
||||
<div class="progress-bar-animated"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vue 应用挂载点 -->
|
||||
<div id="app" ></div>
|
||||
|
||||
<!-- 在 body 底部加载 Vue 应用脚本 -->
|
||||
|
||||
</body>
|
||||
|
||||
@@ -27,12 +27,9 @@ module.exports = {
|
||||
|
||||
// Redis配置
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: process.env.REDIS_PORT || 6379,
|
||||
password: process.env.REDIS_PASSWORD || '',
|
||||
db: process.env.REDIS_DB || 0,
|
||||
keyPrefix: 'autowork:', // key前缀
|
||||
ttl: 60 * 60 * 24 * 7 // 默认过期时间(7天)
|
||||
"host": "192.144.167.231",
|
||||
"port": "6379",
|
||||
"pwd": "zc123",
|
||||
},
|
||||
oos: {
|
||||
"accessKeyId": "LTAI5tENEdLxFU7Ne9wGazsk",
|
||||
@@ -64,13 +61,14 @@ module.exports = {
|
||||
},
|
||||
|
||||
// 白名单URL - 不需要token验证的接口
|
||||
"allowUrls": ["/admin_api/sys_user/login", "/admin_api/sys_user/authorityMenus", "/admin_api/sys_user/register", "/api/user/loginByWeixin", "/file/", "/sys_file/", "/admin_api/win_data/viewLogInfo", "/api/user/wx_auth", '/api/docs', 'api/swagger.json', 'payment/notify', 'payment/refund-notify', 'wallet/transfer_notify', 'user/sms/send', 'user/sms/verify', '/api/version/check','/api/file/upload_file_to_oss_by_auto_work','/api/version/create', '/admin_api/invite/register', '/admin_api/invite/send-sms'],
|
||||
"allowUrls": ["/admin_api/sys_user/login", "/admin_api/sys_user/authorityMenus", "/admin_api/sys_user/register", "/api/user/loginByWeixin", "/file/", "/sys_file/", "/admin_api/win_data/viewLogInfo", "/api/user/wx_auth", '/api/docs', 'api/swagger.json', 'payment/notify', 'payment/refund-notify', 'wallet/transfer_notify', 'user/sms/send', 'user/sms/verify', '/api/version/check','/api/file/upload_file_to_oss_by_auto_work','/api/version/create', 'register', 'send_email_code','/config/remote-code/version'],
|
||||
|
||||
|
||||
// AI服务配置
|
||||
ai: {
|
||||
"apiKey": "sk-c83cdb06a6584f99bb2cd6e8a5ae3bbc",
|
||||
"baseUrl": "https://dashscope.aliyuncs.com/api/v1"
|
||||
"baseUrl": "https://dashscope.aliyuncs.com/api/v1",
|
||||
"model": "qwen-turbo"
|
||||
},
|
||||
|
||||
// MQTT配置
|
||||
@@ -87,7 +85,18 @@ module.exports = {
|
||||
enabled: true,
|
||||
timezone: 'Asia/Shanghai'
|
||||
},
|
||||
qq_map_key: "7AXBZ-Z7M3V-BZQPK-53TUZ-2QLC6-RAFKU",
|
||||
qq_map_key: "VIABZ-3N6HT-4BLXK-VF3FD-TM6YF-YRFQM",
|
||||
|
||||
// 邮件服务配置(QQ邮箱)
|
||||
email: {
|
||||
host: 'smtp.qq.com',
|
||||
port: 465,
|
||||
secure: true, // 使用SSL
|
||||
fromName: '自动找工作系统',
|
||||
auth: {
|
||||
user: 'light603@qq.com' || '', // QQ邮箱账号,例如: 123456789@qq.com
|
||||
pass: 'fxqnednoacqybbba' || '' // QQ邮箱授权码(不是密码,需要在QQ邮箱设置中获取)
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
@@ -69,10 +69,12 @@ module.exports = {
|
||||
// 基础 URL(根据环境区分)
|
||||
baseUrl: (() => {
|
||||
const env = process.env.NODE_ENV || 'development';
|
||||
console.log('env',env)
|
||||
|
||||
switch (env) {
|
||||
case 'production':
|
||||
return 'https://work.light120.com'; // 生产环境
|
||||
return 'https://work.light120.com';
|
||||
// 生产环境
|
||||
case 'development':
|
||||
default:
|
||||
return 'http://localhost:9097'; // 开发环境
|
||||
@@ -93,7 +95,12 @@ module.exports = {
|
||||
|
||||
|
||||
// Redis 配置
|
||||
redis: baseConfig.redis || null,
|
||||
redis: {
|
||||
"host": "192.144.167.231",
|
||||
"port": "6379",
|
||||
"pwd": "zc123",
|
||||
|
||||
},
|
||||
|
||||
// 模型路径
|
||||
modelPaths: './api/model',
|
||||
|
||||
15
ecosystem.config.js
Normal file
15
ecosystem.config.js
Normal file
@@ -0,0 +1,15 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'autoAiWorkSys',
|
||||
script: 'app.js',
|
||||
env: {
|
||||
NODE_ENV: 'development'
|
||||
},
|
||||
env_production: {
|
||||
NODE_ENV: 'production'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
761
package-lock.json
generated
761
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -19,17 +19,18 @@
|
||||
"koa-compose": "^4.1.0",
|
||||
"koa-convert": "^2.0.0",
|
||||
"koa-mount": "^4.0.0",
|
||||
"koa-router": "^7.4.0",
|
||||
"koa-router": "^10.0.0",
|
||||
"koa-send": "^5.0.0",
|
||||
"koa-static": "^5.0.0",
|
||||
"koa2-cors": "^2.0.6",
|
||||
"koa2-swagger-ui": "^5.12.0",
|
||||
"md5": "^2.2.1",
|
||||
"mqtt": "^5.14.0",
|
||||
"mysql2": "^1.7.0",
|
||||
"mysql2": "^3.15.1",
|
||||
"node-schedule": "latest",
|
||||
"nodemailer": "^6.9.7",
|
||||
"node-uuid": "^1.4.8",
|
||||
"redis": "^5.8.3",
|
||||
"redis": "^3.0.2",
|
||||
"sequelize": "^5.22.5",
|
||||
"swagger-jsdoc": "^6.2.8",
|
||||
"uuid": "^8.3.2",
|
||||
|
||||
Reference in New Issue
Block a user