From 71c6e8d9c16a9aa6d27d8b609fea68a3388744d0 Mon Sep 17 00:00:00 2001 From: nanguaboss <602995148@qq.com> Date: Mon, 3 Aug 2026 00:07:01 +0800 Subject: [PATCH] v1.0.10 --- .htaccess | 0 README.md | 98 ++++++ api/auth/forgot.php | 37 ++ api/auth/login.php | 67 ++++ api/auth/logout.php | 26 ++ api/auth/session.php | 24 ++ api/channel/delete.php | 36 ++ api/channel/list.php | 50 +++ api/channel/save.php | 53 +++ api/channel/stats.php | 44 +++ api/common/auth.php | 69 ++++ api/common/db.php | 43 +++ api/common/helpers.php | 92 +++++ api/common/logger.php | 57 +++ api/common/response.php | 34 ++ api/company/add.php | 30 ++ api/company/delete.php | 40 +++ api/company/detail.php | 74 ++++ api/company/employees.php | 81 +++++ api/company/export.php | 58 +++ api/company/import.php | 84 +++++ api/company/list.php | 67 ++++ api/company/update.php | 40 +++ api/dashboard/stats.php | 194 +++++++++++ api/document/delete.php | 54 +++ api/document/list.php | 49 +++ api/document/upload.php | 75 ++++ api/marketing/data_custom.php | 29 ++ api/marketing/edm.php | 114 ++++++ api/marketing/edm_export.php | 56 +++ api/marketing/media_wholesale.php | 28 ++ api/media/add.php | 57 +++ api/media/delete.php | 36 ++ api/media/detail.php | 30 ++ api/media/export.php | 65 ++++ api/media/import.php | 95 +++++ api/media/list.php | 77 ++++ api/media/update.php | 54 +++ api/need/add.php | 32 ++ api/need/delete.php | 36 ++ api/need/detail.php | 29 ++ api/need/list.php | 61 ++++ api/need/push.php | 46 +++ api/need/update.php | 42 +++ api/person/add.php | 53 +++ api/person/companies.php | 52 +++ api/person/delete.php | 36 ++ api/person/detail.php | 38 ++ api/person/export.php | 59 ++++ api/person/import.php | 82 +++++ api/person/list.php | 61 ++++ api/person/update.php | 58 +++ api/preliminary/list.php | 51 +++ api/preliminary/stats.php | 30 ++ api/system/log_list.php | 58 +++ api/system/role_add.php | 37 ++ api/system/role_delete.php | 53 +++ api/system/role_list.php | 47 +++ api/system/role_update.php | 69 ++++ api/system/user_add.php | 52 +++ api/system/user_delete.php | 40 +++ api/system/user_list.php | 50 +++ api/system/user_update.php | 70 ++++ bump_version.bat | 29 ++ channel.html | 17 + company.html | 17 + config/database.php | 13 + dashboard.html | 20 ++ data_custom.html | 17 + document.html | 17 + edm.html | 17 + index.html | 43 +++ log.html | 17 + media.html | 17 + media_wholesale.html | 17 + need.html | 17 + nginx.htaccess | 0 person.html | 17 + preliminary.html | 17 + role.html | 17 + sql/system_tables.sql | 105 ++++++ static/css/login.css | 92 +++++ static/css/slider.css | 56 +++ static/css/style.css | 523 ++++++++++++++++++++++++++++ static/js/channel.js | 173 +++++++++ static/js/common.js | 415 ++++++++++++++++++++++ static/js/company.js | 326 +++++++++++++++++ static/js/config.js | 123 +++++++ static/js/dashboard.js | 252 ++++++++++++++ static/js/document.js | 104 ++++++ static/js/login.js | 97 ++++++ static/js/marketing.js | 197 +++++++++++ static/js/media.js | 263 ++++++++++++++ static/js/need.js | 190 ++++++++++ static/js/person.js | 317 +++++++++++++++++ static/js/preliminary.js | 96 +++++ static/js/system.js | 416 ++++++++++++++++++++++ static/libs/china.js | 46 +++ static/libs/echarts.min.js | 45 +++ static/libs/icon-ext.png | Bin 0 -> 2708 bytes static/libs/icon.png | Bin 0 -> 6697 bytes static/libs/jquery-3.6.0.min.js | 2 + static/libs/layer.css | 1 + static/libs/layer.js | 2 + static/libs/loading-0.gif | Bin 0 -> 5793 bytes static/libs/loading-1.gif | Bin 0 -> 701 bytes static/libs/loading-2.gif | Bin 0 -> 1787 bytes static/libs/slider.js | 120 +++++++ static/libs/world.js | 20 ++ static/uploads/documents/index.html | 1 + tools/bump_version.php | 48 +++ user.html | 17 + 112 files changed, 7815 insertions(+) create mode 100644 .htaccess create mode 100644 README.md create mode 100644 api/auth/forgot.php create mode 100644 api/auth/login.php create mode 100644 api/auth/logout.php create mode 100644 api/auth/session.php create mode 100644 api/channel/delete.php create mode 100644 api/channel/list.php create mode 100644 api/channel/save.php create mode 100644 api/channel/stats.php create mode 100644 api/common/auth.php create mode 100644 api/common/db.php create mode 100644 api/common/helpers.php create mode 100644 api/common/logger.php create mode 100644 api/common/response.php create mode 100644 api/company/add.php create mode 100644 api/company/delete.php create mode 100644 api/company/detail.php create mode 100644 api/company/employees.php create mode 100644 api/company/export.php create mode 100644 api/company/import.php create mode 100644 api/company/list.php create mode 100644 api/company/update.php create mode 100644 api/dashboard/stats.php create mode 100644 api/document/delete.php create mode 100644 api/document/list.php create mode 100644 api/document/upload.php create mode 100644 api/marketing/data_custom.php create mode 100644 api/marketing/edm.php create mode 100644 api/marketing/edm_export.php create mode 100644 api/marketing/media_wholesale.php create mode 100644 api/media/add.php create mode 100644 api/media/delete.php create mode 100644 api/media/detail.php create mode 100644 api/media/export.php create mode 100644 api/media/import.php create mode 100644 api/media/list.php create mode 100644 api/media/update.php create mode 100644 api/need/add.php create mode 100644 api/need/delete.php create mode 100644 api/need/detail.php create mode 100644 api/need/list.php create mode 100644 api/need/push.php create mode 100644 api/need/update.php create mode 100644 api/person/add.php create mode 100644 api/person/companies.php create mode 100644 api/person/delete.php create mode 100644 api/person/detail.php create mode 100644 api/person/export.php create mode 100644 api/person/import.php create mode 100644 api/person/list.php create mode 100644 api/person/update.php create mode 100644 api/preliminary/list.php create mode 100644 api/preliminary/stats.php create mode 100644 api/system/log_list.php create mode 100644 api/system/role_add.php create mode 100644 api/system/role_delete.php create mode 100644 api/system/role_list.php create mode 100644 api/system/role_update.php create mode 100644 api/system/user_add.php create mode 100644 api/system/user_delete.php create mode 100644 api/system/user_list.php create mode 100644 api/system/user_update.php create mode 100644 bump_version.bat create mode 100644 channel.html create mode 100644 company.html create mode 100644 config/database.php create mode 100644 dashboard.html create mode 100644 data_custom.html create mode 100644 document.html create mode 100644 edm.html create mode 100644 index.html create mode 100644 log.html create mode 100644 media.html create mode 100644 media_wholesale.html create mode 100644 need.html create mode 100644 nginx.htaccess create mode 100644 person.html create mode 100644 preliminary.html create mode 100644 role.html create mode 100644 sql/system_tables.sql create mode 100644 static/css/login.css create mode 100644 static/css/slider.css create mode 100644 static/css/style.css create mode 100644 static/js/channel.js create mode 100644 static/js/common.js create mode 100644 static/js/company.js create mode 100644 static/js/config.js create mode 100644 static/js/dashboard.js create mode 100644 static/js/document.js create mode 100644 static/js/login.js create mode 100644 static/js/marketing.js create mode 100644 static/js/media.js create mode 100644 static/js/need.js create mode 100644 static/js/person.js create mode 100644 static/js/preliminary.js create mode 100644 static/js/system.js create mode 100644 static/libs/china.js create mode 100644 static/libs/echarts.min.js create mode 100644 static/libs/icon-ext.png create mode 100644 static/libs/icon.png create mode 100644 static/libs/jquery-3.6.0.min.js create mode 100644 static/libs/layer.css create mode 100644 static/libs/layer.js create mode 100644 static/libs/loading-0.gif create mode 100644 static/libs/loading-1.gif create mode 100644 static/libs/loading-2.gif create mode 100644 static/libs/slider.js create mode 100644 static/libs/world.js create mode 100644 static/uploads/documents/index.html create mode 100644 tools/bump_version.php create mode 100644 user.html diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md new file mode 100644 index 0000000..0644665 --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# SuperLink Web 后台管理系统 + +基于 **PHP原生 + MySQL + jQuery** 的 Web 后台管理系统(无框架,纯 HTML+CSS+JS+JQ)。 + +## 一、部署环境(文档固定) + +| 项目 | 值 | +| --- | --- | +| 服务器 | phpstudy_pro | +| Web服务器 | Nginx 1.15.11 | +| PHP | 7.4.x | +| 数据库 | MySQL 5.7.26 | +| 数据库名 | mysuperlink(原库已有14张业务表) | +| 字符集 | utf8mb4 | +| 根目录 | C:/phpstudy_pro/WWW/mysuperlink/ | + +数据库连接信息(`config/database.php` 已按此配置,可按需修改): +- 主机 `127.0.0.1` / 端口 `3306` / 库名 `mysuperlink` +- 用户 `mysuperlink_root` / 密码 `123456` + +## 二、部署步骤 + +1. 将本项目整个 `mysuperlink/` 文件夹复制到 `C:/phpstudy_pro/WWW/mysuperlink/`。 +2. 在 Navicat/phpMyAdmin 中对 `mysuperlink` 库执行 `sql/system_tables.sql`: + - 新建 3 张系统表:`system_users` / `system_roles` / `system_logs`(含默认数据) + - 新建 1 张业务表:`channels`(渠道管理模块需要,原14张表中没有) +3. 确保 `static/uploads/documents/` 目录可写(PHP上传文件使用)。 +4. 浏览器访问 `http://localhost/mysuperlink/` 即可。 + +**默认账号:admin / admin123**(密码为 sha1 加密存储)。 + +## 三、目录结构 + +``` +mysuperlink/ +├── index.html / dashboard.html / preliminary.html / company.html / person.html +├── media.html / need.html / edm.html / data_custom.html / media_wholesale.html +├── channel.html / document.html / user.html / role.html / log.html (共14个页面) +├── api/ # 所有PHP接口(严禁HTML混写) +│ ├── common/ # db.php(PDO单例) / response.php(统一JSON) / auth.php(Session鉴权) / logger.php(操作日志) / helpers.php +│ ├── auth/ # login.php(滑块验证) / logout.php / forgot.php / session.php(会话校验) +│ ├── dashboard/ # stats.php(8指标卡+行业柱状图+区域+词云) +│ ├── preliminary/ # list.php / stats.php +│ ├── company/ person/ media/ need/ # list/detail/add/update/delete/import/export (+push) +│ ├── marketing/ # edm.php(行业+区域二级联动) / edm_export.php / data_custom.php / media_wholesale.php +│ ├── channel/ # list.php / stats.php / save.php / delete.php +│ ├── document/ # list.php / upload.php(存本地) / delete.php(删物理文件) +│ └── system/ # user_list/add/update/delete / role_list/add/update/delete / log_list(仅超管) +├── static/ +│ ├── css/ # style.css / login.css / slider.css +│ ├── js/ # config.js(含MENU_MAP) / common.js / login.js / dashboard.js / 各模块js +│ ├── libs/ # jquery-3.6.0 / echarts / layer / world.js / slider.js(自研) +│ └── uploads/documents/ # 文件物理存储(按年/月自动分目录) +├── config/database.php # 数据库配置(返回数组) +└── sql/ + ├── init.sql # (原库已有14张表,此处不重复提供) + └── system_tables.sql # ★ 新增3张系统表 + channels表 + 默认数据(必须执行) +``` + +## 四、接口规范 + +统一返回 JSON:`{ "code": 0(成功)/非0(失败), "msg": "提示", "data": {} }` + +- 所有写类操作必须携带 `X-Requested-With: XMLHttpRequest` 头(前端 common.js 已自动处理)。 +- 除登录/找回密码外,所有接口需 Session 鉴权;无权限返回 `403`;未登录返回 `401`。 +- 所有写类接口(add/update/delete/import/export/login/logout/push/audit/convert)自动调用 `logAction()` 写操作日志。 + +### 列表接口通用参数 +`page`(默认1)、`limit`(默认20)、`keyword`(关键词)、各模块专属筛选: +- 企业:`industry` / `business_role` / `country` +- 人员:`gender` / `nationality` / `work_location` +- 媒体:`platform` / `certification_type` / `account_level` / `owner_type` +- 需求:`start_date` / `end_date` / `company_name` / `industry` / `need_category` +- 日志:`action` / `module` / `username` / `start_date` / `end_date` + +## 五、权限控制 + +- 登录后返回 `permissions` 数组,前端按 `MENU_MAP`(config.js)动态渲染左侧菜单。 +- 后端每个接口调用 `checkPermission($module)` 二次校验,写操作另有 `checkAjax()` 校验。 +- 操作日志(`log.html`)仅超级管理员可见(`requireSuperAdmin()`)。 +- 保护规则:内置管理员(id=1)不可删除/禁用;超级管理员角色(id=1)不可修改/删除;被用户使用的角色不可删除。 + +## 六、占位/简化说明(按文档要求) + +- **滑块验证**:纯前端实现(static/libs/slider.js),拖动通过后生成 token 提交登录;服务端校验 token 合法性。如需更安全可改为服务端签发。 +- **找回密码**:占位实现,校验账号后返回成功提示,未接入SMTP。 +- **需求推送 push.php**:占位实现,返回成功并写日志,后续接入第三方API/SMTP。 +- **数据定制 / 媒体批发**:占位页面。 +- **Dashboard 区域分布**:先用国家/地区柱状图(简化方案),后续可细化世界地图(world.js 已内置,可切换为地图渲染)。 +- **需求词云**:从 company_needs.description 做简易分词(占位),后续接入专业分词服务。 +- **渠道模块**:原14张表中无渠道表,已在 system_tables.sql 中新增 `channels` 表。 +- **日志清理**:每月1日执行 `DELETE FROM system_logs WHERE created_at < DATE_SUB(NOW(), INTERVAL 6 MONTH);`(可加cron计划任务)。 + +## 七、CSV 导入模板 + +- 企业:表头 `name_zh,name_en,display_name,business_role,country,registration_number,address,legal_form,legal_representative,industry,industry_subdivision,website,registered_capital,established_date,is_listed,stock_code`(display_name 必填,UTF-8编码) +- 人员:表头 `union_id,full_name,gender,nationality,id_type,id_number,education,graduated_from,hometown,work_location,phone,email`(full_name 必填,union_id 可留空自动生成) +- 媒体:表头 `owner_type,owner_id,platform,account_id,profile_url,remark,is_primary,account_level,content_categories,follower_count,avg_read_count,certification_type`(owner_id 可填名称自动解析) diff --git a/api/auth/forgot.php b/api/auth/forgot.php new file mode 100644 index 0000000..88bc894 --- /dev/null +++ b/api/auth/forgot.php @@ -0,0 +1,37 @@ +getPdo(); +$stmt = $pdo->prepare("SELECT id, username, real_name FROM system_users WHERE username = ? AND is_active = 1 LIMIT 1"); +$stmt->execute([$username]); +$user = $stmt->fetch(); + +if (!$user) { + Response::error('账号不存在或已禁用'); +} + +// TODO: 接入 SMTP 后向 contactEmail 发送重置链接/验证码 +logAction((int)$user['id'], $user['username'], 'forgot', 'auth', 'system_users', (int)$user['id'], ['contact_email' => $contactEmail]); + +Response::success(null, '重置邮件已发送(占位实现,请接入SMTP后生效)'); diff --git a/api/auth/login.php b/api/auth/login.php new file mode 100644 index 0000000..56cd108 --- /dev/null +++ b/api/auth/login.php @@ -0,0 +1,67 @@ +getPdo(); +$stmt = $pdo->prepare( + "SELECT u.*, r.role_name, r.permissions + FROM system_users u + LEFT JOIN system_roles r ON r.id = u.role_id + WHERE u.username = ? LIMIT 1" +); +$stmt->execute([$username]); +$user = $stmt->fetch(); + +if (!$user || !hash_equals($user['password'], sha1($password))) { + Response::error('账号或密码错误'); +} +if ((int)$user['is_active'] !== 1) { + Response::error('账号已被禁用,请联系管理员'); +} + +// 更新最后登录时间/IP +$ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; +$upd = $pdo->prepare("UPDATE system_users SET last_login_time = NOW(), last_login_ip = ? WHERE id = ?"); +$upd->execute([$ip, $user['id']]); + +// 写入 Session +if (session_status() === PHP_SESSION_NONE) { + session_start(); +} +$_SESSION['user_id'] = (int)$user['id']; +$_SESSION['username'] = $user['username']; +$_SESSION['real_name'] = $user['real_name'] ?? $user['username']; +$_SESSION['role_id'] = (int)$user['role_id']; +$_SESSION['role_name'] = $user['role_name'] ?? ''; +$_SESSION['permissions'] = json_decode($user['permissions'] ?? '[]', true) ?: []; + +// 操作日志 +logAction((int)$user['id'], $user['username'], 'login', 'auth', 'system_users', (int)$user['id'], ['ip' => $ip]); + +Response::success([ + 'real_name' => $_SESSION['real_name'], + 'role_name' => $_SESSION['role_name'], + 'permissions' => $_SESSION['permissions'], +], '登录成功'); diff --git a/api/auth/logout.php b/api/auth/logout.php new file mode 100644 index 0000000..64db07f --- /dev/null +++ b/api/auth/logout.php @@ -0,0 +1,26 @@ +getPdo(); +$stmt = $pdo->prepare("SELECT last_login_time, last_login_ip FROM system_users WHERE id = ?"); +$stmt->execute([(int)$_SESSION['user_id']]); +$loginInfo = $stmt->fetch() ?: []; + +Response::success([ + 'user_id' => (int)$_SESSION['user_id'], + 'username' => $_SESSION['username'], + 'real_name' => $_SESSION['real_name'], + 'role_name' => $_SESSION['role_name'], + 'permissions' => $_SESSION['permissions'], + 'last_login_time' => $loginInfo['last_login_time'] ?? null, + 'last_login_ip' => $loginInfo['last_login_ip'] ?? null, +]); diff --git a/api/channel/delete.php b/api/channel/delete.php new file mode 100644 index 0000000..30c1d11 --- /dev/null +++ b/api/channel/delete.php @@ -0,0 +1,36 @@ + 0) $idList[] = $id; +if ($ids !== '') { + foreach (explode(',', $ids) as $v) { + $v = (int)trim($v); + if ($v > 0) $idList[] = $v; + } +} +$idList = array_unique($idList); +if (!$idList) { + Response::error('参数错误', 400); +} + +$pdo = DB::getInstance()->getPdo(); +$in = implode(',', array_fill(0, count($idList), '?')); +$stmt = $pdo->prepare("DELETE FROM channels WHERE id IN ($in)"); +$stmt->execute($idList); +$affected = $stmt->rowCount(); + +logCurrent('delete', 'channel', 'channels', null, ['ids' => $idList]); +Response::success(['affected' => $affected], "已删除 $affected 个渠道"); diff --git a/api/channel/list.php b/api/channel/list.php new file mode 100644 index 0000000..e0362dc --- /dev/null +++ b/api/channel/list.php @@ -0,0 +1,50 @@ +getPdo(); + +$stmt = $pdo->prepare("SELECT COUNT(*) FROM channels WHERE $whereSql"); +$stmt->execute($params); +$total = (int)$stmt->fetchColumn(); + +$offset = ($page - 1) * $limit; +$stmt = $pdo->prepare( + "SELECT id, channel_name, channel_type, contact_person, contact_phone, contact_email, + efficiency_score, remark, is_active, created_at, updated_at + FROM channels + WHERE $whereSql + ORDER BY id DESC + LIMIT $limit OFFSET $offset" +); +$stmt->execute($params); +$list = $stmt->fetchAll(); + +Response::success(['list' => $list, 'total' => $total, 'page' => $page, 'limit' => $limit]); diff --git a/api/channel/save.php b/api/channel/save.php new file mode 100644 index 0000000..001cec4 --- /dev/null +++ b/api/channel/save.php @@ -0,0 +1,53 @@ + $channelName, + 'channel_type' => trim($_POST['channel_type'] ?? '') ?: null, + 'contact_person' => trim($_POST['contact_person'] ?? '') ?: null, + 'contact_phone' => trim($_POST['contact_phone'] ?? '') ?: null, + 'contact_email' => trim($_POST['contact_email'] ?? '') ?: null, + 'remark' => trim($_POST['remark'] ?? '') ?: null, +]; +$score = (float)($_POST['efficiency_score'] ?? 0); +$fields['efficiency_score'] = max(0, min(100, $score)); + +$pdo = DB::getInstance()->getPdo(); + +if ($id > 0) { + $check = $pdo->prepare("SELECT * FROM channels WHERE id = ?"); + $check->execute([$id]); + $old = $check->fetch(); + if (!$old) { + Response::error('渠道不存在'); + } + [$sets, $params] = buildUpdate($fields); + $params[] = $id; + $pdo->prepare("UPDATE channels SET $sets WHERE id = ?")->execute($params); + logCurrent('update', 'channel', 'channels', $id, ['before' => $old, 'after' => $fields]); + Response::success(['id' => $id], '更新成功'); +} + +[$sql, $params] = buildInsert($fields); +$pdo->prepare("INSERT INTO channels $sql")->execute($params); +$newId = (int)$pdo->lastInsertId(); +logCurrent('add', 'channel', 'channels', $newId, $fields); +Response::success(['id' => $newId], '新增成功'); diff --git a/api/channel/stats.php b/api/channel/stats.php new file mode 100644 index 0000000..4a4bf2d --- /dev/null +++ b/api/channel/stats.php @@ -0,0 +1,44 @@ + 2100) { + $year = (int)date('Y'); +} + +$pdo = DB::getInstance()->getPdo(); + +$rows = $pdo->prepare( + "SELECT id, channel_name, channel_type, efficiency_score, created_at + FROM channels + WHERE is_active = 1 AND YEAR(created_at) = ? + ORDER BY efficiency_score DESC" +); +$rows->execute([$year]); +$list = $rows->fetchAll(); + +$summary = [ + 'channel_count' => count($list), + 'avg_score' => 0, + 'max_score' => 0, + 'min_score' => 0, +]; +if ($list) { + $scores = array_column($list, 'efficiency_score'); + $summary['avg_score'] = round(array_sum($scores) / count($scores), 2); + $summary['max_score'] = (float)max($scores); + $summary['min_score'] = (float)min($scores); +} + +// 可用年度(用于前端下拉) +$years = $pdo->query("SELECT DISTINCT YEAR(created_at) AS y FROM channels WHERE is_active = 1 ORDER BY y DESC")->fetchAll(PDO::FETCH_COLUMN); + +Response::success(['year' => $year, 'list' => $list, 'summary' => $summary, 'years' => $years]); diff --git a/api/common/auth.php b/api/common/auth.php new file mode 100644 index 0000000..c35a179 --- /dev/null +++ b/api/common/auth.php @@ -0,0 +1,69 @@ +getPdo(); + */ +class DB +{ + private static $instance = null; + private $pdo; + + private function __construct() + { + $cfg = require __DIR__ . '/../../config/database.php'; + $dsn = sprintf( + 'mysql:host=%s;port=%d;dbname=%s;charset=%s', + $cfg['host'], + $cfg['port'], + $cfg['dbname'], + $cfg['charset'] + ); + $this->pdo = new PDO($dsn, $cfg['username'], $cfg['password'], [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ]); + } + + public static function getInstance() + { + if (self::$instance === null) { + self::$instance = new self(); + } + return self::$instance; + } + + public function getPdo() + { + return $this->pdo; + } + + // 禁止克隆 + private function __clone() {} +} diff --git a/api/common/helpers.php b/api/common/helpers.php new file mode 100644 index 0000000..b529491 --- /dev/null +++ b/api/common/helpers.php @@ -0,0 +1,92 @@ + 类型标记:s字符串/i整数/d日期/n数字) */ +const COMPANY_FIELDS = [ + 'name_zh' => 's', 'name_en' => 's', 'display_name' => 's', + 'business_role' => 's', 'country' => 's', 'registration_number' => 's', + 'address' => 's', 'legal_form' => 's', 'legal_representative' => 's', + 'business_scope' => 's', 'established_date' => 'd', 'registered_capital' => 's', + 'industry' => 's', 'industry_subdivision' => 's', + 'latest_employee_count' => 'i', 'latest_annual_revenue' => 's', + 'is_listed' => 'i', 'stock_code' => 's', 'website' => 's', +]; + +/** 人员表可写入字段白名单 */ +const PERSON_FIELDS = [ + 'union_id' => 's', 'full_name' => 's', 'gender' => 's', 'nationality' => 's', + 'id_type' => 's', 'id_number' => 's', 'education' => 's', 'graduated_from' => 's', + 'hometown' => 's', 'work_location' => 's', +]; + +/** 媒体(社交账号)表可写入字段白名单 */ +const MEDIA_FIELDS = [ + 'owner_type' => 's', 'owner_id' => 'i', 'platform' => 's', 'account_id' => 's', + 'profile_url' => 's', 'remark' => 's', 'is_primary' => 'i', +]; + +/** 媒体商业属性表可写入字段白名单 */ +const MEDIA_ATTR_FIELDS = [ + 'account_level' => 's', 'content_categories' => 's', 'follower_count' => 'i', + 'avg_read_count' => 'i', 'certification_type' => 's', + 'special_requirements' => 's', 'media_remark' => 's', +]; + +/** 需求表可写入字段白名单 */ +const NEED_FIELDS = [ + 'company_id' => 'i', 'contact_person' => 's', 'need_category' => 's', + 'target_product_category' => 's', 'application_scenario' => 's', 'description' => 's', +]; + +/** + * 从 POST 中按白名单提取并清洗数据 + * @param array $allowlist + * @return array + */ +function extractFields($allowlist) +{ + $data = []; + foreach ($allowlist as $key => $type) { + $val = $_POST[$key] ?? null; + if ($val === null) { + continue; + } + $val = trim((string)$val); + if ($val === '') { + continue; + } + switch ($type) { + case 'i': + $data[$key] = (int)$val; + break; + case 'd': + $data[$key] = (strtotime($val) !== false) ? date('Y-m-d', strtotime($val)) : null; + break; + default: + $data[$key] = $val; + } + } + return $data; +} + +/** 生成 INSERT 语句片段:字段名列表 + 占位符 */ +function buildInsert($data) +{ + $cols = array_keys($data); + $sql = '(`' . implode('`,`', $cols) . '`) VALUES (' . rtrim(str_repeat('?,', count($cols)), ',') . ')'; + return [$sql, array_values($data)]; +} + +/** 生成 UPDATE SET 片段 */ +function buildUpdate($data) +{ + $sets = []; + foreach (array_keys($data) as $col) { + $sets[] = "`$col` = ?"; + } + return [implode(',', $sets), array_values($data)]; +} diff --git a/api/common/logger.php b/api/common/logger.php new file mode 100644 index 0000000..8884002 --- /dev/null +++ b/api/common/logger.php @@ -0,0 +1,57 @@ +getPdo(); + $stmt = $pdo->prepare( + "INSERT INTO system_logs (user_id, username, action, module, target_table, target_id, content, ip) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + ); + $contentJson = ($content !== null && $content !== '') + ? json_encode($content, JSON_UNESCAPED_UNICODE) + : null; + return $stmt->execute([$userId, $username, $action, $module, $targetTable, $targetId, $contentJson, $ip]); + } catch (Exception $e) { + // 日志失败不影响主流程 + return false; + } +} + +/** + * 基于当前 Session 用户写入日志(便捷函数) + * @param string $action + * @param string $module + * @param string|null $targetTable + * @param int|null $targetId + * @param mixed $content + */ +function logCurrent($action, $module, $targetTable = null, $targetId = null, $content = null) +{ + return logAction( + $_SESSION['user_id'] ?? null, + $_SESSION['username'] ?? null, + $action, + $module, + $targetTable, + $targetId, + $content + ); +} diff --git a/api/common/response.php b/api/common/response.php new file mode 100644 index 0000000..3ee48f4 --- /dev/null +++ b/api/common/response.php @@ -0,0 +1,34 @@ + 0, 'msg' => $msg, 'data' => $data]); + } + + /** + * 失败返回 + * @param string $msg + * @param int $code 非0错误码(401未登录/403无权限/400参数错误/1业务错误) + */ + public static function error($msg = '操作失败', $code = 1) + { + self::json(['code' => $code, 'msg' => $msg]); + } + + private static function json($payload) + { + header('Content-Type: application/json; charset=utf-8'); + echo json_encode($payload, JSON_UNESCAPED_UNICODE); + exit; + } +} diff --git a/api/company/add.php b/api/company/add.php new file mode 100644 index 0000000..374ee0c --- /dev/null +++ b/api/company/add.php @@ -0,0 +1,30 @@ +getPdo(); +[$sql, $params] = buildInsert($data); +$pdo->prepare("INSERT INTO companies $sql")->execute($params); +$newId = (int)$pdo->lastInsertId(); + +logCurrent('add', 'company', 'companies', $newId, $data); +Response::success(['id' => $newId], '新增成功'); diff --git a/api/company/delete.php b/api/company/delete.php new file mode 100644 index 0000000..598e71d --- /dev/null +++ b/api/company/delete.php @@ -0,0 +1,40 @@ + 0) { + $idList[] = $id; +} +if ($ids !== '') { + foreach (explode(',', $ids) as $v) { + $v = (int)trim($v); + if ($v > 0) { + $idList[] = $v; + } + } +} +$idList = array_unique($idList); +if (!$idList) { + Response::error('参数错误', 400); +} + +$pdo = DB::getInstance()->getPdo(); +$in = implode(',', array_fill(0, count($idList), '?')); +$stmt = $pdo->prepare("UPDATE companies SET is_active = 0 WHERE id IN ($in)"); +$stmt->execute($idList); +$affected = $stmt->rowCount(); + +logCurrent('delete', 'company', 'companies', null, ['ids' => $idList]); +Response::success(['affected' => $affected], "已删除 $affected 条记录"); diff --git a/api/company/detail.php b/api/company/detail.php new file mode 100644 index 0000000..aaa3dfe --- /dev/null +++ b/api/company/detail.php @@ -0,0 +1,74 @@ +getPdo(); + +$stmt = $pdo->prepare("SELECT * FROM companies WHERE id = ?"); +$stmt->execute([$id]); +$company = $stmt->fetch(); +if (!$company) { + Response::error('企业不存在'); +} + +$products = $pdo->prepare("SELECT id, category_name, category_description, is_core, is_active FROM company_products WHERE company_id = ? AND is_active = 1"); +$products->execute([$id]); + +$needs = $pdo->prepare("SELECT id, contact_person, need_category, target_product_category, application_scenario, description, is_valid, created_at FROM company_needs WHERE company_id = ? ORDER BY id DESC"); +$needs->execute([$id]); + +$docs = $pdo->prepare( + "SELECT cd.id, cd.doc_name, cd.storage_path, cd.file_type, cd.publish_date, cd.is_active + FROM company_documents cd + INNER JOIN document_links dl ON dl.document_id = cd.id + WHERE dl.owner_type = 'company' AND dl.owner_id = ? AND cd.is_active = 1" +); +$docs->execute([$id]); + +$financials = $pdo->prepare("SELECT * FROM company_financials WHERE company_id = ? ORDER BY fiscal_year DESC"); +$financials->execute([$id]); + +$relations = $pdo->prepare( + "SELECT cr.id, cr.relation_type, cr.is_direct, cr.ownership_percentage, cr.established_date, c.display_name AS child_name + FROM company_relations cr + LEFT JOIN companies c ON c.id = cr.child_company_id + WHERE cr.parent_company_id = ?" +); +$relations->execute([$id]); + +// 关联联系人(工作经历 + 联系方式) +$contacts = $pdo->prepare( + "SELECT p.id, p.full_name, + (SELECT GROUP_CONCAT(CONCAT_WS(' ', pwe2.position, pwe2.department) SEPARATOR ' / ') + FROM person_work_experiences pwe2 + WHERE pwe2.company_id = ? AND pwe2.person_id = p.id AND pwe2.is_active = 1) AS position, + (SELECT GROUP_CONCAT(CONCAT(sa.platform, ':', sa.account_id) SEPARATOR ' | ') + FROM social_accounts sa WHERE sa.owner_type = 'person' AND sa.owner_id = p.id AND sa.is_active = 1) AS contacts + FROM person_work_experiences pwe + LEFT JOIN persons p ON p.id = pwe.person_id + WHERE pwe.company_id = ? AND pwe.is_active = 1 + GROUP BY p.id, p.full_name" +); +$contacts->execute([$id, $id]); + +Response::success([ + 'company' => $company, + 'products' => $products->fetchAll(), + 'needs' => $needs->fetchAll(), + 'documents' => $docs->fetchAll(), + 'financials' => $financials->fetchAll(), + 'relations' => $relations->fetchAll(), + 'contacts' => $contacts->fetchAll(), +]); diff --git a/api/company/employees.php b/api/company/employees.php new file mode 100644 index 0000000..bac7aaa --- /dev/null +++ b/api/company/employees.php @@ -0,0 +1,81 @@ +getPdo(); + +// 公司存在性校验 +$chk = $pdo->prepare("SELECT COUNT(*) FROM companies WHERE id = ?"); +$chk->execute([$companyId]); +if ((int)$chk->fetchColumn() === 0) { + Response::error('企业不存在'); +} + +$where = ['pwe.company_id = ?', 'pwe.is_active = 1']; +$params = [$companyId]; +if ($keyword !== '') { + $where[] = 'p.full_name LIKE ?'; + $params[] = "%$keyword%"; +} +if ($jobLevel !== '') { + $where[] = 'pwe.job_level = ?'; + $params[] = $jobLevel; +} +$whereSql = implode(' AND ', $where); + +// 总数 +$stmt = $pdo->prepare( + "SELECT COUNT(*) FROM person_work_experiences pwe + INNER JOIN persons p ON p.id = pwe.person_id + WHERE $whereSql" +); +$stmt->execute($params); +$total = (int)$stmt->fetchColumn(); + +// 列表 +$offset = ($page - 1) * $limit; +$stmt = $pdo->prepare( + "SELECT p.id AS person_id, p.full_name, pwe.id AS work_id, pwe.position, pwe.department, + pwe.job_level, pwe.start_date, pwe.end_date, pwe.is_current, + (SELECT sa.account_id FROM social_accounts sa + WHERE sa.owner_type = 'person' AND sa.owner_id = p.id AND sa.platform = 'phone' AND sa.is_active = 1 + ORDER BY sa.is_primary DESC LIMIT 1) AS phone, + (SELECT sa.account_id FROM social_accounts sa + WHERE sa.owner_type = 'person' AND sa.owner_id = p.id AND sa.platform = 'email' AND sa.is_active = 1 + ORDER BY sa.is_primary DESC LIMIT 1) AS email + FROM person_work_experiences pwe + INNER JOIN persons p ON p.id = pwe.person_id + WHERE $whereSql + ORDER BY pwe.is_current DESC, pwe.start_date DESC + LIMIT $limit OFFSET $offset" +); +$stmt->execute($params); +$list = $stmt->fetchAll(); + +// 职级字典(该公司范围内,供筛选下拉) +$lv = $pdo->prepare( + "SELECT DISTINCT job_level FROM person_work_experiences + WHERE company_id = ? AND is_active = 1 AND job_level IS NOT NULL AND job_level <> '' + ORDER BY job_level" +); +$lv->execute([$companyId]); +$levels = $lv->fetchAll(PDO::FETCH_COLUMN); + +Response::success(['list' => $list, 'total' => $total, 'page' => $page, 'limit' => $limit, 'levels' => $levels]); diff --git a/api/company/export.php b/api/company/export.php new file mode 100644 index 0000000..3a41405 --- /dev/null +++ b/api/company/export.php @@ -0,0 +1,58 @@ + 0) { + $idList[] = $v; + } + } +} +$idList = array_unique($idList); +if (!$idList) { + Response::error('请先选择需要导出的企业数据', 1); +} + +$pdo = DB::getInstance()->getPdo(); +$in = implode(',', array_fill(0, count($idList), '?')); +$stmt = $pdo->prepare( + "SELECT id, name_zh, name_en, business_role, country, registration_number, + address, legal_representative, industry, industry_subdivision, website, + registered_capital, established_date, latest_employee_count, latest_annual_revenue, + is_listed, stock_code, created_at + FROM companies WHERE id IN ($in) AND is_active = 1 ORDER BY id DESC" +); +$stmt->execute($idList); +$list = $stmt->fetchAll(); + +logCurrent('export', 'company', 'companies', null, ['count' => count($list), 'ids' => $idList]); + +header('Content-Type: text/csv; charset=utf-8'); +header('Content-Disposition: attachment; filename="companies_' . date('Ymd_His') . '.csv"'); +echo "\xEF\xBB\xBF"; // UTF-8 BOM,便于 Excel 直接打开 +$out = fopen('php://output', 'w'); +fputcsv($out, ['ID', '公司名称', '英文名称', '业务角色', '国家/地区', '注册号', '地址', '法人', '行业', '行业细分', '官网', '注册资本', '成立日期', '员工数', '年营收', '是否上市', '股票代码', '创建时间']); +foreach ($list as $r) { + fputcsv($out, [ + $r['id'], $r['name_zh'], $r['name_en'], $r['business_role'], + $r['country'], $r['registration_number'], $r['address'], $r['legal_representative'], + $r['industry'], $r['industry_subdivision'], $r['website'], $r['registered_capital'], + $r['established_date'], $r['latest_employee_count'], $r['latest_annual_revenue'], + $r['is_listed'], $r['stock_code'], date('Y-m-d', strtotime($r['created_at'])), + ]); +} +fclose($out); +exit; diff --git a/api/company/import.php b/api/company/import.php new file mode 100644 index 0000000..2c44fd0 --- /dev/null +++ b/api/company/import.php @@ -0,0 +1,84 @@ + 'name_zh', 'name_en' => 'name_en', 'display_name' => 'display_name', + 'business_role' => 'business_role', 'country' => 'country', 'registration_number' => 'registration_number', + 'address' => 'address', 'legal_form' => 'legal_form', 'legal_representative' => 'legal_representative', + 'industry' => 'industry', 'industry_subdivision' => 'industry_subdivision', 'website' => 'website', + 'registered_capital' => 'registered_capital', 'established_date' => 'established_date', + 'is_listed' => 'is_listed', 'stock_code' => 'stock_code', +]; + +$pdo = DB::getInstance()->getPdo(); +$inserted = 0; +$failed = 0; + +$stmt = $pdo->prepare( + "INSERT INTO companies + (name_zh, name_en, display_name, business_role, country, registration_number, + address, legal_form, legal_representative, industry, industry_subdivision, website, + registered_capital, established_date, is_listed, stock_code) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)" +); + +while (($row = fgetcsv($handle)) !== false) { + $row = array_map('trim', $row); + $rec = []; + foreach ($header as $idx => $col) { + $col = trim($col); + if (isset($map[$col]) && isset($row[$idx])) { + $rec[$map[$col]] = $row[$idx]; + } + } + if (empty($rec['display_name'])) { + $failed++; + continue; + } + $rec['established_date'] = ($rec['established_date'] ?? '') !== '' ? date('Y-m-d', strtotime($rec['established_date'])) : null; + try { + $stmt->execute([ + $rec['name_zh'] ?? null, $rec['name_en'] ?? null, $rec['display_name'], + $rec['business_role'] ?? null, $rec['country'] ?? null, $rec['registration_number'] ?? null, + $rec['address'] ?? null, $rec['legal_form'] ?? null, $rec['legal_representative'] ?? null, + $rec['industry'] ?? null, $rec['industry_subdivision'] ?? null, $rec['website'] ?? null, + $rec['registered_capital'] ?? null, $rec['established_date'], + ($rec['is_listed'] ?? 0) ? 1 : 0, $rec['stock_code'] ?? null, + ]); + $inserted++; + } catch (Exception $e) { + $failed++; + } +} +fclose($handle); + +logCurrent('import', 'company', 'companies', null, ['inserted' => $inserted, 'failed' => $failed]); +Response::success(['inserted' => $inserted, 'failed' => $failed], "导入完成:成功 $inserted 条,失败 $failed 条"); diff --git a/api/company/list.php b/api/company/list.php new file mode 100644 index 0000000..57579c6 --- /dev/null +++ b/api/company/list.php @@ -0,0 +1,67 @@ +getPdo(); + +$stmt = $pdo->prepare("SELECT COUNT(*) FROM companies WHERE $whereSql"); +$stmt->execute($params); +$total = (int)$stmt->fetchColumn(); + +$offset = ($page - 1) * $limit; +$stmt = $pdo->prepare( + "SELECT id, name_zh, name_en, display_name, business_role, country, registration_number, + address, legal_representative, industry, industry_subdivision, website, + latest_employee_count, latest_annual_revenue, is_listed, stock_code, + is_active, created_at, updated_at + FROM companies + WHERE $whereSql + ORDER BY id DESC + LIMIT $limit OFFSET $offset" +); +$stmt->execute($params); +$list = $stmt->fetchAll(); + +Response::success(['list' => $list, 'total' => $total, 'page' => $page, 'limit' => $limit]); diff --git a/api/company/update.php b/api/company/update.php new file mode 100644 index 0000000..8b34137 --- /dev/null +++ b/api/company/update.php @@ -0,0 +1,40 @@ +getPdo(); + +$check = $pdo->prepare("SELECT id, display_name FROM companies WHERE id = ?"); +$check->execute([$id]); +$old = $check->fetch(); +if (!$old) { + Response::error('企业不存在'); +} + +[$sets, $params] = buildUpdate($data); +$params[] = $id; +$pdo->prepare("UPDATE companies SET $sets WHERE id = ?")->execute($params); + +logCurrent('update', 'company', 'companies', $id, ['before' => $old, 'after' => $data]); +Response::success(null, '更新成功'); diff --git a/api/dashboard/stats.php b/api/dashboard/stats.php new file mode 100644 index 0000000..2947dba --- /dev/null +++ b/api/dashboard/stats.php @@ -0,0 +1,194 @@ +getPdo(); + +// ---------- 8 个指标卡 ---------- +$sqlMetrics = [ + 'company_total' => "SELECT COUNT(*) AS c FROM companies WHERE is_active = 1", + 'person_total' => "SELECT COUNT(*) AS c FROM persons WHERE is_active = 1", + 'company_new' => "SELECT COUNT(*) AS c FROM companies WHERE is_active = 1 AND DATE(created_at) = CURDATE() - INTERVAL 1 DAY", + 'person_new' => "SELECT COUNT(*) AS c FROM persons WHERE is_active = 1 AND DATE(created_at) = CURDATE() - INTERVAL 1 DAY", + 'preliminary' => "SELECT COUNT(*) AS c FROM preliminary_data WHERE status = '待处理' AND is_active = 1", + 'industry_count' => "SELECT COUNT(DISTINCT industry) AS c FROM companies WHERE industry IS NOT NULL AND industry <> ''", + 'manufacturer' => "SELECT COUNT(*) AS c FROM companies WHERE business_role = 'manufacturer' AND is_active = 1", + 'integrator' => "SELECT COUNT(*) AS c FROM companies WHERE business_role IN ('integrator','distributor') AND is_active = 1", + 'media_resource' => "SELECT COUNT(*) AS c FROM media_commercial_attributes", +]; +$metrics = []; +foreach ($sqlMetrics as $key => $sql) { + $metrics[$key] = (int)$pdo->query($sql)->fetchColumn(); +} + +// ---------- 区域分布 ---------- +// 国外(世界地图):企业按 country,人员按 nationality +$regionWorldCompany = $pdo->query( + "SELECT country AS name, COUNT(*) AS value + FROM companies + WHERE country IS NOT NULL AND country <> '' AND is_active = 1 + GROUP BY country ORDER BY value DESC LIMIT 60" +)->fetchAll(); + +$regionWorldPerson = $pdo->query( + "SELECT nationality AS name, COUNT(*) AS value + FROM persons + WHERE nationality IS NOT NULL AND nationality <> '' AND is_active = 1 + GROUP BY nationality ORDER BY value DESC LIMIT 60" +)->fetchAll(); +if (!$regionWorldPerson) { + $regionWorldPerson = $pdo->query( + "SELECT work_location AS name, COUNT(*) AS value + FROM persons WHERE work_location IS NOT NULL AND work_location <> '' AND is_active = 1 + GROUP BY work_location ORDER BY value DESC LIMIT 60" + )->fetchAll(); +} + +// 国内(中国地图):按省份统计(企业取注册地址/国家;人员取工作所在地/家乡) +$regionChinaCompany = provinceCount( + $pdo->query("SELECT country, address FROM companies WHERE is_active = 1")->fetchAll(), + ['country', 'address'] +); +$regionChinaPerson = provinceCount( + $pdo->query("SELECT work_location, hometown FROM persons WHERE is_active = 1")->fetchAll(), + ['work_location', 'hometown'] +); + +// ---------- 行业分布(企业直接取 industry;人员经工作经历关联到企业行业) ---------- +$industryCompany = $pdo->query( + "SELECT industry AS name, COUNT(*) AS value + FROM companies + WHERE industry IS NOT NULL AND industry <> '' AND is_active = 1 + GROUP BY industry ORDER BY value DESC LIMIT 20" +)->fetchAll(); + +$industryPerson = $pdo->query( + "SELECT c.industry AS name, COUNT(DISTINCT pwe.person_id) AS value + FROM person_work_experiences pwe + LEFT JOIN companies c ON c.id = pwe.company_id + WHERE pwe.is_active = 1 AND c.industry IS NOT NULL AND c.industry <> '' AND c.is_active = 1 + GROUP BY c.industry ORDER BY value DESC LIMIT 20" +)->fetchAll(); + +// ---------- 需求关键词(简化:从 company_needs.description 提取高频词,占位) ---------- +$needRows = $pdo->query( + "SELECT description FROM company_needs WHERE is_valid = 1 AND description IS NOT NULL AND description <> '' LIMIT 200" +)->fetchAll(); +$keywordMap = []; +foreach ($needRows as $row) { + $parts = preg_split('/[,。、,.;;::\s\/\|\-\+\/]+/u', $row['description']); + foreach ($parts as $part) { + $part = trim($part); + if (mb_strlen($part, 'utf-8') >= 2 && mb_strlen($part, 'utf-8') <= 12) { + $keywordMap[$part] = ($keywordMap[$part] ?? 0) + 1; + } + } +} +arsort($keywordMap); +$wordCloud = []; +$i = 0; +foreach ($keywordMap as $word => $count) { + if ($i++ >= 50) break; + $wordCloud[] = ['name' => $word, 'value' => $count]; +} + +Response::success([ + 'metrics' => $metrics, + 'region' => [ + 'company' => ['world' => $regionWorldCompany, 'china' => $regionChinaCompany], + 'person' => ['world' => $regionWorldPerson, 'china' => $regionChinaPerson], + ], + 'industry' => ['company' => $industryCompany, 'person' => $industryPerson], + 'wordcloud' => $wordCloud, +]); + +/** + * 从一组记录的文本字段中统计中国省份分布 + * @param array $rows 记录列表(关联数组) + * @param array $fields 用于匹配的字段名 + * @return array [{name:省份, value:数量}] + */ +function provinceCount($rows, $fields) +{ + // 34 个省级行政区(含直辖市/自治区/特别行政区),匹配顺序:长名称优先 + $provinces = [ + '内蒙古自治区', '广西壮族自治区', '西藏自治区', '宁夏回族自治区', '新疆维吾尔自治区', + '黑龙江省', '河北省', '山西省', '辽宁省', '吉林省', '江苏省', '浙江省', '安徽省', + '福建省', '江西省', '山东省', '河南省', '湖北省', '湖南省', '广东省', '海南省', + '四川省', '贵州省', '云南省', '陕西省', '甘肃省', '青海省', + '北京', '天津', '上海', '重庆', '台湾', '香港', '澳门', + ]; + // 常见城市 -> 省份(地址/工作地只有城市名时使用) + $city2prov = [ + '深圳' => '广东', '广州' => '广东', '东莞' => '广东', '佛山' => '广东', + '珠海' => '广东', '中山' => '广东', '惠州' => '广东', '汕头' => '广东', '江门' => '广东', + '杭州' => '浙江', '宁波' => '浙江', '温州' => '浙江', '嘉兴' => '浙江', '绍兴' => '浙江', + '南京' => '江苏', '苏州' => '江苏', '无锡' => '江苏', '常州' => '江苏', '南通' => '江苏', + '成都' => '四川', '绵阳' => '四川', '武汉' => '湖北', '宜昌' => '湖北', '长沙' => '湖南', + '郑州' => '河南', '洛阳' => '河南', '西安' => '陕西', '青岛' => '山东', '济南' => '山东', + '烟台' => '山东', '潍坊' => '山东', '大连' => '辽宁', '沈阳' => '辽宁', '哈尔滨' => '黑龙江', + '长春' => '吉林', '合肥' => '安徽', '芜湖' => '安徽', '福州' => '福建', '厦门' => '福建', + '泉州' => '福建', '昆明' => '云南', '贵阳' => '贵州', '南昌' => '江西', '石家庄' => '河北', + '太原' => '山西', '兰州' => '甘肃', '银川' => '宁夏', '西宁' => '青海', '乌鲁木齐' => '新疆', + '呼和浩特' => '内蒙古', '南宁' => '广西', '海口' => '海南', '三亚' => '海南', + '拉萨' => '西藏', '台北' => '台湾', '高雄' => '台湾', '香港' => '香港', '澳门' => '澳门', + ]; + + $counts = []; + foreach ($rows as $row) { + $text = ''; + foreach ($fields as $f) { + if (!empty($row[$f])) { + $text .= $row[$f] . ' '; + } + } + $province = null; + foreach ($provinces as $p) { + if (mb_strpos($text, $p) !== false) { + $province = $p; + break; + } + } + if ($province === null) { + foreach ($city2prov as $city => $p) { + if (mb_strpos($text, $city) !== false) { + $province = $p; + break; + } + } + } + // 长名 -> 短名(与中国地图 echarts china.js 的省份名保持一致) + if ($province !== null) { + $shortMap = [ + '内蒙古自治区' => '内蒙古', '广西壮族自治区' => '广西', '西藏自治区' => '西藏', + '宁夏回族自治区' => '宁夏', '新疆维吾尔自治区' => '新疆', + '黑龙江省' => '黑龙江', '河北省' => '河北', '山西省' => '山西', '辽宁省' => '辽宁', + '吉林省' => '吉林', '江苏省' => '江苏', '浙江省' => '浙江', '安徽省' => '安徽', + '福建省' => '福建', '江西省' => '江西', '山东省' => '山东', '河南省' => '河南', + '湖北省' => '湖北', '湖南省' => '湖南', '广东省' => '广东', '海南省' => '海南', + '四川省' => '四川', '贵州省' => '贵州', '云南省' => '云南', '陕西省' => '陕西', + '甘肃省' => '甘肃', '青海省' => '青海', '台湾省' => '台湾', + ]; + if (isset($shortMap[$province])) { + $province = $shortMap[$province]; + } + } + if ($province !== null) { + $counts[$province] = ($counts[$province] ?? 0) + 1; + } + } + arsort($counts); + $result = []; + foreach ($counts as $name => $value) { + $result[] = ['name' => $name, 'value' => $value]; + } + return $result; +} diff --git a/api/document/delete.php b/api/document/delete.php new file mode 100644 index 0000000..28d37a5 --- /dev/null +++ b/api/document/delete.php @@ -0,0 +1,54 @@ + 0) $idList[] = $id; +if ($ids !== '') { + foreach (explode(',', $ids) as $v) { + $v = (int)trim($v); + if ($v > 0) $idList[] = $v; + } +} +$idList = array_unique($idList); +if (!$idList) { + Response::error('参数错误', 400); +} + +$pdo = DB::getInstance()->getPdo(); +$in = implode(',', array_fill(0, count($idList), '?')); + +// 取出物理路径 +$stmt = $pdo->prepare("SELECT id, storage_path FROM company_documents WHERE id IN ($in)"); +$stmt->execute($idList); +$docs = $stmt->fetchAll(); + +// 删除物理文件(仅限本站上传目录内的文件) +foreach ($docs as $doc) { + $path = $doc['storage_path']; + if (strpos($path, 'static/uploads/documents/') === 0) { + $full = __DIR__ . '/../../' . $path; + if (is_file($full)) { + @unlink($full); + } + } +} + +// 删除记录(document_links 级联删除) +$del = $pdo->prepare("DELETE FROM company_documents WHERE id IN ($in)"); +$del->execute($idList); +$affected = $del->rowCount(); + +logCurrent('delete', 'document', 'company_documents', null, ['ids' => $idList]); +Response::success(['affected' => $affected], "已删除 $affected 个文件(含物理文件)"); diff --git a/api/document/list.php b/api/document/list.php new file mode 100644 index 0000000..5e1f559 --- /dev/null +++ b/api/document/list.php @@ -0,0 +1,49 @@ +getPdo(); + +$stmt = $pdo->prepare("SELECT COUNT(*) FROM company_documents WHERE $whereSql"); +$stmt->execute($params); +$total = (int)$stmt->fetchColumn(); + +$offset = ($page - 1) * $limit; +$stmt = $pdo->prepare( + "SELECT id, doc_name, storage_path, file_type, is_current, publish_date, document_source, tags, is_active, created_at + FROM company_documents + WHERE $whereSql + ORDER BY id DESC + LIMIT $limit OFFSET $offset" +); +$stmt->execute($params); +$list = $stmt->fetchAll(); + +Response::success(['list' => $list, 'total' => $total, 'page' => $page, 'limit' => $limit]); diff --git a/api/document/upload.php b/api/document/upload.php new file mode 100644 index 0000000..a3dba51 --- /dev/null +++ b/api/document/upload.php @@ -0,0 +1,75 @@ + $maxSize) { + Response::error('文件不能超过50MB', 400); +} + +// 安全扩展名白名单 +$extMap = [ + 'pdf' => 'application/pdf', 'doc' => 'application/msword', + 'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'xls' => 'application/vnd.ms-excel', + 'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'ppt' => 'application/vnd.ms-powerpoint', + 'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + 'txt' => 'text/plain', 'csv' => 'text/csv', 'png' => 'image/png', + 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'gif' => 'image/gif', + 'zip' => 'application/zip', 'rar' => 'application/x-rar-compressed', +]; +$ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); +if (!isset($extMap[$ext])) { + Response::error('不允许的文件类型:' . ($ext !== '' ? $ext : '无扩展名'), 400); +} + +// 目录:static/uploads/documents/年/月 +$baseDir = __DIR__ . '/../../static/uploads/documents'; +$subDir = date('Y') . '/' . date('m'); +$dir = $baseDir . '/' . $subDir; +if (!is_dir($dir) && !mkdir($dir, 0777, true)) { + Response::error('创建上传目录失败', 500); +} + +$newName = date('YmdHis') . '_' . substr(uniqid(), -6) . '.' . $ext; +$dest = $dir . '/' . $newName; +if (!move_uploaded_file($file['tmp_name'], $dest)) { + Response::error('文件保存失败', 500); +} + +$storagePath = 'static/uploads/documents/' . $subDir . '/' . $newName; +$docName = trim($_POST['doc_name'] ?? '') !== '' ? trim($_POST['doc_name']) : $file['name']; +$tags = $_POST['tags'] ?? null; +$tagsJson = null; +if ($tags !== null && $tags !== '') { + $decoded = json_decode($tags, true); + $tagsJson = is_array($decoded) ? json_encode($decoded, JSON_UNESCAPED_UNICODE) : null; +} + +$pdo = DB::getInstance()->getPdo(); +$stmt = $pdo->prepare( + "INSERT INTO company_documents (doc_name, storage_path, file_type, is_current, document_source, tags) + VALUES (?, ?, ?, 1, ?, ?)" +); +$stmt->execute([$docName, $storagePath, $extMap[$ext], '本地上传', $tagsJson]); +$newId = (int)$pdo->lastInsertId(); + +logCurrent('upload', 'document', 'company_documents', $newId, ['doc_name' => $docName, 'storage_path' => $storagePath]); +Response::success(['id' => $newId, 'storage_path' => $storagePath, 'doc_name' => $docName], '上传成功'); diff --git a/api/marketing/data_custom.php b/api/marketing/data_custom.php new file mode 100644 index 0000000..4405a3f --- /dev/null +++ b/api/marketing/data_custom.php @@ -0,0 +1,29 @@ + trim($_POST['contact_name'] ?? ''), + 'contact_phone' => trim($_POST['contact_phone'] ?? ''), + 'requirements' => trim($_POST['requirements'] ?? ''), + ]; + logCurrent('audit', 'marketing', null, null, $content); + Response::success(null, '需求已提交(占位实现,后续接入业务流)'); +} + +Response::success([ + 'title' => '数据定制', + 'description' => '按行业/区域/规模等维度定制企业数据、人员数据,功能建设中(占位)。', + 'status' => 'placeholder', +]); diff --git a/api/marketing/edm.php b/api/marketing/edm.php new file mode 100644 index 0000000..d98d067 --- /dev/null +++ b/api/marketing/edm.php @@ -0,0 +1,114 @@ + 市/区,来自 persons.work_location / hometown) + * filter 按 行业 + 区域 + 关键词 筛选邮箱列表(persons.email + companies 关联联系人邮箱) + * 入参(filter):industry / province / city / keyword / page / limit + */ +require_once __DIR__ . '/../common/db.php'; +require_once __DIR__ . '/../common/response.php'; +require_once __DIR__ . '/../common/auth.php'; + +checkPermission('marketing'); + +$action = $_REQUEST['action'] ?? 'filter'; +$pdo = DB::getInstance()->getPdo(); + +// ---------- 行业下拉 ---------- +if ($action === 'industries') { + $rows = $pdo->query( + "SELECT DISTINCT industry FROM companies WHERE industry IS NOT NULL AND industry <> '' AND is_active = 1 ORDER BY industry" + )->fetchAll(PDO::FETCH_COLUMN); + Response::success(['industries' => $rows]); +} + +// ---------- 区域二级联动 ---------- +if ($action === 'regions') { + // 从 persons.work_location 与 hometown 提取 省/州 与 市/区 + $rows = $pdo->query( + "SELECT work_location AS loc FROM persons WHERE work_location IS NOT NULL AND work_location <> '' AND is_active = 1 + UNION SELECT hometown AS loc FROM persons WHERE hometown IS NOT NULL AND hometown <> '' AND is_active = 1" + )->fetchAll(PDO::FETCH_COLUMN); + + $regions = []; + foreach ($rows as $loc) { + $parts = preg_split('/[\s\-—–\/,,]+/u', trim($loc)); + $province = $parts[0] ?? $loc; + $city = isset($parts[1]) && $parts[1] !== '' ? $parts[1] : ''; + if ($province === '') continue; + if (!isset($regions[$province])) { + $regions[$province] = []; + } + if ($city !== '' && !in_array($city, $regions[$province], true)) { + $regions[$province][] = $city; + } + } + Response::success(['regions' => $regions]); +} + +// ---------- 筛选邮箱列表 ---------- +if ($action === 'filter') { + $industry = trim($_REQUEST['industry'] ?? ''); + $province = trim($_REQUEST['province'] ?? ''); + $city = trim($_REQUEST['city'] ?? ''); + $keyword = trim($_REQUEST['keyword'] ?? ''); + + // 邮箱来源:persons 的 social_accounts(platform=email),关联工作经历(company) 做行业过滤 + $where = ["sa.platform = 'email'", "sa.is_active = 1", 'p.is_active = 1']; + $params = []; + + if ($industry !== '') { + $where[] = "EXISTS ( + SELECT 1 FROM person_work_experiences pwe + LEFT JOIN companies c ON c.id = pwe.company_id + WHERE pwe.person_id = p.id AND pwe.is_active = 1 AND c.industry = ? + )"; + $params[] = $industry; + } + if ($province !== '') { + $where[] = "(p.work_location LIKE ? OR p.hometown LIKE ?)"; + $params[] = "%$province%"; + $params[] = "%$province%"; + } + if ($city !== '') { + $where[] = "(p.work_location LIKE ? OR p.hometown LIKE ?)"; + $params[] = "%$city%"; + $params[] = "%$city%"; + } + if ($keyword !== '') { + $where[] = '(p.full_name LIKE ? OR sa.account_id LIKE ?)'; + $like = "%$keyword%"; + array_push($params, $like, $like); + } + $whereSql = implode(' AND ', $where); + + $stmt = $pdo->prepare( + "SELECT COUNT(*) FROM social_accounts sa + LEFT JOIN persons p ON p.id = sa.owner_id + WHERE $whereSql" + ); + $stmt->execute($params); + $total = (int)$stmt->fetchColumn(); + + [$page, $limit] = pageParams(20); + $offset = ($page - 1) * $limit; + $stmt = $pdo->prepare( + "SELECT sa.id, sa.account_id AS email, p.id AS person_id, p.full_name, p.work_location, p.hometown, + (SELECT c.display_name FROM person_work_experiences pwe + LEFT JOIN companies c ON c.id = pwe.company_id + WHERE pwe.person_id = p.id AND pwe.is_current = 1 LIMIT 1) AS company_name + FROM social_accounts sa + LEFT JOIN persons p ON p.id = sa.owner_id + WHERE $whereSql + ORDER BY sa.id DESC + LIMIT $limit OFFSET $offset" + ); + $stmt->execute($params); + $list = $stmt->fetchAll(); + + Response::success(['list' => $list, 'total' => $total, 'page' => $page, 'limit' => $limit]); +} + +Response::error('未知操作', 400); diff --git a/api/marketing/edm_export.php b/api/marketing/edm_export.php new file mode 100644 index 0000000..4acc3cb --- /dev/null +++ b/api/marketing/edm_export.php @@ -0,0 +1,56 @@ +getPdo(); +$stmt = $pdo->prepare( + "SELECT sa.account_id AS email, p.full_name, p.work_location, p.hometown + FROM social_accounts sa + LEFT JOIN persons p ON p.id = sa.owner_id + WHERE $whereSql + ORDER BY sa.id DESC" +); +$stmt->execute($params); +$list = $stmt->fetchAll(); + +logCurrent('export', 'marketing', 'social_accounts', null, ['count' => count($list), 'emails' => $emailList]); + +header('Content-Type: text/csv; charset=utf-8'); +header('Content-Disposition: attachment; filename="edm_emails_' . date('Ymd_His') . '.csv"'); +echo "\xEF\xBB\xBF"; +$out = fopen('php://output', 'w'); +fputcsv($out, ['邮箱', '姓名', '工作所在地', '家乡']); +foreach ($list as $r) { + fputcsv($out, [$r['email'], $r['full_name'], $r['work_location'], $r['hometown']]); +} +fclose($out); +exit; diff --git a/api/marketing/media_wholesale.php b/api/marketing/media_wholesale.php new file mode 100644 index 0000000..c5939aa --- /dev/null +++ b/api/marketing/media_wholesale.php @@ -0,0 +1,28 @@ + trim($_POST['media_ids'] ?? ''), + 'remark' => trim($_POST['remark'] ?? ''), + ]; + logCurrent('audit', 'marketing', 'social_accounts', null, $content); + Response::success(null, '询价已提交(占位实现,后续接入业务流)'); +} + +Response::success([ + 'title' => '媒体批发', + 'description' => '媒体账号资源批量采购/询价,功能建设中(占位)。', + 'status' => 'placeholder', +]); diff --git a/api/media/add.php b/api/media/add.php new file mode 100644 index 0000000..774abf2 --- /dev/null +++ b/api/media/add.php @@ -0,0 +1,57 @@ +getPdo(); + +// owner 存在性校验 +if ($data['owner_type'] === 'person') { + $chk = $pdo->prepare("SELECT COUNT(*) FROM persons WHERE id = ?"); +} else { + $chk = $pdo->prepare("SELECT COUNT(*) FROM companies WHERE id = ?"); +} +$chk->execute([$data['owner_id']]); +if ((int)$chk->fetchColumn() === 0) { + Response::error('归属对象不存在', 400); +} + +// platform + account_id 唯一 +$chk = $pdo->prepare("SELECT COUNT(*) FROM social_accounts WHERE platform = ? AND account_id = ?"); +$chk->execute([$data['platform'], $data['account_id']]); +if ((int)$chk->fetchColumn() > 0) { + Response::error('该平台账号已存在', 400); +} + +[$sql, $params] = buildInsert($data); +$pdo->prepare("INSERT INTO social_accounts $sql")->execute($params); +$newId = (int)$pdo->lastInsertId(); + +// 写入商业属性 +$attr = extractFields(MEDIA_ATTR_FIELDS); +if (!empty($attr)) { + $attr['social_account_id'] = $newId; + [$attrSql, $attrParams] = buildInsert($attr); + $pdo->prepare("INSERT INTO media_commercial_attributes $attrSql")->execute($attrParams); +} + +logCurrent('add', 'media', 'social_accounts', $newId, ['data' => $data, 'attr' => $attr]); +Response::success(['id' => $newId], '新增成功'); diff --git a/api/media/delete.php b/api/media/delete.php new file mode 100644 index 0000000..23eef0a --- /dev/null +++ b/api/media/delete.php @@ -0,0 +1,36 @@ + 0) $idList[] = $id; +if ($ids !== '') { + foreach (explode(',', $ids) as $v) { + $v = (int)trim($v); + if ($v > 0) $idList[] = $v; + } +} +$idList = array_unique($idList); +if (!$idList) { + Response::error('参数错误', 400); +} + +$pdo = DB::getInstance()->getPdo(); +$in = implode(',', array_fill(0, count($idList), '?')); +$stmt = $pdo->prepare("UPDATE social_accounts SET is_active = 0 WHERE id IN ($in)"); +$stmt->execute($idList); +$affected = $stmt->rowCount(); + +logCurrent('delete', 'media', 'social_accounts', null, ['ids' => $idList]); +Response::success(['affected' => $affected], "已删除 $affected 条记录"); diff --git a/api/media/detail.php b/api/media/detail.php new file mode 100644 index 0000000..93f5dce --- /dev/null +++ b/api/media/detail.php @@ -0,0 +1,30 @@ +getPdo(); +$stmt = $pdo->prepare( + "SELECT sa.*, mca.account_level, mca.content_categories, mca.follower_count, + mca.avg_read_count, mca.certification_type, mca.special_requirements, mca.media_remark + FROM social_accounts sa + LEFT JOIN media_commercial_attributes mca ON mca.social_account_id = sa.id + WHERE sa.id = ?" +); +$stmt->execute([$id]); +$row = $stmt->fetch(); +if (!$row) { + Response::error('媒体账号不存在'); +} + +Response::success(['media' => $row]); diff --git a/api/media/export.php b/api/media/export.php new file mode 100644 index 0000000..69de566 --- /dev/null +++ b/api/media/export.php @@ -0,0 +1,65 @@ + 0) { + $idList[] = $v; + } + } +} +$idList = array_unique($idList); +if (!$idList) { + Response::error('请先选择需要导出的媒体数据', 1); +} + +$where = ['sa.id IN (' . implode(',', array_fill(0, count($idList), '?')) . ')', 'sa.is_active = 1']; +$params = $idList; +$whereSql = implode(' AND ', $where); + +$pdo = DB::getInstance()->getPdo(); +$stmt = $pdo->prepare( + "SELECT sa.id, sa.owner_type, sa.owner_id, sa.platform, sa.account_id, sa.profile_url, sa.remark, + mca.account_level, mca.content_categories, mca.follower_count, mca.avg_read_count, + mca.certification_type, sa.created_at, + CASE sa.owner_type + WHEN 'person' THEN (SELECT p.full_name FROM persons p WHERE p.id = sa.owner_id) + WHEN 'company' THEN (SELECT c.display_name FROM companies c WHERE c.id = sa.owner_id) + ELSE NULL END AS owner_name + FROM social_accounts sa + LEFT JOIN media_commercial_attributes mca ON mca.social_account_id = sa.id + WHERE $whereSql ORDER BY sa.id DESC" +); +$stmt->execute($params); +$list = $stmt->fetchAll(); + +logCurrent('export', 'media', 'social_accounts', null, ['count' => count($list)]); + +header('Content-Type: text/csv; charset=utf-8'); +header('Content-Disposition: attachment; filename="media_' . date('Ymd_His') . '.csv"'); +echo "\xEF\xBB\xBF"; +$out = fopen('php://output', 'w'); +fputcsv($out, ['ID', '归属类型', '归属ID', '归属名称', '平台', '账号', '主页链接', '备注', '等级', '内容领域', '粉丝数', '平均阅读', '认证类型', '创建时间']); +foreach ($list as $r) { + fputcsv($out, [ + $r['id'], $r['owner_type'], $r['owner_id'], $r['owner_name'], $r['platform'], + $r['account_id'], $r['profile_url'], $r['remark'], $r['account_level'], + $r['content_categories'], $r['follower_count'], $r['avg_read_count'], + $r['certification_type'], $r['created_at'], + ]); +} +fclose($out); +exit; diff --git a/api/media/import.php b/api/media/import.php new file mode 100644 index 0000000..1f3ab65 --- /dev/null +++ b/api/media/import.php @@ -0,0 +1,95 @@ +getPdo(); +$insAccount = $pdo->prepare( + "INSERT INTO social_accounts (owner_type, owner_id, platform, account_id, profile_url, remark, is_primary, is_active) + VALUES (?,?,?,?,?,?,?,1)" +); +$insAttr = $pdo->prepare( + "INSERT INTO media_commercial_attributes + (social_account_id, account_level, content_categories, follower_count, avg_read_count, certification_type) + VALUES (?,?,?,?,?,?)" +); + +$inserted = 0; +$failed = 0; + +while (($row = fgetcsv($handle)) !== false) { + $row = array_map('trim', $row); + $rec = []; + foreach ($header as $idx => $col) { + $col = trim($col); + if (isset($row[$idx])) { + $rec[$col] = $row[$idx]; + } + } + if (empty($rec['platform']) || empty($rec['account_id']) || empty($rec['owner_type'])) { + $failed++; + continue; + } + $ownerType = $rec['owner_type'] === 'company' ? 'company' : 'person'; + $ownerId = (int)($rec['owner_id'] ?? 0); + if ($ownerId <= 0) { + // 按名称解析 + $nameCol = $ownerType === 'company' ? 'display_name' : 'full_name'; + $tbl = $ownerType === 'company' ? 'companies' : 'persons'; + $find = $pdo->prepare("SELECT id FROM $tbl WHERE $nameCol = ? LIMIT 1"); + $find->execute([$rec['owner_name'] ?? $rec['owner_id'] ?? '']); + $ownerId = (int)$find->fetchColumn(); + } + if ($ownerId <= 0) { + $failed++; + continue; + } + try { + $chk = $pdo->prepare("SELECT COUNT(*) FROM social_accounts WHERE platform = ? AND account_id = ?"); + $chk->execute([$rec['platform'], $rec['account_id']]); + if ((int)$chk->fetchColumn() > 0) { + $failed++; + continue; + } + $insAccount->execute([ + $ownerType, $ownerId, $rec['platform'], $rec['account_id'], + $rec['profile_url'] ?? null, $rec['remark'] ?? null, !empty($rec['is_primary']) ? 1 : 0, + ]); + $newId = (int)$pdo->lastInsertId(); + if (!empty($rec['account_level']) || !empty($rec['certification_type'])) { + $insAttr->execute([ + $newId, $rec['account_level'] ?? null, $rec['content_categories'] ?? null, + (int)($rec['follower_count'] ?? 0), (int)($rec['avg_read_count'] ?? 0), + $rec['certification_type'] ?? null, + ]); + } + $inserted++; + } catch (Exception $e) { + $failed++; + } +} +fclose($handle); + +logCurrent('import', 'media', 'social_accounts', null, ['inserted' => $inserted, 'failed' => $failed]); +Response::success(['inserted' => $inserted, 'failed' => $failed], "导入完成:成功 $inserted 条,失败 $failed 条"); diff --git a/api/media/list.php b/api/media/list.php new file mode 100644 index 0000000..678d0f2 --- /dev/null +++ b/api/media/list.php @@ -0,0 +1,77 @@ +getPdo(); + +$stmt = $pdo->prepare( + "SELECT COUNT(*) FROM social_accounts sa + LEFT JOIN media_commercial_attributes mca ON mca.social_account_id = sa.id + WHERE $whereSql" +); +$stmt->execute($params); +$total = (int)$stmt->fetchColumn(); + +$offset = ($page - 1) * $limit; +$stmt = $pdo->prepare( + "SELECT sa.id, sa.owner_type, sa.owner_id, sa.platform, sa.account_id, sa.profile_url, + sa.remark, sa.is_primary, sa.is_active, sa.created_at, + mca.account_level, mca.content_categories, mca.follower_count, mca.avg_read_count, + mca.certification_type, mca.special_requirements, mca.media_remark, + CASE sa.owner_type + WHEN 'person' THEN (SELECT p.full_name FROM persons p WHERE p.id = sa.owner_id) + WHEN 'company' THEN (SELECT c.display_name FROM companies c WHERE c.id = sa.owner_id) + ELSE NULL END AS owner_name + FROM social_accounts sa + LEFT JOIN media_commercial_attributes mca ON mca.social_account_id = sa.id + WHERE $whereSql + ORDER BY sa.id DESC + LIMIT $limit OFFSET $offset" +); +$stmt->execute($params); +$list = $stmt->fetchAll(); + +// 供前端筛选下拉使用:平台/认证类型/等级 字典 +$dict = [ + 'platforms' => $pdo->query("SELECT DISTINCT platform FROM social_accounts WHERE is_active = 1 ORDER BY platform")->fetchAll(PDO::FETCH_COLUMN), + 'certification_types' => $pdo->query("SELECT DISTINCT certification_type FROM media_commercial_attributes WHERE certification_type IS NOT NULL AND certification_type <> '' ORDER BY certification_type")->fetchAll(PDO::FETCH_COLUMN), + 'account_levels' => $pdo->query("SELECT DISTINCT account_level FROM media_commercial_attributes ORDER BY account_level")->fetchAll(PDO::FETCH_COLUMN), +]; + +Response::success(['list' => $list, 'total' => $total, 'page' => $page, 'limit' => $limit, 'dict' => $dict]); diff --git a/api/media/update.php b/api/media/update.php new file mode 100644 index 0000000..c0d1226 --- /dev/null +++ b/api/media/update.php @@ -0,0 +1,54 @@ +getPdo(); +$check = $pdo->prepare("SELECT * FROM social_accounts WHERE id = ?"); +$check->execute([$id]); +$old = $check->fetch(); +if (!$old) { + Response::error('媒体账号不存在'); +} + +$data = extractFields(MEDIA_FIELDS); +unset($data['owner_type'], $data['owner_id']); // 归属关系不允许改 + +if (!empty($data)) { + [$sets, $params] = buildUpdate($data); + $params[] = $id; + $pdo->prepare("UPDATE social_accounts SET $sets WHERE id = ?")->execute($params); +} + +// 更新商业属性(存在则更新,不存在则插入) +$attr = extractFields(MEDIA_ATTR_FIELDS); +if (!empty($attr)) { + $exists = $pdo->prepare("SELECT COUNT(*) FROM media_commercial_attributes WHERE social_account_id = ?"); + $exists->execute([$id]); + if ((int)$exists->fetchColumn() > 0) { + [$attrSets, $attrParams] = buildUpdate($attr); + $attrParams[] = $id; + $pdo->prepare("UPDATE media_commercial_attributes SET $attrSets WHERE social_account_id = ?")->execute($attrParams); + } else { + $attr['social_account_id'] = $id; + [$attrSql, $attrParams] = buildInsert($attr); + $pdo->prepare("INSERT INTO media_commercial_attributes $attrSql")->execute($attrParams); + } +} + +logCurrent('update', 'media', 'social_accounts', $id, ['before' => $old, 'after' => $data, 'attr' => $attr]); +Response::success(null, '更新成功'); diff --git a/api/need/add.php b/api/need/add.php new file mode 100644 index 0000000..b067d1b --- /dev/null +++ b/api/need/add.php @@ -0,0 +1,32 @@ +getPdo(); +$chk = $pdo->prepare("SELECT COUNT(*) FROM companies WHERE id = ?"); +$chk->execute([$data['company_id']]); +if ((int)$chk->fetchColumn() === 0) { + Response::error('所属公司不存在', 400); +} + +[$sql, $params] = buildInsert($data); +$pdo->prepare("INSERT INTO company_needs $sql")->execute($params); +$newId = (int)$pdo->lastInsertId(); + +logCurrent('add', 'need', 'company_needs', $newId, $data); +Response::success(['id' => $newId], '新增成功'); diff --git a/api/need/delete.php b/api/need/delete.php new file mode 100644 index 0000000..1327e87 --- /dev/null +++ b/api/need/delete.php @@ -0,0 +1,36 @@ + 0) $idList[] = $id; +if ($ids !== '') { + foreach (explode(',', $ids) as $v) { + $v = (int)trim($v); + if ($v > 0) $idList[] = $v; + } +} +$idList = array_unique($idList); +if (!$idList) { + Response::error('参数错误', 400); +} + +$pdo = DB::getInstance()->getPdo(); +$in = implode(',', array_fill(0, count($idList), '?')); +$stmt = $pdo->prepare("UPDATE company_needs SET is_valid = 0 WHERE id IN ($in)"); +$stmt->execute($idList); +$affected = $stmt->rowCount(); + +logCurrent('delete', 'need', 'company_needs', null, ['ids' => $idList]); +Response::success(['affected' => $affected], "已作废 $affected 条需求"); diff --git a/api/need/detail.php b/api/need/detail.php new file mode 100644 index 0000000..f47d306 --- /dev/null +++ b/api/need/detail.php @@ -0,0 +1,29 @@ +getPdo(); +$stmt = $pdo->prepare( + "SELECT cn.*, c.display_name, c.industry, c.country, c.website + FROM company_needs cn + LEFT JOIN companies c ON c.id = cn.company_id + WHERE cn.id = ?" +); +$stmt->execute([$id]); +$need = $stmt->fetch(); +if (!$need) { + Response::error('需求不存在'); +} + +Response::success(['need' => $need]); diff --git a/api/need/list.php b/api/need/list.php new file mode 100644 index 0000000..2e7664f --- /dev/null +++ b/api/need/list.php @@ -0,0 +1,61 @@ += ?'; $params[] = $startDate; } +if ($endDate !== '') { $where[] = 'DATE(cn.created_at) <= ?'; $params[] = $endDate; } +if ($companyName !== '') { $where[] = 'c.display_name LIKE ?'; $params[] = "%$companyName%"; } +if ($industry !== '') { $where[] = 'c.industry = ?'; $params[] = $industry; } +if ($category !== '') { $where[] = 'cn.need_category = ?'; $params[] = $category; } +$whereSql = implode(' AND ', $where); + +$pdo = DB::getInstance()->getPdo(); + +$stmt = $pdo->prepare( + "SELECT COUNT(*) FROM company_needs cn LEFT JOIN companies c ON c.id = cn.company_id WHERE $whereSql" +); +$stmt->execute($params); +$total = (int)$stmt->fetchColumn(); + +$offset = ($page - 1) * $limit; +$stmt = $pdo->prepare( + "SELECT cn.id, cn.company_id, c.display_name, c.industry, cn.contact_person, + cn.need_category, cn.target_product_category, cn.application_scenario, + cn.description, cn.is_valid, cn.created_at + FROM company_needs cn + LEFT JOIN companies c ON c.id = cn.company_id + WHERE $whereSql + ORDER BY cn.id DESC + LIMIT $limit OFFSET $offset" +); +$stmt->execute($params); +$list = $stmt->fetchAll(); + +Response::success(['list' => $list, 'total' => $total, 'page' => $page, 'limit' => $limit]); diff --git a/api/need/push.php b/api/need/push.php new file mode 100644 index 0000000..44e70ea --- /dev/null +++ b/api/need/push.php @@ -0,0 +1,46 @@ + 0) $idList[] = $id; +if ($ids !== '') { + foreach (explode(',', $ids) as $v) { + $v = (int)trim($v); + if ($v > 0) $idList[] = $v; + } +} +$idList = array_unique($idList); +if (!$idList) { + Response::error('参数错误', 400); +} + +$pdo = DB::getInstance()->getPdo(); +$in = implode(',', array_fill(0, count($idList), '?')); +$stmt = $pdo->prepare("SELECT id, company_id FROM company_needs WHERE id IN ($in) AND is_valid = 1"); +$stmt->execute($idList); +$needs = $stmt->fetchAll(); + +if (!$needs) { + Response::error('没有可推送的有效需求'); +} + +// TODO: 在此接入第三方API推送 / SMTP邮件发送 +// 示例: +// foreach ($needs as $n) { sendToThirdParty($n); sendEmail($n); } + +logCurrent('push', 'need', 'company_needs', null, ['ids' => $idList, 'count' => count($needs)]); +Response::success(['count' => count($needs)], "已推送 " . count($needs) . " 条需求(占位实现,后续接入第三方API/邮件)"); diff --git a/api/need/update.php b/api/need/update.php new file mode 100644 index 0000000..eea619c --- /dev/null +++ b/api/need/update.php @@ -0,0 +1,42 @@ +getPdo(); +$check = $pdo->prepare("SELECT * FROM company_needs WHERE id = ?"); +$check->execute([$id]); +$old = $check->fetch(); +if (!$old) { + Response::error('需求不存在'); +} + +[$sets, $params] = buildUpdate($data); +$params[] = $id; +$pdo->prepare("UPDATE company_needs SET $sets WHERE id = ?")->execute($params); + +logCurrent('update', 'need', 'company_needs', $id, ['before' => $old, 'after' => $data]); +Response::success(null, '更新成功'); diff --git a/api/person/add.php b/api/person/add.php new file mode 100644 index 0000000..e079ce7 --- /dev/null +++ b/api/person/add.php @@ -0,0 +1,53 @@ +getPdo(); + +// union_id 唯一性 +$chk = $pdo->prepare("SELECT COUNT(*) FROM persons WHERE union_id = ?"); +$chk->execute([$data['union_id']]); +if ((int)$chk->fetchColumn() > 0) { + Response::error('union_id 已存在,请更换'); +} + +[$sql, $params] = buildInsert($data); +$pdo->prepare("INSERT INTO persons $sql")->execute($params); +$newId = (int)$pdo->lastInsertId(); + +// 写入联系方式 +$contacts = json_decode($_POST['contacts'] ?? '[]', true); +if (is_array($contacts)) { + $ins = $pdo->prepare( + "INSERT INTO social_accounts (owner_type, owner_id, platform, account_id, profile_url, remark, is_primary) + VALUES ('person', ?, ?, ?, ?, ?, ?)" + ); + foreach ($contacts as $c) { + $platform = trim($c['platform'] ?? ''); + $accountId = trim($c['account_id'] ?? ''); + if ($platform === '' || $accountId === '') continue; + $ins->execute([$newId, $platform, $accountId, $c['profile_url'] ?? null, $c['remark'] ?? null, !empty($c['is_primary']) ? 1 : 0]); + } +} + +logCurrent('add', 'person', 'persons', $newId, ['data' => $data, 'contacts' => $contacts]); +Response::success(['id' => $newId], '新增成功'); diff --git a/api/person/companies.php b/api/person/companies.php new file mode 100644 index 0000000..4ba7dd6 --- /dev/null +++ b/api/person/companies.php @@ -0,0 +1,52 @@ +getPdo(); + +$chk = $pdo->prepare("SELECT COUNT(*) FROM persons WHERE id = ?"); +$chk->execute([$personId]); +if ((int)$chk->fetchColumn() === 0) { + Response::error('人员不存在'); +} + +$stmt = $pdo->prepare( + "SELECT COUNT(*) FROM person_work_experiences pwe + INNER JOIN companies c ON c.id = pwe.company_id + WHERE pwe.person_id = ? AND pwe.is_active = 1" +); +$stmt->execute([$personId]); +$total = (int)$stmt->fetchColumn(); + +$offset = ($page - 1) * $limit; +$stmt = $pdo->prepare( + "SELECT pwe.id AS work_id, pwe.company_id, + CASE WHEN c.name_zh IS NOT NULL AND c.name_zh <> '' THEN c.name_zh + ELSE COALESCE(c.name_en, c.display_name) END AS company_name, + c.industry, pwe.department, pwe.position, pwe.job_level, + pwe.start_date, pwe.end_date, pwe.is_current + FROM person_work_experiences pwe + INNER JOIN companies c ON c.id = pwe.company_id + WHERE pwe.person_id = ? AND pwe.is_active = 1 + ORDER BY pwe.is_current DESC, pwe.start_date DESC + LIMIT $limit OFFSET $offset" +); +$stmt->execute([$personId]); +$list = $stmt->fetchAll(); + +Response::success(['list' => $list, 'total' => $total, 'page' => $page, 'limit' => $limit]); diff --git a/api/person/delete.php b/api/person/delete.php new file mode 100644 index 0000000..7346077 --- /dev/null +++ b/api/person/delete.php @@ -0,0 +1,36 @@ + 0) $idList[] = $id; +if ($ids !== '') { + foreach (explode(',', $ids) as $v) { + $v = (int)trim($v); + if ($v > 0) $idList[] = $v; + } +} +$idList = array_unique($idList); +if (!$idList) { + Response::error('参数错误', 400); +} + +$pdo = DB::getInstance()->getPdo(); +$in = implode(',', array_fill(0, count($idList), '?')); +$stmt = $pdo->prepare("UPDATE persons SET is_active = 0 WHERE id IN ($in)"); +$stmt->execute($idList); +$affected = $stmt->rowCount(); + +logCurrent('delete', 'person', 'persons', null, ['ids' => $idList]); +Response::success(['affected' => $affected], "已删除 $affected 条记录"); diff --git a/api/person/detail.php b/api/person/detail.php new file mode 100644 index 0000000..734d16b --- /dev/null +++ b/api/person/detail.php @@ -0,0 +1,38 @@ +getPdo(); + +$stmt = $pdo->prepare("SELECT * FROM persons WHERE id = ?"); +$stmt->execute([$id]); +$person = $stmt->fetch(); +if (!$person) { + Response::error('人员不存在'); +} + +$accounts = $pdo->prepare("SELECT id, platform, account_id, profile_url, remark, is_primary, is_active FROM social_accounts WHERE owner_type = 'person' AND owner_id = ? ORDER BY is_primary DESC, id DESC"); +$accounts->execute([$id]); + +$experiences = $pdo->prepare( + "SELECT pwe.id, pwe.company_id, c.display_name, c.industry, pwe.position, pwe.department, + pwe.job_level, pwe.start_date, pwe.end_date, pwe.is_current + FROM person_work_experiences pwe + LEFT JOIN companies c ON c.id = pwe.company_id + WHERE pwe.person_id = ? AND pwe.is_active = 1 + ORDER BY pwe.is_current DESC, pwe.start_date DESC" +); +$experiences->execute([$id]); + +Response::success(['person' => $person, 'accounts' => $accounts->fetchAll(), 'experiences' => $experiences->fetchAll()]); diff --git a/api/person/export.php b/api/person/export.php new file mode 100644 index 0000000..d3d6b98 --- /dev/null +++ b/api/person/export.php @@ -0,0 +1,59 @@ + 0) { + $idList[] = $v; + } + } +} +$idList = array_unique($idList); +if (!$idList) { + Response::error('请先选择需要导出的人员数据', 1); +} + +$where = ['p.id IN (' . implode(',', array_fill(0, count($idList), '?')) . ')', 'p.is_active = 1']; +$params = $idList; +$whereSql = implode(' AND ', $where); + +$pdo = DB::getInstance()->getPdo(); +$stmt = $pdo->prepare( + "SELECT p.id, p.union_id, p.full_name, p.gender, p.nationality, p.id_type, p.id_number, + p.education, p.graduated_from, p.hometown, p.work_location, p.created_at, + (SELECT sa.account_id FROM social_accounts sa WHERE sa.owner_type='person' AND sa.owner_id=p.id AND sa.platform='phone' AND sa.is_active=1 LIMIT 1) AS phone, + (SELECT sa.account_id FROM social_accounts sa WHERE sa.owner_type='person' AND sa.owner_id=p.id AND sa.platform='email' AND sa.is_active=1 LIMIT 1) AS email + FROM persons p WHERE $whereSql ORDER BY p.id DESC" +); +$stmt->execute($params); +$list = $stmt->fetchAll(); + +logCurrent('export', 'person', 'persons', null, ['count' => count($list)]); + +header('Content-Type: text/csv; charset=utf-8'); +header('Content-Disposition: attachment; filename="persons_' . date('Ymd_His') . '.csv"'); +echo "\xEF\xBB\xBF"; +$out = fopen('php://output', 'w'); +fputcsv($out, ['ID', 'union_id', '姓名', '性别', '国籍', '证件类型', '证件号', '学历', '毕业院校', '家乡', '工作所在地', '手机', '邮箱', '创建时间']); +foreach ($list as $r) { + fputcsv($out, [ + $r['id'], $r['union_id'], $r['full_name'], $r['gender'], $r['nationality'], + $r['id_type'], $r['id_number'], $r['education'], $r['graduated_from'], + $r['hometown'], $r['work_location'], $r['phone'], $r['email'], $r['created_at'], + ]); +} +fclose($out); +exit; diff --git a/api/person/import.php b/api/person/import.php new file mode 100644 index 0000000..f5dcdf4 --- /dev/null +++ b/api/person/import.php @@ -0,0 +1,82 @@ +getPdo(); +$insPerson = $pdo->prepare( + "INSERT INTO persons (union_id, full_name, gender, nationality, id_type, id_number, education, graduated_from, hometown, work_location) + VALUES (?,?,?,?,?,?,?,?,?,?)" +); +$insAccount = $pdo->prepare( + "INSERT INTO social_accounts (owner_type, owner_id, platform, account_id, is_primary) + VALUES ('person', ?, ?, ?, ?)" +); + +$inserted = 0; +$failed = 0; + +while (($row = fgetcsv($handle)) !== false) { + $row = array_map('trim', $row); + $rec = []; + foreach ($header as $idx => $col) { + $col = trim($col); + if (isset($row[$idx])) { + $rec[$col] = $row[$idx]; + } + } + if (empty($rec['full_name'])) { + $failed++; + continue; + } + try { + $unionId = !empty($rec['union_id']) ? $rec['union_id'] : ('P' . date('YmdHis') . substr(uniqid(), -6)); + // 跳过重复 union_id + $chk = $pdo->prepare("SELECT COUNT(*) FROM persons WHERE union_id = ?"); + $chk->execute([$unionId]); + if ((int)$chk->fetchColumn() > 0) { + $failed++; + continue; + } + $insPerson->execute([ + $unionId, $rec['full_name'], $rec['gender'] ?? '保密', $rec['nationality'] ?? null, + $rec['id_type'] ?? null, $rec['id_number'] ?? null, $rec['education'] ?? null, + $rec['graduated_from'] ?? null, $rec['hometown'] ?? null, $rec['work_location'] ?? null, + ]); + $newId = (int)$pdo->lastInsertId(); + if (!empty($rec['phone'])) { + $insAccount->execute([$newId, 'phone', $rec['phone'], 1]); + } + if (!empty($rec['email'])) { + $insAccount->execute([$newId, 'email', $rec['email'], 0]); + } + $inserted++; + } catch (Exception $e) { + $failed++; + } +} +fclose($handle); + +logCurrent('import', 'person', 'persons', null, ['inserted' => $inserted, 'failed' => $failed]); +Response::success(['inserted' => $inserted, 'failed' => $failed], "导入完成:成功 $inserted 条,失败 $failed 条"); diff --git a/api/person/list.php b/api/person/list.php new file mode 100644 index 0000000..7ce861f --- /dev/null +++ b/api/person/list.php @@ -0,0 +1,61 @@ +getPdo(); + +$stmt = $pdo->prepare("SELECT COUNT(*) FROM persons p WHERE $whereSql"); +$stmt->execute($params); +$total = (int)$stmt->fetchColumn(); + +$offset = ($page - 1) * $limit; +$stmt = $pdo->prepare( + "SELECT p.id, p.union_id, p.full_name, p.gender, p.nationality, p.id_type, p.id_number, + p.education, p.graduated_from, p.hometown, p.work_location, p.is_active, p.created_at, + (SELECT GROUP_CONCAT(CONCAT(sa.platform, ':', sa.account_id) SEPARATOR ' | ') + FROM social_accounts sa WHERE sa.owner_type = 'person' AND sa.owner_id = p.id AND sa.is_active = 1) AS contacts + FROM persons p + WHERE $whereSql + ORDER BY p.id DESC + LIMIT $limit OFFSET $offset" +); +$stmt->execute($params); +$list = $stmt->fetchAll(); + +Response::success(['list' => $list, 'total' => $total, 'page' => $page, 'limit' => $limit]); diff --git a/api/person/update.php b/api/person/update.php new file mode 100644 index 0000000..e6dacc6 --- /dev/null +++ b/api/person/update.php @@ -0,0 +1,58 @@ +getPdo(); +$check = $pdo->prepare("SELECT * FROM persons WHERE id = ?"); +$check->execute([$id]); +$old = $check->fetch(); +if (!$old) { + Response::error('人员不存在'); +} + +$contactsChanged = false; +if (isset($_POST['contacts'])) { + $contactsChanged = true; + // 整体替换联系方式:先删旧(保留非联系人性质?此处直接删除全部后重建) + $pdo->prepare("DELETE FROM social_accounts WHERE owner_type = 'person' AND owner_id = ?")->execute([$id]); + $contacts = json_decode($_POST['contacts'], true); + if (is_array($contacts)) { + $ins = $pdo->prepare( + "INSERT INTO social_accounts (owner_type, owner_id, platform, account_id, profile_url, remark, is_primary) + VALUES ('person', ?, ?, ?, ?, ?, ?)" + ); + foreach ($contacts as $c) { + $platform = trim($c['platform'] ?? ''); + $accountId = trim($c['account_id'] ?? ''); + if ($platform === '' || $accountId === '') continue; + $ins->execute([$id, $platform, $accountId, $c['profile_url'] ?? null, $c['remark'] ?? null, !empty($c['is_primary']) ? 1 : 0]); + } + } +} + +if (!empty($data)) { + [$sets, $params] = buildUpdate($data); + $params[] = $id; + $pdo->prepare("UPDATE persons SET $sets WHERE id = ?")->execute($params); +} + +logCurrent('update', 'person', 'persons', $id, ['before' => $old, 'after' => $data, 'contacts_replaced' => $contactsChanged]); +Response::success(null, '更新成功'); diff --git a/api/preliminary/list.php b/api/preliminary/list.php new file mode 100644 index 0000000..5adc139 --- /dev/null +++ b/api/preliminary/list.php @@ -0,0 +1,51 @@ += ?'; $params[] = $startDate; } +if ($endDate !== '') { $where[] = 'DATE(created_at) <= ?'; $params[] = $endDate; } +$whereSql = implode(' AND ', $where); + +$pdo = DB::getInstance()->getPdo(); + +$stmt = $pdo->prepare("SELECT COUNT(*) FROM preliminary_data WHERE $whereSql"); +$stmt->execute($params); +$total = (int)$stmt->fetchColumn(); + +$offset = ($page - 1) * $limit; +$stmt = $pdo->prepare( + "SELECT id, source_type, company_name, person_name, contact_phone, contact_email, + target_product_category, description, status, source_channel, recorded_by, + follow_person, converted_type, converted_id, created_at, updated_at + FROM preliminary_data + WHERE $whereSql + ORDER BY id DESC + LIMIT $limit OFFSET $offset" +); +$stmt->execute($params); +$list = $stmt->fetchAll(); + +Response::success(['list' => $list, 'total' => $total, 'page' => $page, 'limit' => $limit]); diff --git a/api/preliminary/stats.php b/api/preliminary/stats.php new file mode 100644 index 0000000..b67b04d --- /dev/null +++ b/api/preliminary/stats.php @@ -0,0 +1,30 @@ +getPdo(); + +$total = (int)$pdo->query("SELECT COUNT(*) FROM preliminary_data WHERE is_active = 1")->fetchColumn(); +$today = (int)$pdo->query("SELECT COUNT(*) FROM preliminary_data WHERE is_active = 1 AND DATE(created_at) = CURDATE()")->fetchColumn(); + +$statusRows = $pdo->query( + "SELECT status, COUNT(*) AS cnt FROM preliminary_data WHERE is_active = 1 GROUP BY status" +)->fetchAll(); +$statusMap = []; +foreach ($statusRows as $r) { + $statusMap[$r['status']] = (int)$r['cnt']; +} + +Response::success([ + 'total' => $total, + 'today' => $today, + 'status' => $statusMap, + 'statuses' => ['待处理', '待分配', '处理中', '已转换', '已废弃', '已拒收'], +]); diff --git a/api/system/log_list.php b/api/system/log_list.php new file mode 100644 index 0000000..ba1a1a4 --- /dev/null +++ b/api/system/log_list.php @@ -0,0 +1,58 @@ += DATE_SUB(NOW(), INTERVAL 1 MONTH)']; +$params = []; +if ($keyword !== '') { + $where[] = '(username LIKE ? OR content LIKE ? OR module LIKE ?)'; + $like = "%$keyword%"; + array_push($params, $like, $like, $like); +} +if ($action !== '') { $where[] = 'action = ?'; $params[] = $action; } +if ($module !== '') { $where[] = 'module = ?'; $params[] = $module; } +if ($username !== '') { $where[] = 'username = ?'; $params[] = $username; } +if ($startDate !== '') { $where[] = 'DATE(created_at) >= ?'; $params[] = $startDate; } +if ($endDate !== '') { $where[] = 'DATE(created_at) <= ?'; $params[] = $endDate; } +$whereSql = implode(' AND ', $where); + +$pdo = DB::getInstance()->getPdo(); + +$stmt = $pdo->prepare("SELECT COUNT(*) FROM system_logs WHERE $whereSql"); +$stmt->execute($params); +$total = (int)$stmt->fetchColumn(); + +$offset = ($page - 1) * $limit; +$stmt = $pdo->prepare( + "SELECT id, user_id, username, action, module, target_table, target_id, content, ip, created_at + FROM system_logs + WHERE $whereSql + ORDER BY id DESC + LIMIT $limit OFFSET $offset" +); +$stmt->execute($params); +$list = $stmt->fetchAll(); + +// 筛选下拉字典 +$dict = [ + 'actions' => $pdo->query("SELECT DISTINCT action FROM system_logs ORDER BY action")->fetchAll(PDO::FETCH_COLUMN), + 'modules' => $pdo->query("SELECT DISTINCT module FROM system_logs ORDER BY module")->fetchAll(PDO::FETCH_COLUMN), +]; + +Response::success(['list' => $list, 'total' => $total, 'page' => $page, 'limit' => $limit, 'dict' => $dict]); diff --git a/api/system/role_add.php b/api/system/role_add.php new file mode 100644 index 0000000..41cce5b --- /dev/null +++ b/api/system/role_add.php @@ -0,0 +1,37 @@ +getPdo(); +$chk = $pdo->prepare("SELECT COUNT(*) FROM system_roles WHERE role_name = ?"); +$chk->execute([$roleName]); +if ((int)$chk->fetchColumn() > 0) { + Response::error('角色名称已存在'); +} + +$stmt = $pdo->prepare("INSERT INTO system_roles (role_name, permissions, is_active) VALUES (?, ?, ?)"); +$stmt->execute([$roleName, json_encode(array_values($perms), JSON_UNESCAPED_UNICODE), $isActive]); +$newId = (int)$pdo->lastInsertId(); + +logCurrent('add', 'system', 'system_roles', $newId, ['role_name' => $roleName, 'permissions' => $perms]); +Response::success(['id' => $newId], '新增成功'); diff --git a/api/system/role_delete.php b/api/system/role_delete.php new file mode 100644 index 0000000..61e8205 --- /dev/null +++ b/api/system/role_delete.php @@ -0,0 +1,53 @@ + 0) $idList[] = $id; +if ($ids !== '') { + foreach (explode(',', $ids) as $v) { + $v = (int)trim($v); + if ($v > 0) $idList[] = $v; + } +} +$idList = array_unique($idList); +if (!$idList) { + Response::error('参数错误', 400); +} +if (in_array(1, $idList, true)) { + Response::error('不允许删除内置超级管理员角色', 400); +} + +$pdo = DB::getInstance()->getPdo(); + +// 检查角色是否被使用 +$in = implode(',', array_fill(0, count($idList), '?')); +$stmt = $pdo->prepare("SELECT role_id, COUNT(*) AS cnt FROM system_users WHERE role_id IN ($in) GROUP BY role_id"); +$stmt->execute($idList); +$inUse = $stmt->fetchAll(); +if ($inUse) { + $names = []; + foreach ($inUse as $r) { + $names[] = "角色ID {$r['role_id']}({$r['cnt']}个用户)"; + } + Response::error('以下角色仍被用户使用,无法删除:' . implode('、', $names), 400); +} + +$del = $pdo->prepare("DELETE FROM system_roles WHERE id IN ($in)"); +$del->execute($idList); +$affected = $del->rowCount(); + +logCurrent('delete', 'system', 'system_roles', null, ['ids' => $idList]); +Response::success(['affected' => $affected], "已删除 $affected 个角色"); diff --git a/api/system/role_list.php b/api/system/role_list.php new file mode 100644 index 0000000..82bd340 --- /dev/null +++ b/api/system/role_list.php @@ -0,0 +1,47 @@ +getPdo(); + +$stmt = $pdo->prepare("SELECT COUNT(*) FROM system_roles WHERE $whereSql"); +$stmt->execute($params); +$total = (int)$stmt->fetchColumn(); + +$offset = ($page - 1) * $limit; +$stmt = $pdo->prepare( + "SELECT id, role_name, permissions, is_active, created_at, + (SELECT COUNT(*) FROM system_users u WHERE u.role_id = system_roles.id) AS user_count + FROM system_roles + WHERE $whereSql + ORDER BY id ASC + LIMIT $limit OFFSET $offset" +); +$stmt->execute($params); +$list = $stmt->fetchAll(); +foreach ($list as &$r) { + $r['permissions'] = json_decode($r['permissions'], true) ?: []; +} +unset($r); + +Response::success(['list' => $list, 'total' => $total, 'page' => $page, 'limit' => $limit]); diff --git a/api/system/role_update.php b/api/system/role_update.php new file mode 100644 index 0000000..840e068 --- /dev/null +++ b/api/system/role_update.php @@ -0,0 +1,69 @@ +getPdo(); +$check = $pdo->prepare("SELECT * FROM system_roles WHERE id = ?"); +$check->execute([$id]); +$old = $check->fetch(); +if (!$old) { + Response::error('角色不存在'); +} + +// 超级管理员角色保护:不允许移除其自身 +if ($id === 1) { + Response::error('内置超级管理员角色不允许修改', 400); +} + +$sets = []; +$params = []; + +if (isset($_POST['role_name'])) { + $roleName = trim($_POST['role_name']); + if ($roleName === '') { + Response::error('角色名称不能为空', 400); + } + $chk = $pdo->prepare("SELECT COUNT(*) FROM system_roles WHERE role_name = ? AND id <> ?"); + $chk->execute([$roleName, $id]); + if ((int)$chk->fetchColumn() > 0) { + Response::error('角色名称已存在'); + } + $sets[] = 'role_name = ?'; + $params[] = $roleName; +} +if (isset($_POST['permissions'])) { + $perms = json_decode($_POST['permissions'], true); + if (!is_array($perms)) { + Response::error('权限格式错误', 400); + } + $sets[] = 'permissions = ?'; + $params[] = json_encode(array_values($perms), JSON_UNESCAPED_UNICODE); +} +if (isset($_POST['is_active'])) { + $sets[] = 'is_active = ?'; + $params[] = (int)$_POST['is_active'] ? 1 : 0; +} + +if (!$sets) { + Response::error('没有需要更新的字段', 400); +} + +$params[] = $id; +$pdo->prepare("UPDATE system_roles SET " . implode(', ', $sets) . " WHERE id = ?")->execute($params); + +logCurrent('update', 'system', 'system_roles', $id, ['before' => $old, 'after' => $sets]); +Response::success(null, '更新成功'); diff --git a/api/system/user_add.php b/api/system/user_add.php new file mode 100644 index 0000000..9b8a490 --- /dev/null +++ b/api/system/user_add.php @@ -0,0 +1,52 @@ +getPdo(); + +$chk = $pdo->prepare("SELECT COUNT(*) FROM system_users WHERE username = ?"); +$chk->execute([$username]); +if ((int)$chk->fetchColumn() > 0) { + Response::error('账号已存在'); +} + +$chkRole = $pdo->prepare("SELECT COUNT(*) FROM system_roles WHERE id = ?"); +$chkRole->execute([$roleId]); +if ((int)$chkRole->fetchColumn() === 0) { + Response::error('角色不存在', 400); +} + +$stmt = $pdo->prepare( + "INSERT INTO system_users (username, password, real_name, role_id, is_active) + VALUES (?, ?, ?, ?, ?)" +); +$stmt->execute([$username, sha1($password), $realName !== '' ? $realName : null, $roleId, $isActive]); +$newId = (int)$pdo->lastInsertId(); + +logCurrent('add', 'system', 'system_users', $newId, ['username' => $username, 'role_id' => $roleId, 'is_active' => $isActive]); +Response::success(['id' => $newId], '新增成功'); diff --git a/api/system/user_delete.php b/api/system/user_delete.php new file mode 100644 index 0000000..a334f53 --- /dev/null +++ b/api/system/user_delete.php @@ -0,0 +1,40 @@ + 0) $idList[] = $id; +if ($ids !== '') { + foreach (explode(',', $ids) as $v) { + $v = (int)trim($v); + if ($v > 0) $idList[] = $v; + } +} +$idList = array_unique($idList); +if (!$idList) { + Response::error('参数错误', 400); +} +if (in_array(1, $idList, true)) { + Response::error('不允许删除内置管理员账号', 400); +} + +$pdo = DB::getInstance()->getPdo(); +$in = implode(',', array_fill(0, count($idList), '?')); +$stmt = $pdo->prepare("DELETE FROM system_users WHERE id IN ($in)"); +$stmt->execute($idList); +$affected = $stmt->rowCount(); + +logCurrent('delete', 'system', 'system_users', null, ['ids' => $idList]); +Response::success(['affected' => $affected], "已删除 $affected 个用户"); diff --git a/api/system/user_list.php b/api/system/user_list.php new file mode 100644 index 0000000..abfeace --- /dev/null +++ b/api/system/user_list.php @@ -0,0 +1,50 @@ + 0) { $where[] = 'u.role_id = ?'; $params[] = $roleId; } +if ($active !== null) { $where[] = 'u.is_active = ?'; $params[] = $active; } +$whereSql = implode(' AND ', $where); + +$pdo = DB::getInstance()->getPdo(); + +$stmt = $pdo->prepare("SELECT COUNT(*) FROM system_users u WHERE $whereSql"); +$stmt->execute($params); +$total = (int)$stmt->fetchColumn(); + +$offset = ($page - 1) * $limit; +$stmt = $pdo->prepare( + "SELECT u.id, u.username, u.real_name, u.role_id, r.role_name, u.is_active, + u.last_login_time, u.last_login_ip, u.created_at + FROM system_users u + LEFT JOIN system_roles r ON r.id = u.role_id + WHERE $whereSql + ORDER BY u.id ASC + LIMIT $limit OFFSET $offset" +); +$stmt->execute($params); +$list = $stmt->fetchAll(); + +// 角色下拉 +$roles = $pdo->query("SELECT id, role_name FROM system_roles WHERE is_active = 1 ORDER BY id")->fetchAll(); + +Response::success(['list' => $list, 'total' => $total, 'page' => $page, 'limit' => $limit, 'roles' => $roles]); diff --git a/api/system/user_update.php b/api/system/user_update.php new file mode 100644 index 0000000..0753d32 --- /dev/null +++ b/api/system/user_update.php @@ -0,0 +1,70 @@ +getPdo(); +$check = $pdo->prepare("SELECT * FROM system_users WHERE id = ?"); +$check->execute([$id]); +$old = $check->fetch(); +if (!$old) { + Response::error('用户不存在'); +} + +// 内置管理员保护:不允许禁用 id=1(admin) +if ($id === 1 && isset($_POST['is_active']) && (int)$_POST['is_active'] === 0) { + Response::error('不允许禁用内置管理员账号', 400); +} + +$sets = []; +$params = []; + +if (isset($_POST['real_name'])) { + $sets[] = 'real_name = ?'; + $params[] = trim($_POST['real_name']) !== '' ? trim($_POST['real_name']) : null; +} +if (isset($_POST['role_id'])) { + $roleId = (int)$_POST['role_id']; + $chkRole = $pdo->prepare("SELECT COUNT(*) FROM system_roles WHERE id = ?"); + $chkRole->execute([$roleId]); + if ((int)$chkRole->fetchColumn() === 0) { + Response::error('角色不存在', 400); + } + $sets[] = 'role_id = ?'; + $params[] = $roleId; +} +if (isset($_POST['is_active'])) { + $sets[] = 'is_active = ?'; + $params[] = (int)$_POST['is_active'] ? 1 : 0; +} +$password = (string)($_POST['password'] ?? ''); +if ($password !== '') { + if (strlen($password) < 6) { + Response::error('密码长度不能少于6位', 400); + } + $sets[] = 'password = ?'; + $params[] = sha1($password); +} + +if (!$sets) { + Response::error('没有需要更新的字段', 400); +} + +$params[] = $id; +$pdo->prepare("UPDATE system_users SET " . implode(', ', $sets) . " WHERE id = ?")->execute($params); + +logCurrent('update', 'system', 'system_users', $id, ['before' => ['username' => $old['username'], 'role_id' => $old['role_id'], 'is_active' => $old['is_active']]]); +Response::success(null, '更新成功'); diff --git a/bump_version.bat b/bump_version.bat new file mode 100644 index 0000000..626c625 --- /dev/null +++ b/bump_version.bat @@ -0,0 +1,29 @@ +@echo off +rem ============================================================ +rem 版本号递增(Windows 双击运行) +rem 每次修改代码后双击本文件,版本号 v1.0.01 -> v1.0.02 ... +rem ============================================================ +setlocal +set SCRIPT=%~dp0tools\bump_version.php + +rem 1) 优先使用 PATH 中的 php +where php >nul 2>nul +if %errorlevel%==0 ( + php "%SCRIPT%" + goto :done +) + +rem 2) 自动查找 phpstudy 内置 PHP +if exist "C:\phpstudy_pro\Extensions\php" ( + for /r "C:\phpstudy_pro\Extensions\php" %%f in (php.exe) do ( + "%%f" "%SCRIPT%" + goto :done + ) +) + +echo [错误] 未找到 php.exe,请安装 phpstudy 或将 PHP 加入 PATH。 +pause +exit /b 1 + +:done +pause diff --git a/channel.html b/channel.html new file mode 100644 index 0000000..20246e2 --- /dev/null +++ b/channel.html @@ -0,0 +1,17 @@ + + +
+ + +| ID | 渠道名称 | 类型 | 联系人 | 电话 | 邮箱 | 效率评分 | 备注 | 创建时间 | 操作 |
|---|
| ID | 公司名称 | 行业 | 行业细分 | 国家 | 业务角色 | 注册号 | 法人 | 上市 | 创建时间 | 操作 |
|---|
| ' + h + ' | '; }).join('') + '
|---|
| ' + escHtml(v) + ' | '; }).join('') + '
| 姓名 | 联系方式 | 邮箱 | 职级 | 部门 | 岗位 | 入职日期 | 是否在职 |
|---|
| ID | 文档名称 | 文件类型 | 文件 | 版本 | 发布日期 | 上传时间 | 操作 |
|---|
| 邮箱 | 姓名 | 当前公司 | 工作所在地 | 家乡 |
|---|
' + escHtml(d.description || '功能建设中') + '
' + + '' + escHtml(d.description || '功能建设中') + '
' + + '| ID | 归属 | 平台 | 账号 | 认证类型 | 等级 | 粉丝数 | 平均阅读 | 内容领域 | 操作 |
|---|
| ID | 公司 | 行业 | 联系人 | 需求大类 | 意向品类 | 描述 | 状态 | 录入时间 | 操作 |
|---|
| ID | 姓名 | 性别 | 国籍 | 工作所在地 | 家乡 | 学历 | 联系方式 | 创建时间 | 操作 |
|---|
| 公司 | 职位 | 部门 | 级别 | 时间 |
|---|---|---|---|---|
| 暂无 | ||||
| 公司名称 | 部门 | 岗位 | 职级 | 入职日期 | 是否在岗 |
|---|
| ID | 类型 | 公司 | 姓名 | 电话 | 邮箱 | 意向品类 | 描述 | 状态 | 跟进人 | 录入时间 |
|---|
| ID | 账号 | 姓名 | 角色 | 状态 | 最后登录 | 登录IP | 创建时间 | 操作 |
|---|
| ID | 角色名称 | 权限 | 用户数 | 状态 | 创建时间 | 操作 |
|---|
| ID | 账号 | 操作 | 模块 | 对象表 | 对象ID | 内容 | IP | 时间 |
|---|
=a)}}for(var h=this.__startIndex;h t.unconstrainedWidth?null:d:null;i.setStyle("width",f)}var g=i.getBoundingRect();o.width=g.width;var y=(i.style.margin||0)+2.1;o.height=g.height+y,o.y-=(o.height-c)/2}}}function _M(t){return"center"===t.position}function bM(t){var e,n,i=t.getData(),r=[],o=!1,a=(t.get("minShowLabelAngle")||0)*vM,s=i.getLayout("viewRect"),l=i.getLayout("r"),u=s.width,h=s.x,c=s.y,p=s.height;function d(t){t.ignore=!0}i.each((function(t){var s=i.getItemGraphicEl(t),c=s.shape,p=s.getTextContent(),f=s.getTextGuideLine(),g=i.getItemModel(t),y=g.getModel("label"),v=y.get("position")||g.get(["emphasis","label","position"]),m=y.get("distanceToLabelLine"),x=y.get("alignTo"),_=Ur(y.get("edgeDistance"),u),b=y.get("bleedMargin"),w=g.getModel("labelLine"),S=w.get("length");S=Ur(S,u);var M=w.get("length2");if(M=Ur(M,u),Math.abs(c.endAngle-c.startAngle)0?"right":"left":k>0?"left":"right"}var B=Math.PI,F=0,G=y.get("rotate");if(j(G))F=G*(B/180);else if("center"===v)F=0;else if("radial"===G||!0===G){F=k<0?-A+B:-A}else if("tangential"===G&&"outside"!==v&&"outer"!==v){var W=Math.atan2(k,L);W<0&&(W=2*B+W),L>0&&(W=B+W),F=W-B}if(o=!!F,p.x=I,p.y=T,p.rotation=F,p.setStyle({verticalAlign:"middle"}),P){p.setStyle({align:D});var H=p.states.select;H&&(H.x+=p.x,H.y+=p.y)}else{var Y=p.getBoundingRect().clone();Y.applyTransform(p.getComputedTransform());var X=(p.style.margin||0)+2.1;Y.y-=X/2,Y.height+=X,r.push({label:p,labelLine:f,position:v,len:S,len2:M,minTurnAngle:w.get("minTurnAngle"),maxSurfaceAngle:w.get("maxSurfaceAngle"),surfaceNormal:new De(k,L),linePoints:C,textAlign:D,labelDistance:m,labelAlignTo:x,edgeDistance:_,bleedMargin:b,rect:Y,unconstrainedWidth:Y.width,labelStyleWidth:p.style.width})}s.setTextConfig({inside:P})}})),!o&&t.get("avoidLabelOverlap")&&function(t,e,n,i,r,o,a,s){for(var l=[],u=[],h=Number.MAX_VALUE,c=-Number.MAX_VALUE,p=0;p i&&(i=e);var o=i%2?i+2:i+3;r=[];for(var a=0;a5)return;var i=this._model.coordinateSystem.getSlidedAxisExpandWindow([t.offsetX,t.offsetY]);"none"!==i.behavior&&this._dispatchExpand({axisExpandWindow:i.axisExpandWindow})}this._mouseDownPoint=null},mousemove:function(t){if(!this._mouseDownPoint&&Mk(this,"mousemove")){var e=this._model,n=e.coordinateSystem.getSlidedAxisExpandWindow([t.offsetX,t.offsetY]),i=n.behavior;"jump"===i&&this._throttledDispatchExpand.debounceNextCall(e.get("axisExpandDebounce")),this._throttledDispatchExpand("none"===i?null:{axisExpandWindow:n.axisExpandWindow,animation:"jump"===i?null:{duration:0}})}}};function Mk(t,e){var n=t._model;return n.get("axisExpandable")&&n.get("axisExpandTriggerOn")===e}var Ik=function(t){function e(){var n=null!==t&&t.apply(this,arguments)||this;return n.type=e.type,n}return n(e,t),e.prototype.init=function(){t.prototype.init.apply(this,arguments),this.mergeOption({})},e.prototype.mergeOption=function(t){var e=this.option;t&&C(e,t,!0),this._initDimensions()},e.prototype.contains=function(t,e){var n=t.get("parallelIndex");return null!=n&&e.getComponent("parallel",n)===this},e.prototype.setAxisExpand=function(t){E(["axisExpandable","axisExpandCenter","axisExpandCount","axisExpandWidth","axisExpandWindow"],(function(e){t.hasOwnProperty(e)&&(this.option[e]=t[e])}),this)},e.prototype._initDimensions=function(){var t=this.dimensions=[],e=this.parallelAxisIndex=[];E(B(this.ecModel.queryComponents({mainType:"parallelAxis"}),(function(t){return(t.get("parallelIndex")||0)===this.componentIndex}),this),(function(n){t.push("dim"+n.get("dim")),e.push(n.componentIndex)}))},e.type="parallel",e.dependencies=["parallelAxis"],e.layoutMode="box",e.defaultOption={z:0,left:80,top:60,right:80,bottom:60,layout:"horizontal",axisExpandable:!1,axisExpandCenter:null,axisExpandCount:0,axisExpandWidth:50,axisExpandRate:17,axisExpandDebounce:50,axisExpandSlideTriggerArea:[-.15,.05,.4],axisExpandTriggerOn:"click",parallelAxisDefault:null},e}(Rp),Tk=function(t){function e(e,n,i,r,o){var a=t.call(this,e,n,i)||this;return a.type=r||"value",a.axisIndex=o,a}return n(e,t),e.prototype.isHorizontal=function(){return"horizontal"!==this.coordinateSystem.getModel().get("layout")},e}(nb);function Ck(t,e,n,i,r,o){t=t||0;var a=n[1]-n[0];if(null!=r&&(r=Ak(r,[0,a])),null!=o&&(o=Math.max(o,null!=r?r:0)),"all"===i){var s=Math.abs(e[1]-e[0]);s=Ak(s,[0,a]),r=o=Ak(s,[r,o]),i=0}e[0]=Ak(e[0],n),e[1]=Ak(e[1],n);var l=Dk(e,i);e[i]+=t;var u,h=r||0,c=n.slice();return l.sign<0?c[0]+=h:c[1]-=h,e[i]=Ak(e[i],c),u=Dk(e,i),null!=r&&(u.sign!==l.sign||u.span