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' +
+ '
' +
+ '
' +
+ '
' +
+ '
企业数据' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
人员数据' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ '
' +
+ ''
);
}
- /* ---------- 列表 ---------- */
- 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 =
- '