diff --git a/api/channel/analysis.php b/api/channel/analysis.php new file mode 100644 index 0000000..611714c --- /dev/null +++ b/api/channel/analysis.php @@ -0,0 +1,85 @@ + 2100)) { + $year = ''; +} + +$pdo = DB::getInstance()->getPdo(); + +// 可选年份(企业/人员创建年份并集) +$years = $pdo->query( + "SELECT DISTINCT y FROM ( + SELECT YEAR(created_at) AS y FROM companies WHERE is_active = 1 AND created_at IS NOT NULL + UNION + SELECT YEAR(created_at) AS y FROM persons WHERE is_active = 1 AND created_at IS NOT NULL + ) t ORDER BY y DESC" +)->fetchAll(PDO::FETCH_COLUMN); + +/** 单维度渠道分析 */ +function channelAnalysis($pdo, $table, $year) +{ + $where = 'is_active = 1 AND source_channel IS NOT NULL AND source_channel <> \'\''; + $params = []; + if ($year !== '') { + $where .= ' AND YEAR(created_at) = ?'; + $params[] = (int)$year; + } + + $stmt = $pdo->prepare( + "SELECT source_channel AS channel, COUNT(*) AS cnt + FROM $table WHERE $where + GROUP BY source_channel ORDER BY cnt DESC" + ); + $stmt->execute($params); + $rows = $stmt->fetchAll(); + + $total = 0; + foreach ($rows as $r) { + $total += (int)$r['cnt']; + } + + $result = []; + foreach ($rows as $r) { + $channel = $r['channel']; + // 该渠道 source_detail TOP10 + $detailStmt = $pdo->prepare( + "SELECT COALESCE(NULLIF(TRIM(source_detail), ''), '(未填写)') AS source, COUNT(*) AS cnt + FROM $table + WHERE is_active = 1 AND source_channel = ?" . ($year !== '' ? ' AND YEAR(created_at) = ?' : '') . " + GROUP BY source ORDER BY cnt DESC LIMIT 10" + ); + $dParams = [$channel]; + if ($year !== '') { + $dParams[] = (int)$year; + } + $detailStmt->execute($dParams); + + $result[] = [ + 'channel' => $channel, + 'count' => (int)$r['cnt'], + 'percent' => $total > 0 ? round(((int)$r['cnt'] / $total) * 100, 1) : 0, + 'details' => $detailStmt->fetchAll(), + ]; + } + return $result; +} + +Response::success([ + 'years' => $years, + 'company' => channelAnalysis($pdo, 'companies', $year), + 'person' => channelAnalysis($pdo, 'persons', $year), +]); diff --git a/api/channel/delete.php b/api/channel/delete.php deleted file mode 100644 index 30c1d11..0000000 --- a/api/channel/delete.php +++ /dev/null @@ -1,36 +0,0 @@ - 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 deleted file mode 100644 index e0362dc..0000000 --- a/api/channel/list.php +++ /dev/null @@ -1,50 +0,0 @@ -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/plan_delete.php b/api/channel/plan_delete.php new file mode 100644 index 0000000..c06a8b6 --- /dev/null +++ b/api/channel/plan_delete.php @@ -0,0 +1,27 @@ +getPdo(); +$stmt = $pdo->prepare("UPDATE channel_plans SET is_active = 0 WHERE id = ?"); +$stmt->execute([$id]); +if ($stmt->rowCount() === 0) { + Response::error('计划不存在'); +} + +logCurrent('delete', 'channel_plan', 'channel_plans', $id, ['soft_delete' => true]); +Response::success(null, '删除成功'); diff --git a/api/channel/plan_list.php b/api/channel/plan_list.php new file mode 100644 index 0000000..9871a7a --- /dev/null +++ b/api/channel/plan_list.php @@ -0,0 +1,64 @@ +getPdo(); + +$stmt = $pdo->prepare("SELECT COUNT(*) FROM channel_plans WHERE $whereSql"); +$stmt->execute($params); +$total = (int)$stmt->fetchColumn(); + +$offset = ($page - 1) * $limit; +$stmt = $pdo->prepare( + "SELECT id, channel_type, source_detail, industry, start_date, end_date, remark, status, created_at, updated_at + FROM channel_plans + WHERE $whereSql + ORDER BY id DESC + LIMIT $limit OFFSET $offset" +); +$stmt->execute($params); +$list = $stmt->fetchAll(); + +// 剩余天数:距时间窗口结束日(end_date)的天数,已过期为 0 +$today = strtotime(date('Y-m-d')); +foreach ($list as &$row) { + $row['remaining_days'] = 0; + if (!empty($row['end_date']) && strtotime($row['end_date']) !== false) { + $diff = (int)ceil((strtotime($row['end_date']) - $today) / 86400); + $row['remaining_days'] = max(0, $diff); + } +} +unset($row); + +Response::success(['list' => $list, 'total' => $total, 'page' => $page, 'limit' => $limit]); diff --git a/api/channel/plan_save.php b/api/channel/plan_save.php new file mode 100644 index 0000000..e32431f --- /dev/null +++ b/api/channel/plan_save.php @@ -0,0 +1,55 @@ +getPdo(); + +$fields = [ + 'channel_type' => $channelType, + 'source_detail' => trim($_POST['source_detail'] ?? '') !== '' ? trim($_POST['source_detail']) : null, + 'industry' => trim($_POST['industry'] ?? '') !== '' ? trim($_POST['industry']) : null, + 'start_date' => trim($_POST['start_date'] ?? '') !== '' ? $_POST['start_date'] : null, + 'end_date' => trim($_POST['end_date'] ?? '') !== '' ? $_POST['end_date'] : null, + 'remark' => trim($_POST['remark'] ?? '') !== '' ? trim($_POST['remark']) : null, + 'status' => $status, +]; + +if ($id > 0) { + $check = $pdo->prepare("SELECT id FROM channel_plans WHERE id = ? AND is_active = 1"); + $check->execute([$id]); + if (!$check->fetch()) { + Response::error('计划不存在'); + } + [$sets, $params] = buildUpdate($fields); + $params[] = $id; + $pdo->prepare("UPDATE channel_plans SET $sets WHERE id = ?")->execute($params); + logCurrent('update', 'channel_plan', 'channel_plans', $id, $fields); + Response::success(null, '更新成功'); +} + +[$sql, $params] = buildInsert($fields); +$pdo->prepare("INSERT INTO channel_plans $sql")->execute($params); +$newId = (int)$pdo->lastInsertId(); +logCurrent('add', 'channel_plan', 'channel_plans', $newId, $fields); +Response::success(['id' => $newId], '新增成功'); diff --git a/api/channel/save.php b/api/channel/save.php deleted file mode 100644 index 001cec4..0000000 --- a/api/channel/save.php +++ /dev/null @@ -1,53 +0,0 @@ - $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 deleted file mode 100644 index 4a4bf2d..0000000 --- a/api/channel/stats.php +++ /dev/null @@ -1,44 +0,0 @@ - 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/dicts.php b/api/common/dicts.php index f0611ed..0f3b7ea 100644 --- a/api/common/dicts.php +++ b/api/common/dicts.php @@ -18,22 +18,22 @@ $data = []; if ($type === 'all' || $type === 'country') { $data['countries'] = $pdo->query( - "SELECT id, code, name FROM date_dict_country WHERE is_active = 1 ORDER BY sort_order, id" + "SELECT id, code, name FROM data_dict_country WHERE is_active = 1 ORDER BY sort_order, id" )->fetchAll(); } if ($type === 'all' || $type === 'area') { $data['areas'] = $pdo->query( - "SELECT id, code, name, parent_code FROM date_dict_area WHERE is_active = 1 ORDER BY sort_order, id" + "SELECT id, code, name, parent_code FROM data_dict_area WHERE is_active = 1 ORDER BY sort_order, id" )->fetchAll(); } if ($type === 'all' || $type === 'industry') { $data['industries'] = $pdo->query( - "SELECT id, code, name, parent_code FROM date_dict_industry WHERE is_active = 1 ORDER BY sort_order, id" + "SELECT id, code, name, parent_code FROM data_dict_industry WHERE is_active = 1 ORDER BY sort_order, id" )->fetchAll(); } if ($type === 'all' || $type === 'certificate') { $data['certificates'] = $pdo->query( - "SELECT id, code, name FROM date_dict_certificate WHERE is_active = 1 ORDER BY sort_order, id" + "SELECT id, code, name FROM data_dict_certificate WHERE is_active = 1 ORDER BY sort_order, id" )->fetchAll(); } if ($type === 'all' || $type === 'source_channel') { @@ -41,5 +41,10 @@ if ($type === 'all' || $type === 'source_channel') { "SELECT id, name FROM source_channels WHERE is_active = 1 ORDER BY sort_order, id" )->fetchAll(); } +if ($type === 'all' || $type === 'company_type') { + $data['company_types'] = $pdo->query( + "SELECT id, code, name FROM data_dict_company_type WHERE is_active = 1 ORDER BY sort_order, id" + )->fetchAll(); +} Response::success($data); diff --git a/api/common/helpers.php b/api/common/helpers.php index 5b75b2b..0160fd3 100644 --- a/api/common/helpers.php +++ b/api/common/helpers.php @@ -110,6 +110,87 @@ function strOrNull($v) return ($v === '') ? null : $v; } +/** + * 保存人员工作履历(整表替换:先删后插) + * @param PDO $pdo + * @param int $personId + * @param array $experiences JSON 解码后的数组 [{company_id,position,department,job_level,start_date,end_date,is_current}] + */ +function savePersonExperiences($pdo, $personId, $experiences) +{ + $del = $pdo->prepare("DELETE FROM person_work_experiences WHERE person_id = ?"); + $del->execute([$personId]); + if (empty($experiences)) { + return; + } + $ins = $pdo->prepare( + "INSERT INTO person_work_experiences + (person_id, company_id, position, department, job_level, start_date, end_date, is_current) + VALUES (?,?,?,?,?,?,?,?)" + ); + foreach ($experiences as $e) { + if (!is_array($e)) { + continue; + } + $companyId = (int)($e['company_id'] ?? 0); + if ($companyId <= 0) { + continue; + } + $start = strOrNull($e['start_date'] ?? null); + $end = strOrNull($e['end_date'] ?? null); + $ins->execute([ + $personId, + $companyId, + strOrNull($e['position'] ?? null), + strOrNull($e['department'] ?? null), + strOrNull($e['job_level'] ?? null), + $start !== null && strtotime($start) !== false ? date('Y-m-d', strtotime($start)) : null, + $end !== null && strtotime($end) !== false ? date('Y-m-d', strtotime($end)) : null, + !empty($e['is_current']) ? 1 : 0, + ]); + } +} + +/** + * 保存企业联系方式(social_accounts,整表替换:先删后插) + * 只操作 owner_type = 'company' 的记录 + * @param PDO $pdo + * @param int $companyId + * @param array $accounts JSON 解码后的数组 [{platform,account_id,profile_url,remark,is_active,is_defult}] + */ +function saveCompanyAccounts($pdo, $companyId, $accounts) +{ + $del = $pdo->prepare("DELETE FROM social_accounts WHERE owner_type = 'company' AND owner_id = ?"); + $del->execute([$companyId]); + if (empty($accounts)) { + return; + } + $ins = $pdo->prepare( + "INSERT INTO social_accounts + (owner_type, owner_id, platform, account_id, profile_url, remark, is_primary, is_defult, is_active) + VALUES ('company', ?, ?, ?, ?, ?, 0, ?, ?)" + ); + foreach ($accounts as $a) { + if (!is_array($a)) { + continue; + } + $platform = trim($a['platform'] ?? ''); + $accountId = trim($a['account_id'] ?? ''); + if ($platform === '' || $accountId === '') { + continue; + } + $ins->execute([ + $companyId, + $platform, + $accountId, + strOrNull($a['profile_url'] ?? null), + strOrNull($a['remark'] ?? null), + !empty($a['is_defult']) ? 1 : 0, + !empty($a['is_active']) ? 1 : 0, + ]); + } +} + /** * 保存企业年度财务明细(整表替换:先删后插) * @param PDO $pdo @@ -175,11 +256,11 @@ function saveCompanyCertifications($pdo, $companyId, $certifications) return; } - $valid = $pdo->prepare("SELECT id FROM date_dict_certificate WHERE id = ?"); + $valid = $pdo->prepare("SELECT id FROM data_dict_certificate WHERE id = ?"); $ins = $pdo->prepare( "INSERT INTO company_certifications - (company_id, certification_type_id, certificate_number, issue_date, expiry_date) - VALUES (?,?,?,?,?)" + (company_id, certification_type_id, certificate_number, level, issuing_authority, issue_date, expiry_date) + VALUES (?,?,?,?,?,?,?)" ); $seen = []; foreach ($certifications as $ct) { @@ -199,6 +280,8 @@ function saveCompanyCertifications($pdo, $companyId, $certifications) $companyId, $tid, strOrNull($ct['certificate_number'] ?? null), + strOrNull($ct['level'] ?? null), + strOrNull($ct['issuing_authority'] ?? null), strOrNull($ct['issue_date'] ?? null), strOrNull($ct['expiry_date'] ?? null), ]); diff --git a/api/company/add.php b/api/company/add.php index aa1a212..0839646 100644 --- a/api/company/add.php +++ b/api/company/add.php @@ -26,8 +26,8 @@ $pdo = DB::getInstance()->getPdo(); $pdo->prepare("INSERT INTO companies $sql")->execute($params); $newId = (int)$pdo->lastInsertId(); -// 财务信息 / 资质认证(可选,整表替换) -if (array_key_exists('financials', $_POST) || array_key_exists('certifications', $_POST)) { +// 财务信息 / 资质认证 / 联系方式(可选,整表替换) +if (array_key_exists('financials', $_POST) || array_key_exists('certifications', $_POST) || array_key_exists('accounts', $_POST)) { $pdo->beginTransaction(); try { if (array_key_exists('financials', $_POST)) { @@ -46,6 +46,14 @@ if (array_key_exists('financials', $_POST) || array_key_exists('certifications', } saveCompanyCertifications($pdo, $newId, $certs); } + if (array_key_exists('accounts', $_POST)) { + $accounts = json_decode($_POST['accounts'], true); + if (!is_array($accounts)) { + $pdo->rollBack(); + Response::error('联系方式格式错误', 400); + } + saveCompanyAccounts($pdo, $newId, $accounts); + } $pdo->commit(); } catch (Exception $e) { $pdo->rollBack(); diff --git a/api/company/cert_types.php b/api/company/cert_types.php index 872713e..62985c6 100644 --- a/api/company/cert_types.php +++ b/api/company/cert_types.php @@ -2,7 +2,7 @@ /** * 认证类型字典接口 GET /api/company/cert_types.php * 返回启用中的认证类型(如:瞪羚企业、国家高新技术企业等),供编辑弹窗下拉使用 - * 数据源:date_dict_certificate(原 certification_types,v1.0.16 改名) + * 数据源:data_dict_certificate(原 certification_types,v1.0.16 改名,v1.0.18 再改名 data_dict_certificate) */ require_once __DIR__ . '/../common/db.php'; require_once __DIR__ . '/../common/response.php'; @@ -11,6 +11,6 @@ require_once __DIR__ . '/../common/auth.php'; checkPermission('company'); $pdo = DB::getInstance()->getPdo(); -$types = $pdo->query("SELECT id, name FROM date_dict_certificate WHERE is_active = 1 ORDER BY sort_order, id")->fetchAll(); +$types = $pdo->query("SELECT id, name FROM data_dict_certificate WHERE is_active = 1 ORDER BY sort_order, id")->fetchAll(); Response::success(['types' => $types]); diff --git a/api/company/detail.php b/api/company/detail.php index 4b8be61..8cbdaee 100644 --- a/api/company/detail.php +++ b/api/company/detail.php @@ -23,7 +23,7 @@ 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 = $pdo->prepare("SELECT id, category_name, category_description, positioning, series, is_core, is_active, created_at, updated_at FROM company_products WHERE company_id = ? AND is_active = 1"); $products->execute([$id]); $docs = $pdo->prepare( @@ -45,18 +45,18 @@ $relations = $pdo->prepare( ); $relations->execute([$id]); -// 资质认证(瞪羚企业等,JOIN 字典表取名称) +// 资质认证(瞪羚企业等,JOIN 字典表取名称;含级别/颁证机构) $certs = $pdo->prepare( "SELECT cc.certification_type_id, ct.name AS certification_name, cc.certificate_number, - cc.issue_date, cc.expiry_date + cc.level, cc.issuing_authority, cc.issue_date, cc.expiry_date FROM company_certifications cc - INNER JOIN date_dict_certificate ct ON ct.id = cc.certification_type_id + INNER JOIN data_dict_certificate ct ON ct.id = cc.certification_type_id WHERE cc.company_id = ? ORDER BY ct.sort_order, ct.id" ); $certs->execute([$id]); -$certTypes = $pdo->query("SELECT id, name FROM date_dict_certificate WHERE is_active = 1 ORDER BY sort_order, id")->fetchAll(); +$certTypes = $pdo->query("SELECT id, name FROM data_dict_certificate WHERE is_active = 1 ORDER BY sort_order, id")->fetchAll(); // 联系方式:social_accounts 中属于该公司的记录(仅展示,无修改/删除入口) $accounts = $pdo->prepare( diff --git a/api/company/list.php b/api/company/list.php index d5eb258..0023d2d 100644 --- a/api/company/list.php +++ b/api/company/list.php @@ -1,7 +1,11 @@ fetchColumn(); $offset = ($page - 1) * $limit; $stmt = $pdo->prepare( - "SELECT id, name_zh, name_en, display_name, business_role, country, registration_number, + "SELECT id, name_zh, name_en, display_name, business_role, legal_form, country, registration_number, address, legal_representative, industry, industry_subdivision, website, latest_employee_count, latest_annual_revenue, is_listed, stock_code, source_channel, source_detail, is_active, created_at, updated_at diff --git a/api/company/official_media_add.php b/api/company/official_media_add.php new file mode 100644 index 0000000..8bf93ce --- /dev/null +++ b/api/company/official_media_add.php @@ -0,0 +1,48 @@ +getPdo(); + +$chk = $pdo->prepare("SELECT COUNT(*) FROM companies WHERE id = ?"); +$chk->execute([$companyId]); +if ((int)$chk->fetchColumn() === 0) { + Response::error('企业不存在'); +} + +$ins = $pdo->prepare( + "INSERT INTO social_accounts (owner_type, owner_id, platform, account_id, profile_url, remark, is_primary, is_defult, is_active) + VALUES ('company', ?, ?, ?, ?, ?, 0, ?, ?)" +); +$ins->execute([ + $companyId, + $platform, + $accountId, + strOrNull($_POST['profile_url'] ?? null), + strOrNull($_POST['remark'] ?? null), + !empty($_POST['is_defult']) ? 1 : 0, + isset($_POST['is_active']) && (int)$_POST['is_active'] === 0 ? 0 : 1, +]); +$newId = (int)$pdo->lastInsertId(); + +logCurrent('add', 'official_media', 'social_accounts', $newId, ['company_id' => $companyId, 'platform' => $platform, 'account_id' => $accountId]); +Response::success(['id' => $newId], '新增成功'); diff --git a/api/company/official_media_list.php b/api/company/official_media_list.php new file mode 100644 index 0000000..0c939b3 --- /dev/null +++ b/api/company/official_media_list.php @@ -0,0 +1,43 @@ +getPdo(); + +$chk = $pdo->prepare("SELECT COUNT(*) FROM companies WHERE id = ?"); +$chk->execute([$companyId]); +if ((int)$chk->fetchColumn() === 0) { + Response::error('企业不存在'); +} + +$stmt = $pdo->prepare("SELECT COUNT(*) FROM social_accounts WHERE owner_type = 'company' AND owner_id = ? AND is_active = 1"); +$stmt->execute([$companyId]); +$total = (int)$stmt->fetchColumn(); + +$offset = ($page - 1) * $limit; +$stmt = $pdo->prepare( + "SELECT id, platform, account_id, profile_url, remark, is_defult, is_active, created_at + FROM social_accounts + WHERE owner_type = 'company' AND owner_id = ? AND is_active = 1 + ORDER BY is_defult DESC, id DESC + LIMIT $limit OFFSET $offset" +); +$stmt->execute([$companyId]); + +Response::success(['list' => $stmt->fetchAll(), 'total' => $total, 'page' => $page, 'limit' => $limit]); diff --git a/api/company/product_add.php b/api/company/product_add.php new file mode 100644 index 0000000..877704c --- /dev/null +++ b/api/company/product_add.php @@ -0,0 +1,103 @@ +getPdo(); + +$chk = $pdo->prepare("SELECT id, industry FROM companies WHERE id = ?"); +$chk->execute([$companyId]); +$company = $chk->fetch(); +if (!$company) { + Response::error('企业不存在'); +} + +$pdo->beginTransaction(); +try { + $ins = $pdo->prepare( + "INSERT INTO company_products (company_id, category_name, category_description, positioning, series, is_core, is_active) + VALUES (?, ?, ?, ?, ?, ?, ?)" + ); + $ins->execute([ + $companyId, + $categoryName, + strOrNull($_POST['category_description'] ?? null), + strOrNull($_POST['positioning'] ?? null), + strOrNull($_POST['series'] ?? null), + !empty($_POST['is_core']) ? 1 : 0, + isset($_POST['is_active']) && (int)$_POST['is_active'] === 0 ? 0 : 1, + ]); + $productId = (int)$pdo->lastInsertId(); + + // EAV 参数值 + $attrs = json_decode($_POST['attrs'] ?? '[]', true); + if (is_array($attrs) && $attrs) { + // 校验 attr 属于该公司行业 + $valid = $pdo->prepare("SELECT id, attr_type FROM company_products_attr WHERE id = ? AND industry = ? AND is_active = 1"); + $insV = $pdo->prepare( + "INSERT INTO company_products_attr_value (product_id, attr_id, value_string, value_number, value_boolean, value_date) + VALUES (?, ?, ?, ?, ?, ?)" + ); + foreach ($attrs as $a) { + $attrId = (int)($a['attr_id'] ?? 0); + if ($attrId <= 0) { + continue; + } + $valid->execute([$attrId, $company['industry']]); + $def = $valid->fetch(); + if (!$def) { + continue; + } + $val = trim((string)($a['value'] ?? '')); + if ($val === '') { + continue; + } + $vStr = $vNum = $vBool = $vDate = null; + switch ($def['attr_type']) { + case 'number': + $vNum = is_numeric($val) ? $val : null; + if ($vNum === null) { + $vStr = $val; + } + break; + case 'boolean': + $vBool = (in_array($val, ['1', 'true', '是', 'yes', 'Y', 'y', '支持'], true)) ? 1 : 0; + break; + case 'date': + $vDate = (strtotime($val) !== false) ? date('Y-m-d', strtotime($val)) : null; + if ($vDate === null) { + $vStr = $val; + } + break; + default: + $vStr = $val; + } + $insV->execute([$productId, $attrId, $vStr, $vNum, $vBool, $vDate]); + } + } + + $pdo->commit(); +} catch (Exception $e) { + $pdo->rollBack(); + Response::error('产品保存失败:' . $e->getMessage()); +} + +logCurrent('add', 'product', 'company_products', $productId, ['company_id' => $companyId, 'category_name' => $categoryName]); +Response::success(['id' => $productId], '新增成功'); diff --git a/api/company/product_attrs.php b/api/company/product_attrs.php new file mode 100644 index 0000000..7f5124e --- /dev/null +++ b/api/company/product_attrs.php @@ -0,0 +1,24 @@ +getPdo(); +$attrs = $pdo->prepare( + "SELECT id, attr_name, attr_type, unit FROM company_products_attr + WHERE industry = ? AND is_active = 1 ORDER BY sort_order, id" +); +$attrs->execute([$industry]); + +Response::success(['attrs' => $attrs->fetchAll()]); diff --git a/api/company/products.php b/api/company/products.php index ce088fd..e6552b9 100644 --- a/api/company/products.php +++ b/api/company/products.php @@ -1,6 +1,7 @@ fetchColumn(); $offset = ($page - 1) * $limit; $stmt = $pdo->prepare( - "SELECT id, category_name, category_description, is_core, is_active + "SELECT id, category_name, category_description, positioning, series, is_core, is_active, created_at, updated_at FROM company_products WHERE company_id = ? AND is_active = 1 ORDER BY is_core DESC, id DESC LIMIT $limit OFFSET $offset" ); $stmt->execute([$companyId]); +$list = $stmt->fetchAll(); -Response::success(['list' => $stmt->fetchAll(), 'total' => $total, 'page' => $page, 'limit' => $limit]); +// 批量取 EAV 参数值(每个产品一个 JSON:attr_id => 值) +if ($list) { + $ids = array_column($list, 'id'); + $in = implode(',', array_fill(0, count($ids), '?')); + $valStmt = $pdo->prepare( + "SELECT v.product_id, a.attr_name, a.attr_type, a.unit, + v.value_string, v.value_number, v.value_boolean, v.value_date + FROM company_products_attr_value v + INNER JOIN company_products_attr a ON a.id = v.attr_id + WHERE v.product_id IN ($in)" + ); + $valStmt->execute($ids); + $values = []; + foreach ($valStmt as $v) { + $values[$v['product_id']][] = [ + 'attr_name' => $v['attr_name'], + 'attr_type' => $v['attr_type'], + 'unit' => $v['unit'], + 'value' => $v['value_string'] !== null ? $v['value_string'] + : ($v['value_number'] !== null ? rtrim(rtrim(sprintf('%.4f', $v['value_number']), '0'), '.') + : ($v['value_boolean'] !== null ? ($v['value_boolean'] ? '是' : '否') + : ($v['value_date'] ?? ''))), + ]; + } + foreach ($list as &$row) { + $row['attrs'] = $values[$row['id']] ?? []; + } + unset($row); +} + +Response::success(['list' => $list, 'total' => $total, 'page' => $page, 'limit' => $limit]); diff --git a/api/company/update.php b/api/company/update.php index 3879f6f..56c4e65 100644 --- a/api/company/update.php +++ b/api/company/update.php @@ -21,7 +21,8 @@ $data = extractFields(COMPANY_FIELDS); unset($data['display_name']); // 显示名称不允许通过编辑接口置空/改名,如需改名请走完整字段 $hasFinancials = array_key_exists('financials', $_POST); $hasCertifications = array_key_exists('certifications', $_POST); -if (empty($data) && !$hasFinancials && !$hasCertifications) { +$hasAccounts = array_key_exists('accounts', $_POST); +if (empty($data) && !$hasFinancials && !$hasCertifications && !$hasAccounts) { Response::error('没有需要更新的字段', 400); } @@ -40,8 +41,8 @@ if (!empty($data)) { $pdo->prepare("UPDATE companies SET $sets WHERE id = ?")->execute($params); } -// 财务信息 / 资质认证(可选,整表替换) -if ($hasFinancials || $hasCertifications) { +// 财务信息 / 资质认证 / 联系方式(可选,整表替换) +if ($hasFinancials || $hasCertifications || $hasAccounts) { $pdo->beginTransaction(); try { if ($hasFinancials) { @@ -60,6 +61,14 @@ if ($hasFinancials || $hasCertifications) { } saveCompanyCertifications($pdo, $id, $certs); } + if ($hasAccounts) { + $accounts = json_decode($_POST['accounts'], true); + if (!is_array($accounts)) { + $pdo->rollBack(); + Response::error('联系方式格式错误', 400); + } + saveCompanyAccounts($pdo, $id, $accounts); + } $pdo->commit(); } catch (Exception $e) { $pdo->rollBack(); diff --git a/api/person/add.php b/api/person/add.php index 34d42cc..a88218b 100644 --- a/api/person/add.php +++ b/api/person/add.php @@ -49,5 +49,11 @@ if (is_array($contacts)) { } } +// 工作履历(v1.0.18:整表替换) +$experiences = json_decode($_POST['experiences'] ?? '[]', true); +if (is_array($experiences)) { + savePersonExperiences($pdo, $newId, $experiences); +} + logCurrent('add', 'person', 'persons', $newId, ['data' => $data, 'contacts' => $contacts]); Response::success(['id' => $newId], '新增成功'); diff --git a/api/person/company_options.php b/api/person/company_options.php new file mode 100644 index 0000000..7ddf956 --- /dev/null +++ b/api/person/company_options.php @@ -0,0 +1,32 @@ +getPdo(); + +$where = 'is_active = 1'; +$params = []; +if ($keyword !== '') { + $where .= ' AND (name_zh LIKE ? OR name_en LIKE ? OR display_name LIKE ?)'; + $like = "%$keyword%"; + array_push($params, $like, $like, $like); +} + +$stmt = $pdo->prepare( + "SELECT id, COALESCE(NULLIF(display_name,''), name_zh, name_en) AS name + FROM companies WHERE $where ORDER BY id ASC LIMIT $limit" +); +$stmt->execute($params); + +Response::success(['list' => $stmt->fetchAll()]); diff --git a/api/person/connections.php b/api/person/connections.php new file mode 100644 index 0000000..91cf945 --- /dev/null +++ b/api/person/connections.php @@ -0,0 +1,185 @@ + 200) { + $limit = 50; +} + +$pdo = DB::getInstance()->getPdo(); + +$stmt = $pdo->prepare("SELECT id, full_name, hometown, graduated_from, gender, work_location FROM persons WHERE id = ?"); +$stmt->execute([$personId]); +$target = $stmt->fetch(); +if (!$target) { + Response::error('人员不存在'); +} + +/** 省-市名称归一化:湖南省衡阳市 -> 湖南衡阳;新疆维吾尔自治区乌鲁木齐市 -> 新疆乌鲁木齐;北京市 -> 北京 */ +function normalizeAreaName($s) +{ + $s = trim((string)$s); + if ($s === '') { + return ''; + } + $muni = ['北京市' => '北京', '天津市' => '天津', '上海市' => '上海', '重庆市' => '重庆']; + if (isset($muni[$s])) { + return $muni[$s]; + } + // 自治区后缀(维吾尔/壮族/回族)及省后缀 + $s = preg_replace('/(维吾尔|壮族|回族)?自治区$/', '', $s); + $s = str_replace('省', '', $s); + // 去掉末尾「市」 + $s = preg_replace('/市$/', '', $s); + $s = str_replace('特别行政区', '', $s); + return $s; +} + +$tHometown = normalizeAreaName($target['hometown']); +$tWorkLoc = normalizeAreaName($target['work_location']); +$tSchool = trim((string)$target['graduated_from']); +$tGender = $target['gender']; +$isGenderPair = ($tGender === '男' || $tGender === '女'); + +// 目标人员的任职公司集合:company_id => 入职年份 +$expMap = []; // person_id => [ ['company_id'=>x,'sy'=>yyyy|null], ... ] +$targetComps = []; // company_id => sy +$stmt = $pdo->prepare( + "SELECT person_id, company_id, YEAR(start_date) AS sy + FROM person_work_experiences WHERE is_active = 1 AND person_id != ?" +); +$stmt->execute([$personId]); +$candIds = []; +foreach ($stmt as $row) { + $pid = (int)$row['person_id']; + $expMap[$pid][] = ['company_id' => (int)$row['company_id'], 'sy' => $row['sy'] !== null ? (int)$row['sy'] : null]; + $candIds[$pid] = true; +} +$stmt = $pdo->prepare( + "SELECT company_id, YEAR(start_date) AS sy + FROM person_work_experiences WHERE is_active = 1 AND person_id = ?" +); +$stmt->execute([$personId]); +foreach ($stmt as $row) { + $targetComps[(int)$row['company_id']] = $row['sy'] !== null ? (int)$row['sy'] : null; +} + +// 候选人员基本信息 +$candList = $pdo->query( + "SELECT id, full_name, hometown, graduated_from, gender, work_location FROM persons WHERE id != $personId AND is_active = 1" +)->fetchAll(); + +// 手机号(常用优先) +$phones = []; +foreach ($pdo->query( + "SELECT owner_id, account_id FROM social_accounts + WHERE owner_type = 'person' AND platform = 'phone' AND is_active = 1 + ORDER BY is_defult DESC, is_primary DESC, id DESC" +) as $row) { + if (!isset($phones[$row['owner_id']])) { + $phones[$row['owner_id']] = $row['account_id']; + } +} + +$result = []; +foreach ($candList as $c) { + $tags = []; + $score = 0; + + // 老乡:市级一致(归一化后整体相等即省市都一致) + $cHometown = normalizeAreaName($c['hometown']); + if ($tHometown !== '' && $cHometown !== '' && $tHometown === $cHometown) { + $tags[] = '老乡'; + $score += 20; + } + + // 校友 + $cSchool = trim((string)$c['graduated_from']); + if ($tSchool !== '' && $cSchool !== '' && $tSchool === $cSchool) { + $tags[] = '校友'; + $score += 20; + } + + // 同事:任职公司交集 + $shared = []; // company_id => [targetSy, candSy] + foreach (($expMap[$c['id']] ?? []) as $e) { + if (isset($targetComps[$e['company_id']])) { + $shared[$e['company_id']] = [$targetComps[$e['company_id']], $e['sy']]; + } + } + if ($shared) { + $tags[] = '同事'; + $score += 20; + // 同一家公司任职次数≥2(两家及以上共同任职公司) + if (count($shared) >= 2) { + $score += 10; + } + // 同事且入职年份一致 + foreach ($shared as $pair) { + if ($pair[0] !== null && $pair[1] !== null && $pair[0] === $pair[1]) { + $score += 10; + break; + } + } + } + + // 工作地点一致 + $cWorkLoc = normalizeAreaName($c['work_location']); + if ($tWorkLoc !== '' && $cWorkLoc !== '' && $tWorkLoc === $cWorkLoc) { + $score += 10; + } + + // 性别一致(均为男或均为女) + if ($isGenderPair && ($c['gender'] === '男' || $c['gender'] === '女') && $c['gender'] === $tGender) { + $score += 5; + } + + // 至少有一种交集才进入人脉 + if (!$tags) { + continue; + } + + // 随机 0~5(剩余百分比),总亲密上限 99(不能达到 100%) + $score += mt_rand(0, 5); + $score = min(99, $score); + + $result[] = [ + 'id' => (int)$c['id'], + 'full_name' => $c['full_name'], + 'phone' => $phones[$c['id']] ?? '', + 'tags' => $tags, + 'intimacy' => $score, + ]; +} + +// 亲密度降序,其次按 id 升序 +usort($result, function ($a, $b) { + if ($b['intimacy'] !== $a['intimacy']) { + return $b['intimacy'] - $a['intimacy']; + } + return $a['id'] - $b['id']; +}); + +$result = array_slice($result, 0, $limit); +Response::success(['list' => $result]); diff --git a/api/person/update.php b/api/person/update.php index 48ebe14..b1010ee 100644 --- a/api/person/update.php +++ b/api/person/update.php @@ -54,5 +54,14 @@ if (!empty($data)) { $pdo->prepare("UPDATE persons SET $sets WHERE id = ?")->execute($params); } +// 工作履历(v1.0.18:可选,整表替换) +if (array_key_exists('experiences', $_POST)) { + $experiences = json_decode($_POST['experiences'], true); + if (!is_array($experiences)) { + Response::error('工作履历格式错误', 400); + } + savePersonExperiences($pdo, $id, $experiences); +} + logCurrent('update', 'person', 'persons', $id, ['before' => $old, 'after' => $data, 'contacts_replaced' => $contactsChanged]); Response::success(null, '更新成功'); diff --git a/channel.html b/channel.html index 20246e2..1a704e3 100644 --- a/channel.html +++ b/channel.html @@ -10,6 +10,7 @@ + diff --git a/competitor_analysis.html b/competitor_analysis.html new file mode 100644 index 0000000..e5157c2 --- /dev/null +++ b/competitor_analysis.html @@ -0,0 +1,18 @@ + + + + + +竞品分析 - SuperLink + + + + + + + + + + + + \ No newline at end of file diff --git a/competitor_data.html b/competitor_data.html new file mode 100644 index 0000000..2682138 --- /dev/null +++ b/competitor_data.html @@ -0,0 +1,18 @@ + + + + + +竞品资料 - SuperLink + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/requirements_2026-08-08.md b/docs/requirements_2026-08-08.md new file mode 100644 index 0000000..c52d4bd --- /dev/null +++ b/docs/requirements_2026-08-08.md @@ -0,0 +1,80 @@ +# mysuperlink v1.0.18 迭代需求(2026-08-08 文档) + +## 来源文档:0807---29877584-0c3b-41d8-b102-e9aa9c1cea62.docx(13 条) + +### 1. 企业新增弹窗 +- "工商信息" tab → "基本信息" +- 删"英文名称";"公司名称" → "企业名称";"法律形式" → "企业类型" +- "资质认证"列表增加"级别""颁证机构"字段 + +### 2. 字典表 + 表单导航 +- 新建字典表 `data_dict_company_type`(对应 companies 表"企业类型",后续按需加类型) +- 基本信息下方左侧增加文字锚文本导航:工商信息 / 联系方式 / 行业归属 / 业务类型 / 来源 +- 删除"行业信息" tab 卡 + +### 3. 企业数据顶部检索 +- ① "筛选"下拉:所有字段为选项,第一个"全部字段",指定字段后检索词只匹配该字段(①和②联动) +- ② 文本输入框 +- ③ "行业"下拉筛选,初始"全部行业" +- ④ "全部业务角色" → "企业类型" +- ⑤ 删掉国家/地区文本框 + +### 4. 数据库改名(其它无变化) +- date_dict_area → data_dict_area +- date_dict_certificate → data_dict_certificate +- date_dict_country → data_dict_country +- date_dict_industry → data_dict_industry + +### 5. 新建数据表(EAV 模式,参考 https://chat.deepseek.com/share/jqeygekewi0qlle1dl) +- company_products(已存在)增加"产品基本定位"字段(商业定位介绍) +- 新建 `company_products_attr`(参数属性定义表) +- 新建 `company_products_attr_value`(产品参数值表) +- 字段按链接 EAV 方案自由拟定 + +### 6. 企业数据"产品"详情页 +- 展示:产品品类、基本定位、包含系列、状态、更新日期 +- "新增产品"按钮:快速新增产品记录 + +### 7. 企业数据新增"官媒"入口 +- 该公司媒体清单:平台、账号名称、ID、主页链接、状态、更新日期 +- "新增官媒"按钮 + +### 8. 人员数据新增"人脉"入口 +- 列出相关人员:工作履历/家乡/毕业院校有交集(家乡须市级一致,如湖南衡阳 vs 湖南长沙不算) +- 字段:姓名、联系方式、交集(老乡/校友/同事)、亲密度(%) +- 亲密度规则:老乡/校友/同事各 20%;同公司次数≥2 +10%;同事且入职年份一致 +10%;工作地点一致 +10%;性别一致 +5%;随机 +5%;任何两人不得达到 100% +- 点击姓名可反查该人员详情 + +### 9. 人员编辑页 +- 国籍为中国/中国香港/中国澳门/中国台湾时,家乡输入框置灰不可填 +- 工作所在地/家乡都只显示"省-市"两级(不细化区县) +- 存储与显示均为"省-市"结构:如"湖南省衡阳市"→"湖南衡阳"、"新疆维吾尔自治区乌鲁木齐市"→"新疆乌鲁木齐" + +### 10. 人员新增 +- 分两个 tab:基本信息 / 工作履历 + +### 11. 左侧导航 +- 数据管理下删掉"媒体数据" +- "营销服务" → "精准营销",删掉其下"媒体批发" +- 新增一级导航"媒体管理":媒体资源 / 舆情监测 / 推广服务(占位) +- "需求转盘"改为"精准营销"下的二级导航 + +### 12. 新增一级导航"竞品管理" +- 二级菜单:竞品分析 / 竞品资料 + +### 13. 渠道管理 +- 目标:记录企业/人员数据来源渠道 + 拓展高效渠道(数据源 source_channel + source_detail) +- 两部分:渠道效能分析 / 渠道新增计划 +- 渠道效能分析:企业(左)/人员(右)两个板块,右上角"年份"下拉(默认全部) + - 按"渠道"字段饼状图按百分比排序;鼠标悬停饼图区域显示该渠道 source_detail TOP10 表格 + - 表格字段:来源(只显示前 8 字,超出用"…")、数量;默认显示渠道第一名 +- 渠道新增计划:列表:渠道类别(=source_channel)、来源详情、所属行业、时间窗口("2025-4-1至2025-4-5")、剩余天数、备注说明、状态反馈(下拉:待启动/已执行/错过) + +### 14. 删除 sql/system_tables.sql + +--- +## DeepSeek 分享链接 EAV 方案要点 +1. 产品基础表 products:product_id, industry, company_name, product_name, series, release_date, created_at +2. 参数属性定义表 attributes:attr_id, industry, attr_name, attr_type ENUM('string','number','boolean','date'), unit, sort_order, UNIQUE(industry, attr_name) +3. 产品参数值表 product_attributes:id, product_id, attr_id, value_string TEXT, value_number DECIMAL(20,4), value_boolean TINYINT(1), value_date DATE, created_at, UNIQUE(product_id, attr_id) +- 行转列 Pivot 查询做行业对比;本项目按公司维度(company_products) diff --git a/media_opinion.html b/media_opinion.html new file mode 100644 index 0000000..84300ca --- /dev/null +++ b/media_opinion.html @@ -0,0 +1,18 @@ + + + + + +舆情监测 - SuperLink + + + + + + + + + + + + \ No newline at end of file diff --git a/media_promo.html b/media_promo.html new file mode 100644 index 0000000..bee7428 --- /dev/null +++ b/media_promo.html @@ -0,0 +1,18 @@ + + + + + +推广服务 - SuperLink + + + + + + + + + + + + \ No newline at end of file diff --git a/media_resources.html b/media_resources.html new file mode 100644 index 0000000..30aa1e3 --- /dev/null +++ b/media_resources.html @@ -0,0 +1,18 @@ + + + + + +媒体资源 - SuperLink + + + + + + + + + + + + \ No newline at end of file diff --git a/sql/dict_seed.sql b/sql/dict_seed.sql index 7ae7a4f..c0b28b1 100644 --- a/sql/dict_seed.sql +++ b/sql/dict_seed.sql @@ -2,7 +2,7 @@ -- 数据字典种子数据(由官方 Excel 文件生成,勿手工修改) -- 来源:国家地区编码.xls / 行政区划编码.xls / 招标行业分类.xls / 证书类型枚举.xls -- --- ⚠️ 重要:date_dict_certificate 被 company_certifications.certification_type_id +-- ⚠️ 重要:data_dict_certificate 被 company_certifications.certification_type_id -- 外键 ON DELETE CASCADE 引用,本文件会对证书表 DELETE 后重建! -- 执行前必须先备份 company_certifications(及相关业务表)!! -- 安全做法: @@ -16,8 +16,8 @@ SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0; -- 1) 国家地区编码(62 条,另补充 'CN 中国' 1 条供国内企业/人员使用) -DELETE FROM `date_dict_country`; -INSERT INTO `date_dict_country` (`code`, `name`, `sort_order`) VALUES +DELETE FROM `data_dict_country`; +INSERT INTO `data_dict_country` (`code`, `name`, `sort_order`) VALUES ('CN', '中国', 0), ('HK', '中国香港', 1), ('MO', '中国澳门', 2), @@ -83,8 +83,8 @@ INSERT INTO `date_dict_country` (`code`, `name`, `sort_order`) VALUES ('BR', '巴西', 62); -- 2) 行政区划编码(省/市/区县三级,共 3632 条) -DELETE FROM `date_dict_area`; -INSERT INTO `date_dict_area` (`code`, `name`, `parent_code`, `sort_order`) VALUES +DELETE FROM `data_dict_area`; +INSERT INTO `data_dict_area` (`code`, `name`, `parent_code`, `sort_order`) VALUES ('110000', '北京市', NULL, 1), ('110100', '北京市', '110000', 2), ('110101', '东城区', '110100', 3), @@ -3719,8 +3719,8 @@ INSERT INTO `date_dict_area` (`code`, `name`, `parent_code`, `sort_order`) VALUE ('820000', '澳门特别行政区', NULL, 3632); -- 3) 招标行业分类(24 条) -DELETE FROM `date_dict_industry`; -INSERT INTO `date_dict_industry` (`code`, `name`, `parent_code`, `sort_order`) VALUES +DELETE FROM `data_dict_industry`; +INSERT INTO `data_dict_industry` (`code`, `name`, `parent_code`, `sort_order`) VALUES ('1', '工程建筑', NULL, 1), ('2', '办公文教', NULL, 2), ('3', '医疗卫生', NULL, 3), @@ -3747,8 +3747,8 @@ INSERT INTO `date_dict_industry` (`code`, `name`, `parent_code`, `sort_order`) V ('15', '其他', NULL, 24); -- 4) 证书类型枚举(263 条) -DELETE FROM `date_dict_certificate`; -INSERT INTO `date_dict_certificate` (`code`, `name`, `sort_order`) VALUES +DELETE FROM `data_dict_certificate`; +INSERT INTO `data_dict_certificate` (`code`, `name`, `sort_order`) VALUES ('000001', '强制性产品认证', 1), ('000002001', '质量管理体系认证', 2), ('000002002', '环境管理体系认证', 3), diff --git a/sql/migration_v1.0.18.sql b/sql/migration_v1.0.18.sql new file mode 100644 index 0000000..622b8a9 --- /dev/null +++ b/sql/migration_v1.0.18.sql @@ -0,0 +1,118 @@ +-- ============================================================ +-- SuperLink Web 后台管理系统 - v1.0.18 数据库结构变更脚本 +-- 对应需求文档:0807---29877584-0c3b-41d8-b102-e9aa9c1cea62.docx +-- 1) 数据字典表改名:date_dict_* -> data_dict_*(其它无变化) +-- 2) 新增企业类型字典表 data_dict_company_type(对应 companies.legal_form 企业类型) +-- 3) company_certifications 增加「级别」「颁证机构」 +-- 4) company_products 增加「产品基本定位」「包含系列」 +-- 5) 新增 EAV 产品参数表:company_products_attr / company_products_attr_value +-- 6) 新增渠道新增计划表 channel_plans +-- 执行方式:直接执行本文件(先备份数据库) +-- ============================================================ +SET NAMES utf8mb4; + +-- ------------------------------------------------------------ +-- 1) 数据字典表改名(其它无变化) +-- ------------------------------------------------------------ +RENAME TABLE `date_dict_area` TO `data_dict_area`; +RENAME TABLE `date_dict_certificate` TO `data_dict_certificate`; +RENAME TABLE `date_dict_country` TO `data_dict_country`; +RENAME TABLE `date_dict_industry` TO `data_dict_industry`; + +-- ------------------------------------------------------------ +-- 2) 企业类型字典表(对应 companies 表「企业类型」,后续按需增加具体类型) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS `data_dict_company_type`; +CREATE TABLE `data_dict_company_type` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `code` VARCHAR(50) NULL DEFAULT NULL COMMENT '企业类型编码', + `name` VARCHAR(100) NOT NULL COMMENT '企业类型名称', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序', + `is_active` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '启用状态', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_code` (`code`), + KEY `idx_name` (`name`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='企业类型字典(对应 companies.legal_form 企业类型)'; + +INSERT INTO `data_dict_company_type` (`code`, `name`, `sort_order`) VALUES +('llc', '有限责任公司', 1), +('joint_stock', '股份有限公司', 2), +('partnership', '合伙企业', 3), +('sole_proprietorship', '个人独资企业', 4), +('state_owned', '国有企业', 5), +('collective', '集体所有制企业', 6), +('foreign_invested', '外商投资企业', 7), +('joint_venture', '中外合资企业', 8), +('other', '其他', 99); + +-- ------------------------------------------------------------ +-- 3) 资质认证表增加「级别」「颁证机构」 +-- ------------------------------------------------------------ +ALTER TABLE `company_certifications` + ADD COLUMN `level` VARCHAR(50) NULL DEFAULT NULL COMMENT '级别' AFTER `certificate_number`, + ADD COLUMN `issuing_authority` VARCHAR(200) NULL DEFAULT NULL COMMENT '颁证机构' AFTER `level`; + +-- ------------------------------------------------------------ +-- 4) 产品基础表(company_products)增加「产品基本定位」「包含系列」 +-- 说明:产品基本定位 = 介绍这一款产品的商业定位 +-- ------------------------------------------------------------ +ALTER TABLE `company_products` + ADD COLUMN `positioning` TEXT NULL COMMENT '产品基本定位(商业定位介绍)' AFTER `category_description`, + ADD COLUMN `series` VARCHAR(200) NULL COMMENT '包含系列' AFTER `positioning`; + +-- ------------------------------------------------------------ +-- 5) EAV 模式产品参数表(参考 DeepSeek 分享方案) +-- company_products_attr 参数属性定义表:按行业定义「有哪些参数」(元数据) +-- company_products_attr_value 产品参数值表:产品 + 参数属性 -> 具体值 +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS `company_products_attr`; +CREATE TABLE `company_products_attr` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '参数属性ID', + `industry` VARCHAR(100) NOT NULL COMMENT '所属行业(对应 companies.industry)', + `attr_name` VARCHAR(50) NOT NULL COMMENT '参数名称', + `attr_type` ENUM('string','number','boolean','date') NOT NULL DEFAULT 'string' COMMENT '参数类型', + `unit` VARCHAR(20) NULL DEFAULT NULL COMMENT '单位', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '展示排序', + `is_active` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '启用状态', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_industry_attr` (`industry`, `attr_name`), + KEY `idx_industry` (`industry`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='产品参数属性定义表(EAV,按行业定义参数)'; + +DROP TABLE IF EXISTS `company_products_attr_value`; +CREATE TABLE `company_products_attr_value` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `product_id` INT UNSIGNED NOT NULL COMMENT '产品ID(关联 company_products.id)', + `attr_id` INT UNSIGNED NOT NULL COMMENT '参数属性ID(关联 company_products_attr.id)', + `value_string` TEXT NULL COMMENT '字符串值', + `value_number` DECIMAL(20,4) NULL COMMENT '数值', + `value_boolean` TINYINT(1) NULL COMMENT '布尔值', + `value_date` DATE NULL COMMENT '日期值', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_product_attr` (`product_id`, `attr_id`), + KEY `idx_product` (`product_id`), + KEY `idx_attr` (`attr_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='产品参数值表(EAV,产品与参数属性关联存储具体值)'; + +-- ------------------------------------------------------------ +-- 6) 渠道新增计划表(渠道管理 - 渠道新增计划) +-- ------------------------------------------------------------ +DROP TABLE IF EXISTS `channel_plans`; +CREATE TABLE `channel_plans` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', + `channel_type` VARCHAR(50) NOT NULL COMMENT '渠道类别(= source_channel 枚举)', + `source_detail` VARCHAR(255) NULL DEFAULT NULL COMMENT '来源详情', + `industry` VARCHAR(100) NULL DEFAULT NULL COMMENT '所属行业', + `start_date` DATE NULL DEFAULT NULL COMMENT '时间窗口开始', + `end_date` DATE NULL DEFAULT NULL COMMENT '时间窗口结束', + `remark` VARCHAR(255) NULL DEFAULT NULL COMMENT '备注说明', + `status` ENUM('待启动','已执行','错过') NOT NULL DEFAULT '待启动' COMMENT '状态反馈', + `is_active` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '启用状态', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_channel_type` (`channel_type`), + KEY `idx_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='渠道新增计划表'; diff --git a/sql/system_tables.sql b/sql/system_tables.sql deleted file mode 100644 index b5f3202..0000000 --- a/sql/system_tables.sql +++ /dev/null @@ -1,188 +0,0 @@ --- ============================================================ --- SuperLink Web 后台管理系统 - 系统表(含 v1.0.16 数据字典表) --- 数据库:mysuperlink (utf8mb4 / utf8mb4_unicode_ci) --- 说明: --- 1) 原库已有14张业务表(companies/persons/social_accounts/company_needs 等),本文件不改动它们。 --- 2) 本文件定义系统表:system_users / system_roles / system_logs / channels, --- 以及 v1.0.16 新增的数据字典表:date_dict_country / date_dict_area / --- date_dict_industry / date_dict_certificate / source_channels。 --- 3) 按需求文档:所有时间字段调整为"年-月-日"(DATE 类型,不保留时分秒)。 --- 注意:DATE 默认值使用表达式 DEFAULT (CURRENT_DATE),需 MySQL 8.0.13+; --- 若为 MySQL 5.7,请将时间字段保留为 DATETIME 或由应用层写入日期。 --- 4) 数据字典的完整数据由官方 Excel 生成,见 sql/dict_seed.sql(建表后执行)。 --- 执行方式:Navicat / phpMyAdmin / mysql 命令行执行本文件即可(可重复执行前请先删除旧表)。 --- ============================================================ - -SET NAMES utf8mb4; - --- ------------------------------------------------------------ --- 1. 系统用户表 --- ------------------------------------------------------------ -DROP TABLE IF EXISTS `system_users`; -CREATE TABLE `system_users` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `username` VARCHAR(50) NOT NULL COMMENT '登录账号', - `password` VARCHAR(64) NOT NULL COMMENT '密码(sha1加密)', - `real_name` VARCHAR(50) DEFAULT NULL COMMENT '真实姓名', - `role_id` INT UNSIGNED NOT NULL COMMENT '角色ID', - `is_active` TINYINT(1) UNSIGNED DEFAULT 1 COMMENT '启用状态(0禁用,1启用)', - `last_login_time` DATE DEFAULT NULL COMMENT '最后登录时间(年-月-日)', - `last_login_ip` VARCHAR(50) DEFAULT NULL COMMENT '最后登录IP', - `created_at` DATE DEFAULT (CURRENT_DATE) COMMENT '创建时间(年-月-日)', - `updated_at` DATE DEFAULT (CURRENT_DATE) COMMENT '更新时间(年-月-日)', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_username` (`username`), - KEY `idx_role_id` (`role_id`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统用户表'; - --- ------------------------------------------------------------ --- 2. 系统角色表 --- ------------------------------------------------------------ -DROP TABLE IF EXISTS `system_roles`; -CREATE TABLE `system_roles` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `role_name` VARCHAR(50) NOT NULL COMMENT '角色名称', - `permissions` JSON NOT NULL COMMENT '权限列表(菜单标识数组)', - `is_active` TINYINT(1) UNSIGNED DEFAULT 1 COMMENT '启用状态', - `created_at` DATE DEFAULT (CURRENT_DATE) COMMENT '创建时间(年-月-日)', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_role_name` (`role_name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统角色表'; - --- ------------------------------------------------------------ --- 3. 操作日志表 --- ------------------------------------------------------------ -DROP TABLE IF EXISTS `system_logs`; -CREATE TABLE `system_logs` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `user_id` INT UNSIGNED NOT NULL COMMENT '操作人ID', - `username` VARCHAR(50) NOT NULL COMMENT '操作人账号', - `action` VARCHAR(50) NOT NULL COMMENT '操作类型(login/logout/add/update/delete/import/export/audit/convert)', - `module` VARCHAR(50) NOT NULL COMMENT '操作模块(company/person/media/need/preliminary/system等)', - `target_table` VARCHAR(50) DEFAULT NULL COMMENT '操作对象表名', - `target_id` INT UNSIGNED DEFAULT NULL COMMENT '操作对象记录ID', - `content` JSON DEFAULT NULL COMMENT '操作内容(变更前后数据)', - `ip` VARCHAR(50) DEFAULT NULL COMMENT '客户端IP', - `created_at` DATE DEFAULT (CURRENT_DATE) COMMENT '操作时间(年-月-日)', - PRIMARY KEY (`id`), - KEY `idx_user_id` (`user_id`), - KEY `idx_action` (`action`), - KEY `idx_created_at` (`created_at`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='操作日志表'; - --- ------------------------------------------------------------ --- 4. 默认数据(必须插入) --- ------------------------------------------------------------ --- 超级管理员角色 -INSERT INTO `system_roles` (`role_name`, `permissions`) VALUES -('超级管理员', '["dashboard","company","person","media","need","marketing","channel","document","preliminary","system","log"]'); - --- 默认管理员账号(密码:admin123,sha1加密) -INSERT INTO `system_users` (`username`, `password`, `real_name`, `role_id`) VALUES -('admin', SHA1('admin123'), '系统管理员', 1); - --- 基础角色(可选,建议一并创建) -INSERT INTO `system_roles` (`role_name`, `permissions`) VALUES -('数据管理员', '["dashboard","company","person","media","document","preliminary"]'), -('业务专员', '["dashboard","need","marketing"]'), -('只读访客', '["dashboard"]'); - --- ------------------------------------------------------------ --- 5. 渠道管理业务表(原14张表中无渠道表,渠道模块所需,按需创建) --- ------------------------------------------------------------ -DROP TABLE IF EXISTS `channels`; -CREATE TABLE `channels` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `channel_name` VARCHAR(100) NOT NULL COMMENT '渠道名称', - `channel_type` VARCHAR(50) DEFAULT NULL COMMENT '渠道类型(线上/线下/展会/转介绍等)', - `contact_person` VARCHAR(50) DEFAULT NULL COMMENT '联系人', - `contact_phone` VARCHAR(50) DEFAULT NULL COMMENT '联系电话', - `contact_email` VARCHAR(100) DEFAULT NULL COMMENT '联系邮箱', - `efficiency_score` DECIMAL(5,2) DEFAULT 0 COMMENT '效率评分(0-100)', - `remark` VARCHAR(255) DEFAULT NULL COMMENT '备注', - `is_active` TINYINT(1) UNSIGNED DEFAULT 1 COMMENT '启用状态', - `created_at` DATE DEFAULT (CURRENT_DATE) COMMENT '创建时间(年-月-日)', - `updated_at` DATE DEFAULT (CURRENT_DATE) COMMENT '更新时间(年-月-日)', - PRIMARY KEY (`id`), - KEY `idx_channel_type` (`channel_type`), - KEY `idx_is_active` (`is_active`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='渠道管理表'; - --- ------------------------------------------------------------ --- 6. v1.0.16 数据字典表 --- ------------------------------------------------------------ --- 6.1 国家地区编码字典 -DROP TABLE IF EXISTS `date_dict_country`; -CREATE TABLE `date_dict_country` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `code` VARCHAR(50) NOT NULL COMMENT '国家/地区编码', - `name` VARCHAR(100) NOT NULL COMMENT '国家/地区中文名', - `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序', - `is_active` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '启用状态', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_code` (`code`), - KEY `idx_name` (`name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='国家地区编码字典'; - --- 6.2 行政区划编码字典(省/市/区县三级,parent_code 关联) -DROP TABLE IF EXISTS `date_dict_area`; -CREATE TABLE `date_dict_area` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `code` VARCHAR(50) NOT NULL COMMENT '行政区划编码', - `name` VARCHAR(100) NOT NULL COMMENT '行政区划名称', - `parent_code` VARCHAR(50) DEFAULT NULL COMMENT '上级行政区划编码(省级为NULL)', - `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序', - `is_active` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '启用状态', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_code` (`code`), - KEY `idx_parent` (`parent_code`), - KEY `idx_name` (`name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='行政区划编码字典'; - --- 6.3 招标行业分类字典(parent_code 预留大类/细分层级) -DROP TABLE IF EXISTS `date_dict_industry`; -CREATE TABLE `date_dict_industry` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `code` VARCHAR(50) NOT NULL COMMENT '行业编码', - `name` VARCHAR(100) NOT NULL COMMENT '行业名称', - `parent_code` VARCHAR(50) DEFAULT NULL COMMENT '上级行业编码(大类为NULL)', - `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序', - `is_active` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '启用状态', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_code` (`code`), - KEY `idx_parent` (`parent_code`), - KEY `idx_name` (`name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='招标行业分类字典'; - --- 6.4 证书类型枚举字典(原 certification_types 表改名而来;新装环境在此创建) -DROP TABLE IF EXISTS `date_dict_certificate`; -CREATE TABLE `date_dict_certificate` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `code` VARCHAR(50) DEFAULT NULL COMMENT '证书类型枚举编码', - `name` VARCHAR(100) NOT NULL COMMENT '证书类型名称', - `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序', - `is_active` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '启用状态', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_code` (`code`), - KEY `idx_name` (`name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='证书类型枚举字典'; - --- 6.5 来源渠道字典(source_channel 枚举) -DROP TABLE IF EXISTS `source_channels`; -CREATE TABLE `source_channels` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `name` VARCHAR(50) NOT NULL COMMENT '渠道名称', - `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序', - `is_active` TINYINT(1) UNSIGNED NOT NULL DEFAULT 1 COMMENT '启用状态', - PRIMARY KEY (`id`), - UNIQUE KEY `uk_name` (`name`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='来源渠道字典'; - --- 6.6 来源渠道默认数据 -INSERT INTO `source_channels` (`name`, `sort_order`) VALUES -('展会', 1), ('SNS/社交媒体', 2), ('竞价广告', 3), ('搜索引擎自然流量', 4), -('行业媒体/门户', 5), ('转介绍', 6), ('电话销售', 7), ('邮件营销', 8), -('地推', 9), ('代理商/渠道', 10), ('官网咨询', 11), ('其他', 99); - --- 6.7 数据字典完整数据(国家/行政区划/行业/证书)由官方 Excel 生成: --- 请执行 sql/dict_seed.sql diff --git a/static/css/style.css b/static/css/style.css index 9546fac..dba5178 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -519,6 +519,141 @@ table.grid .ops a:hover { text-decoration: underline; } .cert-row input[type="text"] { width: 170px; } .cert-row input[type="date"] { width: 140px; } +/* ---------- 基本信息锚点导航布局(左侧文字锚 + 右侧分区) ---------- */ +.form-anchor-layout { + display: flex; + gap: 14px; + align-items: flex-start; +} +.form-anchor-nav { + flex: 0 0 86px; + display: flex; + flex-direction: column; + gap: 2px; + position: sticky; + top: 0; + border-right: 1px solid #eef0f4; + padding-right: 10px; + padding-top: 4px; +} +.form-anchor-nav .anchor-link { + display: block; + padding: 7px 10px; + font-size: 13px; + color: #5a6472; + text-decoration: none; + border-radius: 4px; + white-space: nowrap; +} +.form-anchor-nav .anchor-link:hover { color: #2a5298; background: #f2f6fd; } +.form-anchor-nav .anchor-link.active { + color: #2a5298; + background: #e8f0fe; + font-weight: bold; +} +.form-anchor-body { flex: 1; min-width: 0; } +.form-sec { padding: 4px 2px 14px; border-bottom: 1px dashed #eef0f4; } +.form-sec:last-child { border-bottom: none; } +.form-sec-title { + font-size: 13px; + font-weight: bold; + color: #2a5298; + margin-bottom: 10px; + padding-left: 8px; + border-left: 3px solid #2a5298; + line-height: 1.4; +} + +/* 联系方式行(企业 social_accounts) */ +.account-row { + display: flex; + gap: 8px; + margin-bottom: 8px; + align-items: center; + flex-wrap: wrap; +} +.account-row select, .account-row input { + border: 1px solid #d9dee8; + border-radius: 4px; + padding: 7px 8px; + font-size: 13px; + background: #fff; +} +.account-row select[name="acc-platform"] { width: 110px; } +.account-row input[name="acc-account"] { width: 150px; } +.account-row input[name="acc-url"] { width: 210px; } +.account-row input[name="acc-remark"] { width: 130px; } +.account-row select[name="acc-active"] { width: 76px; } + +/* ---------- 人员工作履历行 ---------- */ +.exp-row { + display: flex; + gap: 8px; + margin-bottom: 8px; + align-items: center; + flex-wrap: wrap; +} +.exp-row select, .exp-row input { + border: 1px solid #d9dee8; + border-radius: 4px; + padding: 7px 8px; + font-size: 13px; + background: #fff; +} +.exp-row input[type="text"] { width: 110px; } +.exp-row input[type="date"] { width: 130px; } + +/* 家乡置灰(中国/港澳台国籍时不可填) */ +.area-disabled select, .area-disabled input { + background: #f2f3f5 !important; + color: #b6bfcc !important; + cursor: not-allowed; +} + +/* 人脉弹窗姓名链接 */ +.link { color: #2a5298; text-decoration: underline; cursor: pointer; } +.link:hover { color: #4fa3ff; } + +/* ---------- 渠道管理:效能分析面板 ---------- */ +.ana-panel { + flex: 1 1 46%; + min-width: 340px; + border: 1px solid #e5e9f0; + border-radius: 6px; + overflow: hidden; +} +.ana-head { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px 14px; + background: #f7f9fc; + border-bottom: 1px solid #e5e9f0; + font-size: 13px; + font-weight: bold; + color: #1e2a3a; +} +.ana-year { + border: 1px solid #d9dee8; + border-radius: 4px; + padding: 4px 8px; + font-size: 12px; + background: #fff; +} +.ana-body { padding: 12px 14px; } +.ana-chart { width: 100%; height: 260px; } +.ana-detail { + margin-top: 10px; + border-top: 1px dashed #eef0f4; + padding-top: 10px; +} +.ana-detail-title { + font-size: 12px; + color: #2a5298; + font-weight: bold; + margin-bottom: 6px; +} + /* ---------- 页脚(版权/备案) ---------- */ .footer { padding: 14px 22px; diff --git a/static/js/channel.js b/static/js/channel.js index b3c90c6..99e7723 100644 --- a/static/js/channel.js +++ b/static/js/channel.js @@ -1,173 +1,359 @@ /** - * channel.js - 渠道管理逻辑(渠道列表 + 年度统计 + 效率数据) + * channel.js - 渠道管理逻辑(v1.0.18 重构) + * 目标:记录企业/人员数据的来源渠道,并根据以往数据有计划地拓展高效渠道 + * 两部分: + * 1) 渠道效能分析:企业(左)/人员(右)两个板块,右上角年份下拉(默认全部) + * - 按「渠道」字段饼状图按百分比排序;鼠标悬停饼图区域显示该渠道 source_detail TOP10 表格 + * - 表格字段:来源(只显示前 8 字,超出用…)、数量;默认显示渠道第一名 + * 2) 渠道新增计划:列表(渠道类别/来源详情/所属行业/时间窗口/剩余天数/备注说明/状态反馈) */ $(function () { renderShell('渠道管理', '业务模块 / 渠道管理'); renderPage(); initPage('channel', function (user) { if (!checkPagePermission('channel')) return; - loadList(1); - loadStats(); + loadAnalysis(); + loadPlans(1); }); var currentPage = 1; var pageRows = []; + var analysisData = null; + var charts = { company: null, person: null }; - /** 渲染页面内容(统计卡+工具栏+表格+分页) */ + /** 渲染页面内容:渠道效能分析(左右两板块)+ 渠道新增计划 */ function renderPage() { $('#page-content').html( - '
' + '
' + + '
' + + ' 渠道效能分析' + + ' 按「渠道」字段统计,鼠标悬停饼图查看该渠道来源详情 TOP10' + + '
' + + '
' + + '
' + + '
企业数据' + + '
' + + '
' + + '
' + + '
渠道明细
' + + ' ' + + '
来源数量
' + + '
' + + '
' + + '
' + + '
人员数据' + + '
' + + '
' + + '
' + + '
渠道明细
' + + ' ' + + '
来源数量
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + ' 渠道新增计划' + + ' 根据历史数据有计划地拓展高效渠道' + + '
' + '
' + - ' ' + - ' ' + - ' ' + - ' ' + - ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + ' ' + - ' ' + + ' ' + '
' + '
' + - ' ' + - ' ' + + ' ' + + ' ' + '
ID渠道名称类型联系人电话邮箱效率评分备注创建时间操作
ID渠道类别来源详情所属行业时间窗口剩余天数备注说明状态反馈创建时间操作
' + - ' ' + + ' ' + '
' ); } - /* ---------- 列表 ---------- */ - function loadList(page) { + /* ============================================================ + * 1) 渠道效能分析 + * ============================================================ */ + function loadAnalysis() { + var year = null; + var $c = $('#ana-year-company'); + var $p = $('#ana-year-person'); + if ($c.length && $p.length) { + var yc = $c.val(); + var yp = $p.val(); + year = (yc === yp) ? yc : ''; // 两板块年份不同时,分别请求 + } + if (year !== null && year === '' && $c.length && $p.length && $c.val() !== $p.val()) { + // 两板块各自请求 + httpGet('channel/analysis.php', { year: $c.val() || '' }).then(function (d) { + analysisData = d; + renderAnalysis(d, 'company'); + fillYears(d, 'company'); + }); + httpGet('channel/analysis.php', { year: $p.val() || '' }).then(function (d) { + renderAnalysis(d, 'person'); + fillYears(d, 'person'); + }); + return; + } + httpGet('channel/analysis.php', { year: year || '' }).then(function (d) { + analysisData = d; + fillYears(d, 'company'); + fillYears(d, 'person'); + renderAnalysis(d, 'company'); + renderAnalysis(d, 'person'); + }); + } + + function fillYears(d, side) { + var $sel = side === 'company' ? $('#ana-year-company') : $('#ana-year-person'); + var cur = $sel.val(); + var html = ''; + (d.years || []).forEach(function (y) { + html += ''; + }); + $sel.html(html); + } + + function renderAnalysis(d, side) { + var rows = side === 'company' ? (d.company || []) : (d.person || []); + var chartId = side === 'company' ? 'ana-chart-company' : 'ana-chart-person'; + var detailBodyId = side === 'company' ? 'ana-detail-c' : 'ana-detail-p'; + var detailTitleId = side === 'company' ? 'ana-detail-title-c' : 'ana-detail-title-p'; + + // 饼图数据(按百分比排序) + var pieData = rows.map(function (r) { + return { name: r.channel, value: r.count, percent: r.percent }; + }).sort(function (a, b) { return b.value - a.value; }); + + if (!charts[side]) { + charts[side] = echarts.init(document.getElementById(chartId)); + } + var chart = charts[side]; + chart.clear(); + chart.setOption({ + tooltip: { + trigger: 'item', + formatter: function (p) { + if (p.data && p.data.percent !== undefined) { + return p.data.name + '
数量:' + p.data.value + '(' + p.data.percent + '%)'; + } + return p.name + '
数量:' + p.value; + } + }, + legend: { bottom: 0, type: 'scroll', textStyle: { fontSize: 11 } }, + series: [{ + type: 'pie', + radius: ['38%', '62%'], + center: ['50%', '44%'], + data: pieData, + label: { show: true, formatter: '{b}\n{d}%', fontSize: 11 }, + emphasis: { itemStyle: { shadowBlur: 8, shadowColor: 'rgba(0,0,0,0.2)' } } + }] + }); + + // 默认显示第一名渠道的 source_detail TOP10 + function renderDetail(index) { + var r = rows[index]; + if (!r) { + $('#' + detailBodyId).html('暂无数据'); + return; + } + $('#' + detailTitleId).text(r.channel + ' 来源明细(TOP' + Math.min(10, (r.details || []).length) + ',共 ' + r.count + ' 条)'); + var html = ''; + (r.details || []).forEach(function (item) { + var src = item.source || ''; + var short = src.length > 8 ? src.substring(0, 8) + '…' : src; + html += '' + escHtml(short) + '' + (item.count !== undefined ? item.count : item.cnt) + ''; + }); + if (!(r.details || []).length) { + html = '暂无明细'; + } + $('#' + detailBodyId).html(html); + } + renderDetail(0); + + // 悬停饼图区域 -> 显示该渠道 TOP10 表格 + chart.off('mouseover'); + chart.on('mouseover', function (p) { + if (p.dataIndex !== undefined) { + renderDetail(p.dataIndex); + } + }); + chart.off('mouseout'); + chart.on('mouseout', function () { + renderDetail(0); + }); + + // 空数据提示 + if (!rows.length) { + $('#' + detailBodyId).html('暂无渠道数据(企业/人员需填写来源渠道)'); + } + } + + $('#ana-year-company, #ana-year-person').on('change', function () { + loadAnalysis(); + }); + + /* ============================================================ + * 2) 渠道新增计划 + * ============================================================ */ + function loadPlans(page) { currentPage = page; var params = buildFilterParam(); - delete params.year; // 年度仅用于统计 params.page = page; params.limit = PAGE_SIZE; - httpGet('channel/list.php', params).then(function (data) { + httpGet('channel/plan_list.php', params).then(function (data) { var rows = data.list || []; pageRows = rows; var html = ''; rows.forEach(function (r) { + var win = r.start_date && r.end_date + ? fmtWin(r.start_date) + ' 至 ' + fmtWin(r.end_date) + : (r.start_date || r.end_date || '-'); html += '' + '' + r.id + '' + - '' + escHtml(r.channel_name) + '' + - '' + escHtml(r.channel_type || '-') + '' + - '' + escHtml(r.contact_person || '-') + '' + - '' + escHtml(r.contact_phone || '-') + '' + - '' + escHtml(r.contact_email || '-') + '' + - '' + escHtml(r.efficiency_score) + '' + + '' + escHtml(r.channel_type) + '' + + '' + escHtml((r.source_detail || '-').substring(0, 16)) + '' + + '' + escHtml(r.industry || '-') + '' + + '' + escHtml(win) + '' + + '' + (r.remaining_days > 0 ? '' + r.remaining_days + ' 天' : '已到期') + '' + '' + escHtml((r.remark || '-').substring(0, 20)) + '' + + '' + statusTag(r.status) + '' + '' + fmtDate(r.created_at) + '' + - '编辑' + - '删除'; + '编辑' + + '删除'; }); if (!rows.length) { - html = '暂无渠道数据(渠道表为新增加的业务表,可在 sql/system_tables.sql 中创建)'; + html = '暂无渠道新增计划'; } - $('#channel-tbody').html(html); - renderPagination($('#pagination'), data, loadList); + $('#plan-tbody').html(html); + renderPagination($('#plan-pagination'), data, loadPlans); }); } - /* ---------- 年度统计 ---------- */ - function loadStats() { - httpGet('channel/stats.php').then(function (data) { - var years = data.years || []; - var html = ''; - years.forEach(function (y) { - html += ''; - }); - $('#year-select').html(html); - - $('#stat-summary').html( - '
' + data.summary.channel_count + '
' + data.year + '年渠道数
' + - '
' + data.summary.avg_score + '
平均效率评分
' + - '
' + data.summary.max_score + '
最高评分
' + - '
' + data.summary.min_score + '
最低评分
' - ); - window._channelStatData = data; - }); + /** 时间窗口显示格式:2025-4-1 */ + function fmtWin(d) { + if (!d) return ''; + var parts = String(d).split('-'); + if (parts.length === 3) { + return parseInt(parts[0], 10) + '-' + parseInt(parts[1], 10) + '-' + parseInt(parts[2], 10); + } + return d; } - $('#year-select').on('change', function () { - var y = $(this).val(); - if (!y) return; - httpGet('channel/stats.php', { year: y }).then(function (data) { - $('#stat-summary').html( - '
' + data.summary.channel_count + '
' + data.year + '年渠道数
' + - '
' + data.summary.avg_score + '
平均效率评分
' + - '
' + data.summary.max_score + '
最高评分
' + - '
' + data.summary.min_score + '
最低评分
' - ); - window._channelStatData = data; - }); + function statusTag(s) { + if (s === '已执行') return '已执行'; + if (s === '错过') return '错过'; + return '待启动'; + } + + $('#btn-plan-search').on('click', function () { loadPlans(1); }); + $('#btn-plan-reset').on('click', function () { + $('.toolbar [data-filter]').val(''); + loadPlans(1); }); - /* ---------- 新增/编辑 ---------- */ - $('#btn-add').on('click', function () { openForm(null); }); - window.editChannel = function (id) { + /* ---------- 新增/编辑计划 ---------- */ + $('#btn-plan-add').on('click', function () { openPlanForm(null); }); + window.editPlan = function (id) { var row = null; pageRows.forEach(function (r) { if (r.id == id) row = r; }); - openForm(row); + openPlanForm(row); }; - function openForm(ch) { - var isEdit = !!ch; - var c = ch || {}; + function openPlanForm(pl) { + var isEdit = !!pl; + var r = pl || {}; + // 渠道类别下拉(来自 source_channels 字典) + var chHtml = ''; + var hasCur = false; + if (window._channelTypes && window._channelTypes.length) { + window._channelTypes.forEach(function (c) { + var sel = (c === r.channel_type) ? ' selected' : ''; + if (sel) hasCur = true; + chHtml += ''; + }); + } + if (r.channel_type && !hasCur) { + chHtml += ''; + } + var content = - '
' + + '' + '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + '
' + - '