From 76431c20acb396c25ee37358b9c7deec650d3510 Mon Sep 17 00:00:00 2001 From: nanguaboss <602995148@qq.com> Date: Sun, 9 Aug 2026 15:47:05 +0800 Subject: [PATCH] =?UTF-8?q?v1.0.27:=20=E7=A2=8E=E7=89=87=E5=A4=84=E7=90=86?= =?UTF-8?q?=E4=B8=AD=E5=BF=83(=E5=AE=8C=E6=95=B4=E6=96=B9=E6=A1=88)=20-=20?= =?UTF-8?q?=E5=AE=8C=E6=95=B4=E5=BA=A6=E8=AE=A1=E7=AE=97=E5=BC=95=E6=93=8E?= =?UTF-8?q?(=E5=9B=9B=E8=A1=A8=E5=88=86=E5=B1=82/=E9=98=88=E5=80=BC)+is=5F?= =?UTF-8?q?incomplete=E6=A0=87=E8=AE=B0(=E5=86=99=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E6=8C=82=E9=92=A9)+api/fragment=E5=85=AB=E6=8E=A5=E5=8F=A3(?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1/=E7=A1=AC=E7=A2=8E=E7=89=87=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E8=AF=A6=E6=83=85=E8=BD=AC=E6=8D=A2=E5=BA=9F=E5=BC=83?= =?UTF-8?q?/=E8=BD=AF=E7=A2=8E=E7=89=87=E5=88=97=E8=A1=A8=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E8=A1=A5=E5=85=A8)+fragment.html=E5=89=8D=E7=AB=AF(?= =?UTF-8?q?=E6=8C=87=E6=A0=87=E5=8D=A1+=E7=A1=AC=E8=BD=AF=E7=A2=8E?= =?UTF-8?q?=E7=89=87=E5=8F=8C=E5=8C=BA=E5=9D=97)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/common/completeness.php | 236 ++++++++++++++++++++ api/company/add.php | 5 + api/company/import.php | 3 + api/company/official_media_add.php | 5 + api/company/update.php | 5 + api/fragment/hard_common.php | 56 +++++ api/fragment/hard_convert.php | 163 ++++++++++++++ api/fragment/hard_detail.php | 32 +++ api/fragment/hard_discard.php | 33 +++ api/fragment/hard_list.php | 62 ++++++ api/fragment/soft_detail.php | 56 +++++ api/fragment/soft_list.php | 83 +++++++ api/fragment/soft_update.php | 144 ++++++++++++ api/fragment/stats_overview.php | 49 ++++ api/media/add.php | 4 + api/media/import.php | 3 + api/media/update.php | 4 + api/person/add.php | 5 + api/person/import.php | 4 + api/person/update.php | 5 + fragment.html | 17 ++ sql/migration_v1.0.27.sql | 18 ++ static/css/style.css | 1 + static/js/config.js | 6 +- static/js/fragment.js | 346 +++++++++++++++++++++++++++++ 25 files changed, 1342 insertions(+), 3 deletions(-) create mode 100644 api/common/completeness.php create mode 100644 api/fragment/hard_common.php create mode 100644 api/fragment/hard_convert.php create mode 100644 api/fragment/hard_detail.php create mode 100644 api/fragment/hard_discard.php create mode 100644 api/fragment/hard_list.php create mode 100644 api/fragment/soft_detail.php create mode 100644 api/fragment/soft_list.php create mode 100644 api/fragment/soft_update.php create mode 100644 api/fragment/stats_overview.php create mode 100644 fragment.html create mode 100644 sql/migration_v1.0.27.sql create mode 100644 static/js/fragment.js diff --git a/api/common/completeness.php b/api/common/completeness.php new file mode 100644 index 0000000..d5474bf --- /dev/null +++ b/api/common/completeness.php @@ -0,0 +1,236 @@ + 层级),threshold 为完整度阈值 */ +const COMPLETENESS_LAYERS = [ + 'persons' => [ + 'core' => ['full_name'], + 'important' => ['work_location'], + 'supplementary' => ['gender', 'nationality', 'education', 'graduated_from', 'hometown', 'id_type', 'id_number'], + 'virtual' => ['phone' => 'core', 'email' => 'important'], // 关联 social_accounts.platform + 'threshold' => 65, + ], + 'companies' => [ + 'core' => ['display_name', 'industry'], + 'important' => ['address', 'business_role', 'country'], + 'supplementary' => ['name_zh', 'name_en', 'registration_number', 'legal_form', 'legal_representative', + 'business_scope', 'established_date', 'registered_capital', 'industry_subdivision', + 'latest_employee_count', 'latest_annual_revenue', 'is_listed', 'stock_code', 'website'], + 'virtual' => [], + 'threshold' => 65, + ], + 'social_accounts' => [ + 'core' => ['platform', 'account_id'], + 'important' => ['is_primary'], + 'supplementary' => ['profile_url', 'remark'], + 'virtual' => [], + 'threshold' => 60, + ], + 'media' => [ + 'core' => ['social_account_id', 'account_level'], + 'important' => ['follower_count', 'content_categories'], + 'supplementary' => ['certification_type', 'avg_read_count', 'special_requirements', 'media_remark'], + 'virtual' => [], + 'threshold' => 55, + ], +]; + +/** 字段中文名(前端展示缺失字段用) */ +const COMPLETENESS_LABELS = [ + 'full_name' => '姓名', 'phone' => '手机', 'email' => '邮箱', 'work_location' => '工作所在地', + 'gender' => '性别', 'nationality' => '国籍', 'education' => '学历', 'graduated_from' => '毕业院校', + 'hometown' => '家乡', 'id_type' => '证件类型', 'id_number' => '证件号码', + 'display_name' => '名称', 'industry' => '行业', 'address' => '地址', 'business_role' => '业务类型', + 'country' => '国家/地区', 'name_zh' => '中文名', 'name_en' => '英文名', 'registration_number' => '注册号', + 'legal_form' => '企业类型', 'legal_representative' => '法定代表人', 'business_scope' => '经营范围', + 'established_date' => '成立日期', 'registered_capital' => '注册资本', 'industry_subdivision' => '行业细分', + 'latest_employee_count' => '员工数', 'latest_annual_revenue' => '年营收', 'is_listed' => '是否上市', + 'stock_code' => '股票代码', 'website' => '官网', + 'platform' => '平台', 'account_id' => '账号', 'is_primary' => '常用', 'profile_url' => '主页链接', 'remark' => '备注', + 'social_account_id' => '关联账号', 'account_level' => '账号等级', 'follower_count' => '粉丝数', + 'content_categories' => '内容领域', 'certification_type' => '认证类型', 'avg_read_count' => '平均阅读', + 'special_requirements' => '特殊要求', 'media_remark' => '评估备注', +]; + +/** 字段是否缺失:null / 空串 / 纯空白 视为缺失(0 视为已填写) */ +function completenessIsMissing($v) +{ + return ($v === null) || (trim((string)$v) === ''); +} + +/** + * 根据分层定义计算完整度 + * @param string $table persons|companies|social_accounts|media + * @param array $row 该表记录(persons 需已注入虚拟字段 phone/email) + * @return array { score:int, missing_core:[], missing_important:[], missing_supp:[], level:string } + */ +function completenessCalc($table, $row) +{ + $layers = COMPLETENESS_LAYERS[$table] ?? null; + if (!$layers) { + return ['score' => 100, 'missing_core' => [], 'missing_important' => [], 'missing_supp' => [], 'level' => 'complete']; + } + $missing = ['core' => [], 'important' => [], 'supplementary' => []]; + + foreach (['core' => 'core', 'important' => 'important', 'supplementary' => 'supplementary'] as $layer => $bucket) { + foreach ($layers[$layer] as $field) { + if (completenessIsMissing($row[$field] ?? null)) { + $missing[$bucket][] = $field; + } + } + } + // 虚拟字段(如人员 phone/email)按配置的层级计入 + foreach (($layers['virtual'] ?? []) as $field => $layer) { + if (completenessIsMissing($row[$field] ?? null)) { + $missing[$layer][] = $field; + } + } + + $score = 100 + - count($missing['core']) * 20 + - count($missing['important']) * 8 + - count($missing['supplementary']) * 2; + $score = max(0, $score); + + $level = 'complete'; + if (!empty($missing['core'])) { + $level = 'core'; // 缺核心(红) + } elseif (!empty($missing['important'])) { + $level = 'important'; // 缺重要(橙) + } elseif (!empty($missing['supplementary'])) { + $level = 'supplementary'; // 待补充(蓝) + } + + return [ + 'score' => $score, + 'missing_core' => $missing['core'], + 'missing_important' => $missing['important'], + 'missing_supp' => $missing['supplementary'], + 'level' => $level, + ]; +} + +/** 人员记录:注入虚拟字段 phone/email(来自 social_accounts) */ +function personWithVirtualFields(PDO $pdo, array $row) +{ + $stmt = $pdo->prepare( + "SELECT platform, account_id FROM social_accounts + WHERE owner_type = 'person' AND owner_id = ? AND is_active = 1 AND platform IN ('phone','email') + ORDER BY is_primary DESC, id ASC" + ); + $stmt->execute([$row['id']]); + foreach ($stmt->fetchAll() as $acc) { + if (!isset($row[$acc['platform']])) { + $row[$acc['platform']] = $acc['account_id']; + } + } + return $row; +} + +/** + * 计算某条记录的完整度信息 + * @param PDO $pdo + * @param string $table persons|companies|social_accounts|media + * @param int $id + * @return array { row, score, missing_core, missing_important, missing_supp, level, threshold } + */ +function completenessInfo(PDO $pdo, $table, $id) +{ + $row = null; + switch ($table) { + case 'persons': + $stmt = $pdo->prepare("SELECT * FROM persons WHERE id = ?"); + $stmt->execute([$id]); + $row = $stmt->fetch(); + if ($row) { + $row = personWithVirtualFields($pdo, $row); + } + break; + case 'companies': + $stmt = $pdo->prepare("SELECT * FROM companies WHERE id = ?"); + $stmt->execute([$id]); + $row = $stmt->fetch(); + break; + case 'social_accounts': + $stmt = $pdo->prepare("SELECT * FROM social_accounts WHERE id = ?"); + $stmt->execute([$id]); + $row = $stmt->fetch(); + break; + case 'media': + $stmt = $pdo->prepare("SELECT * FROM media_commercial_attributes WHERE social_account_id = ?"); + $stmt->execute([$id]); + $row = $stmt->fetch(); + if ($row) { + $row['id'] = $row['social_account_id']; + } + break; + } + if (!$row) { + return null; + } + $calc = completenessCalc($table, $row); + return [ + 'row' => $row, + 'score' => $calc['score'], + 'missing_core' => $calc['missing_core'], + 'missing_important' => $calc['missing_important'], + 'missing_supp' => $calc['missing_supp'], + 'level' => $calc['level'], + 'threshold' => COMPLETENESS_LAYERS[$table]['threshold'] ?? 65, + 'is_complete' => $calc['score'] >= (COMPLETENESS_LAYERS[$table]['threshold'] ?? 65), + ]; +} + +/** + * 更新某条记录的 is_incomplete 标记(完整度低于阈值 → 1) + * @param PDO $pdo + * @param string $table persons|companies|social_accounts|media + * @param int $id + * @return array|null completenessInfo 结果(记录不存在返回 null) + */ +function updateIncomplete(PDO $pdo, $table, $id) +{ + $info = completenessInfo($pdo, $table, $id); + if (!$info) { + return null; + } + $flag = $info['is_complete'] ? 0 : 1; + switch ($table) { + case 'persons': + $pdo->prepare("UPDATE persons SET is_incomplete = ? WHERE id = ?")->execute([$flag, $id]); + break; + case 'companies': + $pdo->prepare("UPDATE companies SET is_incomplete = ? WHERE id = ?")->execute([$flag, $id]); + break; + case 'social_accounts': + $pdo->prepare("UPDATE social_accounts SET is_incomplete = ? WHERE id = ?")->execute([$flag, $id]); + break; + case 'media': + $pdo->prepare("UPDATE media_commercial_attributes SET is_incomplete = ? WHERE social_account_id = ?")->execute([$flag, $id]); + break; + } + return $info; +} + +/** 更新某个归属方(人员/企业)全部 social_accounts 的 is_incomplete */ +function updateAccountsIncomplete(PDO $pdo, $ownerType, $ownerId) +{ + $stmt = $pdo->prepare("SELECT id FROM social_accounts WHERE owner_type = ? AND owner_id = ?"); + $stmt->execute([$ownerType, $ownerId]); + foreach ($stmt->fetchAll() as $acc) { + updateIncomplete($pdo, 'social_accounts', (int)$acc['id']); + } +} + +/** 更新媒体账号(social_accounts + 其商业属性)的 is_incomplete */ +function updateMediaIncomplete(PDO $pdo, $socialAccountId) +{ + updateIncomplete($pdo, 'social_accounts', $socialAccountId); + updateIncomplete($pdo, 'media', $socialAccountId); +} diff --git a/api/company/add.php b/api/company/add.php index 0839646..e543113 100644 --- a/api/company/add.php +++ b/api/company/add.php @@ -8,6 +8,7 @@ require_once __DIR__ . '/../common/response.php'; require_once __DIR__ . '/../common/auth.php'; require_once __DIR__ . '/../common/logger.php'; require_once __DIR__ . '/../common/helpers.php'; +require_once __DIR__ . '/../common/completeness.php'; checkAjax(); checkPermission('company'); @@ -61,5 +62,9 @@ if (array_key_exists('financials', $_POST) || array_key_exists('certifications', } } +// 完整度检查:企业主表 + 其 social_accounts +updateIncomplete($pdo, 'companies', $newId); +updateAccountsIncomplete($pdo, 'company', $newId); + logCurrent('add', 'company', 'companies', $newId, $data); Response::success(['id' => $newId], '新增成功'); diff --git a/api/company/import.php b/api/company/import.php index 8dcb71f..acb4f0c 100644 --- a/api/company/import.php +++ b/api/company/import.php @@ -10,6 +10,7 @@ require_once __DIR__ . '/../common/db.php'; require_once __DIR__ . '/../common/response.php'; require_once __DIR__ . '/../common/auth.php'; require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/completeness.php'; checkAjax(); checkPermission('company'); @@ -75,6 +76,8 @@ while (($row = fgetcsv($handle)) !== false) { ($rec['is_listed'] ?? 0) ? 1 : 0, $rec['stock_code'] ?? null, $rec['source_channel'] ?? null, $rec['source_detail'] ?? null, ]); + // 完整度检查(导入每行入库后) + updateIncomplete($pdo, 'companies', (int)$pdo->lastInsertId()); $inserted++; } catch (Exception $e) { $failed++; diff --git a/api/company/official_media_add.php b/api/company/official_media_add.php index 8bf93ce..9f219b3 100644 --- a/api/company/official_media_add.php +++ b/api/company/official_media_add.php @@ -10,6 +10,7 @@ require_once __DIR__ . '/../common/response.php'; require_once __DIR__ . '/../common/auth.php'; require_once __DIR__ . '/../common/helpers.php'; require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/completeness.php'; checkAjax(); checkPermission('company'); @@ -44,5 +45,9 @@ $ins->execute([ ]); $newId = (int)$pdo->lastInsertId(); +// 完整度检查:新官媒账号 + 所属企业全部联系方式 +updateIncomplete($pdo, 'social_accounts', $newId); +updateAccountsIncomplete($pdo, 'company', $companyId); + logCurrent('add', 'official_media', 'social_accounts', $newId, ['company_id' => $companyId, 'platform' => $platform, 'account_id' => $accountId]); Response::success(['id' => $newId], '新增成功'); diff --git a/api/company/update.php b/api/company/update.php index 56c4e65..06e6f23 100644 --- a/api/company/update.php +++ b/api/company/update.php @@ -8,6 +8,7 @@ require_once __DIR__ . '/../common/response.php'; require_once __DIR__ . '/../common/auth.php'; require_once __DIR__ . '/../common/logger.php'; require_once __DIR__ . '/../common/helpers.php'; +require_once __DIR__ . '/../common/completeness.php'; checkAjax(); checkPermission('company'); @@ -76,6 +77,10 @@ if ($hasFinancials || $hasCertifications || $hasAccounts) { } } +// 完整度检查:企业主表 + 其 social_accounts +updateIncomplete($pdo, 'companies', $id); +updateAccountsIncomplete($pdo, 'company', $id); + if (!empty($data)) { logCurrent('update', 'company', 'companies', $id, ['before' => $old, 'after' => $data]); } diff --git a/api/fragment/hard_common.php b/api/fragment/hard_common.php new file mode 100644 index 0000000..a00033d --- /dev/null +++ b/api/fragment/hard_common.php @@ -0,0 +1,56 @@ + ['core' => ['company_name', 'contact_phone'], 'important' => ['contact_email'], 'supplementary' => ['person_name', 'description', 'target_product_category']], + 'person' => ['core' => ['person_name', 'contact_phone'], 'important' => ['contact_email'], 'supplementary' => ['company_name', 'description', 'target_product_category']], + 'product' => ['core' => ['company_name', 'target_product_category'], 'important' => ['contact_phone', 'contact_email'], 'supplementary' => ['person_name', 'description']], + 'need' => ['core' => ['company_name', 'target_product_category'], 'important' => ['contact_phone', 'contact_email'], 'supplementary' => ['person_name', 'description']], + 'mixed' => ['core' => ['company_name', 'person_name'], 'important' => ['contact_phone', 'contact_email'], 'supplementary' => ['description', 'target_product_category']], +]; +const HARD_LABELS = [ + 'company_name' => '公司名称', 'person_name' => '联系人', 'contact_phone' => '电话', + 'contact_email' => '邮箱', 'target_product_category' => '产品品类', 'description' => '描述', +]; + +/** 计算硬碎片完整度与缺失层级 */ +function hardCompleteness($row) +{ + $layers = HARD_LAYERS[$row['source_type']] ?? HARD_LAYERS['mixed']; + $missing = ['core' => [], 'important' => [], 'supplementary' => []]; + foreach (['core', 'important', 'supplementary'] as $layer) { + foreach ($layers[$layer] as $field) { + if ($row[$field] === null || trim((string)$row[$field]) === '') { + $missing[$layer][] = $field; + } + } + } + $score = 100 - count($missing['core']) * 20 - count($missing['important']) * 8 - count($missing['supplementary']) * 2; + $score = max(0, $score); + $level = 'complete'; + if (!empty($missing['core'])) { + $level = 'core'; + } elseif (!empty($missing['important'])) { + $level = 'important'; + } elseif (!empty($missing['supplementary'])) { + $level = 'supplementary'; + } + return ['score' => $score, 'level' => $level, 'missing' => $missing]; +} + +/** 内容摘要:拼接已有关键信息 */ +function hardSummary($row) +{ + $parts = []; + if (!empty($row['company_name'])) $parts[] = '公司:' . $row['company_name']; + if (!empty($row['person_name'])) $parts[] = '联系人:' . $row['person_name']; + if (!empty($row['contact_phone'])) $parts[] = '手机:' . $row['contact_phone']; + if (!empty($row['contact_email'])) $parts[] = '邮箱:' . $row['contact_email']; + if (!empty($row['target_product_category'])) $parts[] = '品类:' . $row['target_product_category']; + if (empty($parts) && !empty($row['description'])) $parts[] = '描述:' . mb_substr($row['description'], 0, 20); + return implode(' ', $parts); +} diff --git a/api/fragment/hard_convert.php b/api/fragment/hard_convert.php new file mode 100644 index 0000000..3376305 --- /dev/null +++ b/api/fragment/hard_convert.php @@ -0,0 +1,163 @@ +getPdo(); + +$stmt = $pdo->prepare("SELECT * FROM preliminary_data WHERE id = ? AND is_active = 1"); +$stmt->execute([$id]); +$frag = $stmt->fetch(); +if (!$frag) { + Response::error('碎片不存在'); +} +if (in_array($frag['status'], ['已转换', '已废弃'], true)) { + Response::error('该碎片已处理(' . $frag['status'] . ')'); +} + +$companyName = trim($_POST['company_name'] ?? ($frag['company_name'] ?? '')); +$personName = trim($_POST['person_name'] ?? ($frag['person_name'] ?? '')); +$phone = trim($_POST['contact_phone'] ?? ($frag['contact_phone'] ?? '')); +$email = trim($_POST['contact_email'] ?? ($frag['contact_email'] ?? '')); +$category = trim($_POST['target_product_category'] ?? ($frag['target_product_category'] ?? '')); +$description = trim($_POST['description'] ?? ($frag['description'] ?? '')); +$industry = trim($_POST['industry'] ?? ''); +$sourceChannel = $frag['source_channel'] ?? null; + +$convertedId = 0; + +/** 创建 social_accounts(phone/email,常用) */ +$saveContacts = function ($ownerType, $ownerId) use ($pdo, $phone, $email) { + if ($phone !== '') { + $pdo->prepare( + "INSERT INTO social_accounts (owner_type, owner_id, platform, account_id, is_primary, is_defult) + VALUES (?, ?, 'phone', ?, 1, 1)" + )->execute([$ownerType, $ownerId, $phone]); + } + if ($email !== '') { + $pdo->prepare( + "INSERT INTO social_accounts (owner_type, owner_id, platform, account_id, is_primary, is_defult) + VALUES (?, ?, 'email', ?, 1, 1)" + )->execute([$ownerType, $ownerId, $email]); + } +}; + +/** 按名称找公司,找不到则自动创建 */ +$resolveCompany = function () use ($pdo, $companyName, $sourceChannel, $industry) { + $stmt = $pdo->prepare("SELECT id FROM companies WHERE display_name = ? AND is_active = 1 LIMIT 1"); + $stmt->execute([$companyName]); + $cid = (int)$stmt->fetchColumn(); + if ($cid > 0) { + return $cid; + } + $pdo->prepare( + "INSERT INTO companies (display_name, name_zh, industry, source_channel) VALUES (?, ?, ?, ?)" + )->execute([$companyName, $companyName, $industry !== '' ? $industry : null, $sourceChannel]); + return (int)$pdo->lastInsertId(); +}; + +switch ($targetType) { + case 'company': + if ($companyName === '') { + Response::error('公司名称为必填项', 400); + } + $pdo->prepare( + "INSERT INTO companies (display_name, name_zh, industry, business_scope, source_channel, source_detail) + VALUES (?, ?, ?, ?, ?, ?)" + )->execute([ + $companyName, $companyName, + $industry !== '' ? $industry : null, + $description !== '' ? $description : null, + $sourceChannel, + $frag['description'] ?? null, + ]); + $convertedId = (int)$pdo->lastInsertId(); + $saveContacts('company', $convertedId); + updateIncomplete($pdo, 'companies', $convertedId); + updateAccountsIncomplete($pdo, 'company', $convertedId); + break; + + case 'person': + if ($personName === '') { + Response::error('联系人为必填项', 400); + } + $unionId = 'P' . date('YmdHis') . substr(uniqid(), -6); + $pdo->prepare( + "INSERT INTO persons (union_id, full_name, source_channel, source_detail) VALUES (?, ?, ?, ?)" + )->execute([$unionId, $personName, $sourceChannel, $frag['description'] ?? null]); + $convertedId = (int)$pdo->lastInsertId(); + $saveContacts('person', $convertedId); + updateIncomplete($pdo, 'persons', $convertedId); + updateAccountsIncomplete($pdo, 'person', $convertedId); + break; + + case 'product': + if ($companyName === '') { + Response::error('公司名称为必填项', 400); + } + if ($category === '') { + Response::error('产品品类为必填项', 400); + } + $companyId = $resolveCompany(); + $pdo->prepare( + "INSERT INTO company_products (company_id, category_name, category_description) + VALUES (?, ?, ?)" + )->execute([$companyId, $category, $description !== '' ? $description : null]); + $convertedId = (int)$pdo->lastInsertId(); + updateIncomplete($pdo, 'companies', $companyId); + break; + + case 'need': + if ($companyName === '') { + Response::error('公司名称为必填项', 400); + } + $companyId = $resolveCompany(); + $contactPerson = trim($_POST['contact_person'] ?? ($frag['person_name'] ?? '')); + $needCategory = trim($_POST['need_category'] ?? ''); + $scenario = trim($_POST['application_scenario'] ?? ''); + $pdo->prepare( + "INSERT INTO company_needs (company_id, contact_person, need_category, target_product_category, application_scenario, description) + VALUES (?, ?, ?, ?, ?, ?)" + )->execute([ + $companyId, + $contactPerson !== '' ? $contactPerson : null, + $needCategory !== '' ? $needCategory : null, + $category !== '' ? $category : null, + $scenario !== '' ? $scenario : null, + $description !== '' ? $description : null, + ]); + $convertedId = (int)$pdo->lastInsertId(); + updateIncomplete($pdo, 'companies', $companyId); + break; +} + +// 标记碎片已转换 +$pdo->prepare( + "UPDATE preliminary_data SET status = '已转换', converted_type = ?, converted_id = ?, processed_at = NOW() WHERE id = ?" +)->execute([$targetType, $convertedId, $id]); + +logCurrent('convert', 'fragment', 'preliminary_data', $id, [ + 'target_type' => $targetType, + 'converted_id' => $convertedId, + 'fields' => ['company_name' => $companyName, 'person_name' => $personName, 'phone' => $phone, 'email' => $email], +]); + +Response::success(['converted_type' => $targetType, 'converted_id' => $convertedId], '转换成功'); diff --git a/api/fragment/hard_detail.php b/api/fragment/hard_detail.php new file mode 100644 index 0000000..5b205de --- /dev/null +++ b/api/fragment/hard_detail.php @@ -0,0 +1,32 @@ +getPdo(); +$stmt = $pdo->prepare("SELECT * FROM preliminary_data WHERE id = ?"); +$stmt->execute([$id]); +$row = $stmt->fetch(); +if (!$row) { + Response::error('碎片不存在'); +} + +$calc = hardCompleteness($row); +$row['completeness'] = $calc['score']; +$row['level'] = $calc['level']; +$row['missing'] = $calc['missing']; +$row['summary'] = hardSummary($row); +$row['source_type_label'] = ['company' => '企业', 'person' => '人员', 'product' => '产品', 'need' => '需求', 'mixed' => '混合'][$row['source_type']] ?? $row['source_type']; + +Response::success($row); diff --git a/api/fragment/hard_discard.php b/api/fragment/hard_discard.php new file mode 100644 index 0000000..974e9aa --- /dev/null +++ b/api/fragment/hard_discard.php @@ -0,0 +1,33 @@ +getPdo(); +$stmt = $pdo->prepare("SELECT id, status FROM preliminary_data WHERE id = ? AND is_active = 1"); +$stmt->execute([$id]); +$frag = $stmt->fetch(); +if (!$frag) { + Response::error('碎片不存在'); +} +if (in_array($frag['status'], ['已转换', '已废弃'], true)) { + Response::error('该碎片已处理(' . $frag['status'] . ')'); +} + +$pdo->prepare("UPDATE preliminary_data SET status = '已废弃', processed_at = NOW() WHERE id = ?")->execute([$id]); + +logCurrent('discard', 'fragment', 'preliminary_data', $id, ['status' => '已废弃']); +Response::success(null, '已废弃'); diff --git a/api/fragment/hard_list.php b/api/fragment/hard_list.php new file mode 100644 index 0000000..08b9781 --- /dev/null +++ b/api/fragment/hard_list.php @@ -0,0 +1,62 @@ +getPdo(); + +$stmt = $pdo->prepare("SELECT COUNT(*) FROM preliminary_data WHERE $whereSql"); +$stmt->execute($params); +$total = (int)$stmt->fetchColumn(); + +$offset = ($page - 1) * $limit; +$stmt = $pdo->prepare( + "SELECT id, source_type, company_name, person_name, contact_phone, contact_email, + target_product_category, description, status, source_channel, recorded_by, + follow_person, converted_type, converted_id, created_at, updated_at + FROM preliminary_data + WHERE $whereSql + ORDER BY id DESC + LIMIT $limit OFFSET $offset" +); +$stmt->execute($params); +$rows = $stmt->fetchAll(); + +$list = []; +foreach ($rows as $r) { + $calc = hardCompleteness($r); + $r['completeness'] = $calc['score']; + $r['level'] = $calc['level']; + $r['missing'] = $calc['missing']; + $r['summary'] = hardSummary($r); + $r['source_type_label'] = ['company' => '企业', 'person' => '人员', 'product' => '产品', 'need' => '需求', 'mixed' => '混合'][$r['source_type']] ?? $r['source_type']; + $list[] = $r; +} + +Response::success(['list' => $list, 'total' => $total, 'page' => $page, 'limit' => $limit]); diff --git a/api/fragment/soft_detail.php b/api/fragment/soft_detail.php new file mode 100644 index 0000000..a42d760 --- /dev/null +++ b/api/fragment/soft_detail.php @@ -0,0 +1,56 @@ +getPdo(); +$info = completenessInfo($pdo, $table, $id); +if (!$info) { + Response::error('记录不存在'); +} + +$tableLabels = ['persons' => '人员', 'companies' => '企业', 'social_accounts' => '联系方式', 'media' => '媒体']; +$name = ''; +switch ($table) { + case 'persons': + $name = $info['row']['full_name'] ?? ''; + break; + case 'companies': + $name = $info['row']['display_name'] ?? ''; + break; + case 'social_accounts': + $name = $info['row']['account_id'] ?? ''; + break; + case 'media': + $acc = $pdo->prepare("SELECT account_id FROM social_accounts WHERE id = ?"); + $acc->execute([$id]); + $name = (string)$acc->fetchColumn(); + break; +} + +Response::success([ + 'table' => $table, + 'table_label' => $tableLabels[$table], + 'id' => $id, + 'name' => $name ?: ('#' . $id), + 'score' => $info['score'], + 'level' => $info['level'], + 'threshold' => $info['threshold'], + 'missing_core' => array_map(function ($f) { return ['key' => $f, 'label' => COMPLETENESS_LABELS[$f] ?? $f]; }, $info['missing_core']), + 'missing_important' => array_map(function ($f) { return ['key' => $f, 'label' => COMPLETENESS_LABELS[$f] ?? $f]; }, $info['missing_important']), + 'missing_supp' => array_map(function ($f) { return ['key' => $f, 'label' => COMPLETENESS_LABELS[$f] ?? $f]; }, $info['missing_supp']), + 'row' => $info['row'], +]); diff --git a/api/fragment/soft_list.php b/api/fragment/soft_list.php new file mode 100644 index 0000000..944d445 --- /dev/null +++ b/api/fragment/soft_list.php @@ -0,0 +1,83 @@ + ['label' => '人员', 'name' => 'full_name'], + 'companies' => ['label' => '企业', 'name' => 'display_name'], + 'social_accounts' => ['label' => '联系方式', 'name' => 'account_id'], + 'media' => ['label' => '媒体', 'name' => null], // media 需 JOIN social_accounts 取名 +]; + +$pdo = DB::getInstance()->getPdo(); +$all = []; + +foreach ($tableMap as $t => $cfg) { + if ($table !== '' && $table !== $t) { + continue; + } + if ($t === 'media') { + $rows = $pdo->query( + "SELECT m.social_account_id AS id, sa.account_id AS name, m.updated_at AS created_at + FROM media_commercial_attributes m + LEFT JOIN social_accounts sa ON sa.id = m.social_account_id + WHERE m.is_incomplete = 1" + )->fetchAll(); + } else { + $rows = $pdo->query("SELECT id, {$cfg['name']} AS name, created_at FROM `$t` WHERE is_incomplete = 1")->fetchAll(); + } + foreach ($rows as $r) { + $info = completenessInfo($pdo, $t, (int)$r['id']); + if (!$info) { + continue; + } + if ($level !== '' && $info['level'] !== $level) { + continue; + } + $missingFields = array_merge($info['missing_core'], $info['missing_important'], $info['missing_supp']); + $all[] = [ + 'table' => $t, + 'table_label' => $cfg['label'], + 'id' => (int)$r['id'], + 'name' => $r['name'] ?: ('#' . $r['id']), + 'score' => $info['score'], + 'level' => $info['level'], + 'missing_fields' => array_map(function ($f) { return COMPLETENESS_LABELS[$f] ?? $f; }, $missingFields), + 'created_at' => $r['created_at'] ?? null, + ]; + } +} + +// 按完整度升序(最残缺的排前面),再按时间倒序 +usort($all, function ($a, $b) { + if ($a['score'] !== $b['score']) { + return $a['score'] <=> $b['score']; + } + return strcmp($b['created_at'] ?? '', $a['created_at'] ?? ''); +}); + +if ($keyword !== '') { + $all = array_values(array_filter($all, function ($r) use ($keyword) { + return mb_strpos($r['name'], $keyword) !== false; + })); +} + +$total = count($all); +$offset = ($page - 1) * $limit; +$list = array_slice($all, $offset, $limit); + +Response::success(['list' => $list, 'total' => $total, 'page' => $page, 'limit' => $limit]); diff --git a/api/fragment/soft_update.php b/api/fragment/soft_update.php new file mode 100644 index 0000000..90bc80e --- /dev/null +++ b/api/fragment/soft_update.php @@ -0,0 +1,144 @@ +getPdo(); + +/** 按白名单收集 POST 字段 */ +$fields = []; +$collect = function ($whitelist) use (&$fields) { + foreach ($whitelist as $k => $type) { + if (!isset($_POST[$k])) { + continue; + } + $v = trim((string)$_POST[$k]); + if ($v === '') { + $fields[$k] = null; + continue; + } + switch ($type) { + case 'i': + $fields[$k] = (int)$v; + break; + case 'd': + $fields[$k] = (strtotime($v) !== false) ? date('Y-m-d', strtotime($v)) : null; + break; + default: + $fields[$k] = $v; + } + } +}; + +$isComplete = false; +switch ($table) { + case 'persons': + $collect([ + 'full_name' => 's', 'gender' => 's', 'nationality' => 's', 'education' => 's', + 'graduated_from' => 's', 'hometown' => 's', 'work_location' => 's', + 'id_type' => 's', 'id_number' => 's', 'source_channel' => 's', 'source_detail' => 's', + ]); + if (!empty($fields)) { + [$sets, $params] = buildUpdate($fields); + $params[] = $id; + $pdo->prepare("UPDATE persons SET $sets WHERE id = ?")->execute($params); + } + // phone/email 写 social_accounts(有则更新,无则插入) + foreach (['phone', 'email'] as $plat) { + if (!isset($_POST[$plat])) { + continue; + } + $v = trim((string)$_POST[$plat]); + $exist = $pdo->prepare("SELECT id FROM social_accounts WHERE owner_type = 'person' AND owner_id = ? AND platform = ?"); + $exist->execute([$id, $plat]); + $accId = (int)$exist->fetchColumn(); + if ($accId > 0) { + if ($v === '') { + $pdo->prepare("DELETE FROM social_accounts WHERE id = ?")->execute([$accId]); + } else { + $pdo->prepare("UPDATE social_accounts SET account_id = ? WHERE id = ?")->execute([$v, $accId]); + } + } elseif ($v !== '') { + $pdo->prepare( + "INSERT INTO social_accounts (owner_type, owner_id, platform, account_id, is_primary, is_defult) + VALUES ('person', ?, ?, ?, 1, 1)" + )->execute([$id, $plat, $v]); + } + } + $info = updateIncomplete($pdo, 'persons', $id); + updateAccountsIncomplete($pdo, 'person', $id); + break; + + case 'companies': + $collect([ + 'display_name' => 's', 'name_zh' => 's', 'name_en' => 's', 'business_role' => 's', + 'country' => 's', 'registration_number' => 's', 'address' => 's', 'legal_form' => 's', + 'legal_representative' => 's', 'business_scope' => 's', 'established_date' => 'd', + 'registered_capital' => 's', 'industry' => 's', 'industry_subdivision' => 's', + 'latest_employee_count' => 'i', 'latest_annual_revenue' => 's', 'is_listed' => 'i', + 'stock_code' => 's', 'website' => 's', + ]); + if (!empty($fields)) { + [$sets, $params] = buildUpdate($fields); + $params[] = $id; + $pdo->prepare("UPDATE companies SET $sets WHERE id = ?")->execute($params); + } + $info = updateIncomplete($pdo, 'companies', $id); + break; + + case 'social_accounts': + $collect([ + 'platform' => 's', 'account_id' => 's', 'is_primary' => 'i', 'profile_url' => 's', 'remark' => 's', + ]); + if (!empty($fields)) { + [$sets, $params] = buildUpdate($fields); + $params[] = $id; + $pdo->prepare("UPDATE social_accounts SET $sets WHERE id = ?")->execute($params); + } + $info = updateIncomplete($pdo, 'social_accounts', $id); + break; + + case 'media': + $collect([ + 'account_level' => 's', 'content_categories' => 's', 'follower_count' => 'i', + 'avg_read_count' => 'i', 'certification_type' => 's', 'special_requirements' => 's', 'media_remark' => 's', + ]); + if (!empty($fields)) { + [$sets, $params] = buildUpdate($fields); + $params[] = $id; + $pdo->prepare("UPDATE media_commercial_attributes SET $sets WHERE social_account_id = ?")->execute($params); + } + $info = updateIncomplete($pdo, 'media', $id); + updateIncomplete($pdo, 'social_accounts', $id); + break; +} + +if (!$info) { + Response::error('记录不存在'); +} + +logCurrent('complete', 'fragment', $table, $id, ['fields' => array_keys($fields), 'score' => $info['score']]); + +Response::success([ + 'score' => $info['score'], + 'level' => $info['level'], + 'is_complete' => $info['is_complete'], +], $info['is_complete'] ? '补全完成' : '已更新,完整度 ' . $info['score'] . '%'); diff --git a/api/fragment/stats_overview.php b/api/fragment/stats_overview.php new file mode 100644 index 0000000..07a3db6 --- /dev/null +++ b/api/fragment/stats_overview.php @@ -0,0 +1,49 @@ +getPdo(); + +// 硬碎片:preliminary_data 中未终结(未转换/未废弃)的记录 +$hardTotal = (int)$pdo->query( + "SELECT COUNT(*) FROM preliminary_data WHERE is_active = 1 AND status NOT IN ('已转换','已废弃')" +)->fetchColumn(); + +// 软碎片:四张主表 is_incomplete=1 之和 +$softTotal = 0; +foreach (['companies', 'persons', 'social_accounts', 'media_commercial_attributes'] as $t) { + $softTotal += (int)$pdo->query("SELECT COUNT(*) FROM `$t` WHERE is_incomplete = 1")->fetchColumn(); +} + +// 本月新增(硬碎片按 created_at) +$monthNew = (int)$pdo->query( + "SELECT COUNT(*) FROM preliminary_data WHERE is_active = 1 AND created_at >= DATE_FORMAT(CURDATE(), '%Y-%m-01')" +)->fetchColumn(); + +// 本月处理(已转换/已废弃且 processed_at 在本月) +$monthProcessed = (int)$pdo->query( + "SELECT COUNT(*) FROM preliminary_data + WHERE is_active = 1 AND status IN ('已转换','已废弃') + AND processed_at >= DATE_FORMAT(CURDATE(), '%Y-%m-01')" +)->fetchColumn(); + +// 待处理碎片 +$pending = (int)$pdo->query( + "SELECT COUNT(*) FROM preliminary_data WHERE is_active = 1 AND status = '待处理'" +)->fetchColumn(); + +Response::success([ + 'hard_total' => $hardTotal, + 'soft_total' => $softTotal, + 'month_new' => $monthNew, + 'month_processed' => $monthProcessed, + 'pending' => $pending, +]); diff --git a/api/media/add.php b/api/media/add.php index 774abf2..0388b27 100644 --- a/api/media/add.php +++ b/api/media/add.php @@ -9,6 +9,7 @@ require_once __DIR__ . '/../common/response.php'; require_once __DIR__ . '/../common/auth.php'; require_once __DIR__ . '/../common/logger.php'; require_once __DIR__ . '/../common/helpers.php'; +require_once __DIR__ . '/../common/completeness.php'; checkAjax(); checkPermission('media'); @@ -53,5 +54,8 @@ if (!empty($attr)) { $pdo->prepare("INSERT INTO media_commercial_attributes $attrSql")->execute($attrParams); } +// 完整度检查:媒体账号 + 商业属性 +updateMediaIncomplete($pdo, $newId); + logCurrent('add', 'media', 'social_accounts', $newId, ['data' => $data, 'attr' => $attr]); Response::success(['id' => $newId], '新增成功'); diff --git a/api/media/import.php b/api/media/import.php index 9aa9083..b025849 100644 --- a/api/media/import.php +++ b/api/media/import.php @@ -9,6 +9,7 @@ require_once __DIR__ . '/../common/db.php'; require_once __DIR__ . '/../common/response.php'; require_once __DIR__ . '/../common/auth.php'; require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/completeness.php'; checkAjax(); checkPermission('media'); @@ -85,6 +86,8 @@ while (($row = fgetcsv($handle)) !== false) { $rec['certification_type'] ?? null, ]); } + // 完整度检查(导入每行入库后) + updateMediaIncomplete($pdo, $newId); $inserted++; } catch (Exception $e) { $failed++; diff --git a/api/media/update.php b/api/media/update.php index c0d1226..c1d6c76 100644 --- a/api/media/update.php +++ b/api/media/update.php @@ -8,6 +8,7 @@ require_once __DIR__ . '/../common/response.php'; require_once __DIR__ . '/../common/auth.php'; require_once __DIR__ . '/../common/logger.php'; require_once __DIR__ . '/../common/helpers.php'; +require_once __DIR__ . '/../common/completeness.php'; checkAjax(); checkPermission('media'); @@ -50,5 +51,8 @@ if (!empty($attr)) { } } +// 完整度检查:媒体账号 + 商业属性 +updateMediaIncomplete($pdo, $id); + logCurrent('update', 'media', 'social_accounts', $id, ['before' => $old, 'after' => $data, 'attr' => $attr]); Response::success(null, '更新成功'); diff --git a/api/person/add.php b/api/person/add.php index a88218b..e51bd13 100644 --- a/api/person/add.php +++ b/api/person/add.php @@ -9,6 +9,7 @@ require_once __DIR__ . '/../common/response.php'; require_once __DIR__ . '/../common/auth.php'; require_once __DIR__ . '/../common/logger.php'; require_once __DIR__ . '/../common/helpers.php'; +require_once __DIR__ . '/../common/completeness.php'; checkAjax(); checkPermission('person'); @@ -55,5 +56,9 @@ if (is_array($experiences)) { savePersonExperiences($pdo, $newId, $experiences); } +// 完整度检查:人员主表 + 其 social_accounts +updateIncomplete($pdo, 'persons', $newId); +updateAccountsIncomplete($pdo, 'person', $newId); + logCurrent('add', 'person', 'persons', $newId, ['data' => $data, 'contacts' => $contacts]); Response::success(['id' => $newId], '新增成功'); diff --git a/api/person/import.php b/api/person/import.php index 67f3c64..d6d11f7 100644 --- a/api/person/import.php +++ b/api/person/import.php @@ -9,6 +9,7 @@ require_once __DIR__ . '/../common/db.php'; require_once __DIR__ . '/../common/response.php'; require_once __DIR__ . '/../common/auth.php'; require_once __DIR__ . '/../common/logger.php'; +require_once __DIR__ . '/../common/completeness.php'; checkAjax(); checkPermission('person'); @@ -72,6 +73,9 @@ while (($row = fgetcsv($handle)) !== false) { if (!empty($rec['email'])) { $insAccount->execute([$newId, 'email', $rec['email']]); } + // 完整度检查(导入每行入库后) + updateIncomplete($pdo, 'persons', $newId); + updateAccountsIncomplete($pdo, 'person', $newId); $inserted++; } catch (Exception $e) { $failed++; diff --git a/api/person/update.php b/api/person/update.php index b1010ee..f197fc1 100644 --- a/api/person/update.php +++ b/api/person/update.php @@ -8,6 +8,7 @@ require_once __DIR__ . '/../common/response.php'; require_once __DIR__ . '/../common/auth.php'; require_once __DIR__ . '/../common/logger.php'; require_once __DIR__ . '/../common/helpers.php'; +require_once __DIR__ . '/../common/completeness.php'; checkAjax(); checkPermission('person'); @@ -63,5 +64,9 @@ if (array_key_exists('experiences', $_POST)) { savePersonExperiences($pdo, $id, $experiences); } +// 完整度检查:人员主表 + 其 social_accounts +updateIncomplete($pdo, 'persons', $id); +updateAccountsIncomplete($pdo, 'person', $id); + logCurrent('update', 'person', 'persons', $id, ['before' => $old, 'after' => $data, 'contacts_replaced' => $contactsChanged]); Response::success(null, '更新成功'); diff --git a/fragment.html b/fragment.html new file mode 100644 index 0000000..508bcf3 --- /dev/null +++ b/fragment.html @@ -0,0 +1,17 @@ + + + + + +碎片处理中心 - SuperLink + + + + + + + + + + + diff --git a/sql/migration_v1.0.27.sql b/sql/migration_v1.0.27.sql new file mode 100644 index 0000000..03916b3 --- /dev/null +++ b/sql/migration_v1.0.27.sql @@ -0,0 +1,18 @@ +-- ============================================================ +-- SuperLink Web - v1.0.27 数据库结构变更 +-- 碎片处理完整方案:软碎片管理(完整度计算与待补全标记) +-- 四张主表增加 is_incomplete(0=完整,1=待补全) +-- ============================================================ +SET NAMES utf8mb4; + +ALTER TABLE `companies` + ADD COLUMN `is_incomplete` TINYINT(1) UNSIGNED DEFAULT 0 COMMENT '是否待补全(0=完整,1=待补全)' AFTER `website`; + +ALTER TABLE `persons` + ADD COLUMN `is_incomplete` TINYINT(1) UNSIGNED DEFAULT 0 COMMENT '是否待补全(0=完整,1=待补全)' AFTER `work_location`; + +ALTER TABLE `social_accounts` + ADD COLUMN `is_incomplete` TINYINT(1) UNSIGNED DEFAULT 0 COMMENT '是否待补全(0=完整,1=待补全)' AFTER `is_active`; + +ALTER TABLE `media_commercial_attributes` + ADD COLUMN `is_incomplete` TINYINT(1) UNSIGNED DEFAULT 0 COMMENT '是否待补全(0=完整,1=待补全)' AFTER `media_remark`; diff --git a/static/css/style.css b/static/css/style.css index 5516f46..abd5967 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -737,6 +737,7 @@ table.grid .ops a:hover { text-decoration: underline; } .tag-green { background: #eafaf1; color: #27ae60; } .tag-red { background: #fdecea; color: #e74c3c; } .tag-orange { background: #fef5e7; color: #f39c12; } +.tag-blue { background: #eaf2fd; color: #2a5298; } .tag-gray { background: #f0f2f6; color: #8a94a6; } /* ---------- 空状态 ---------- */ diff --git a/static/js/config.js b/static/js/config.js index 92bc4ee..674d345 100644 --- a/static/js/config.js +++ b/static/js/config.js @@ -5,7 +5,7 @@ var BASE_URL = '/api/'; var PAGE_SIZE = 20; /** 系统版本号(logo旁展示):修改代码后运行 tools/bump_version.php 自动递增 */ -var APP_VERSION = 'v1.0.26'; +var APP_VERSION = 'v1.0.27'; /** 页脚版权/备案信息(在 config.js 中修改) */ var FOOTER_TEXT = '© 2026 SuperLink 管理系统 版权所有 | 备案号:请替换为真实备案号'; @@ -35,7 +35,7 @@ var ICONS = { /** 菜单标识 -> 权限显示信息(角色权限标签等用) */ var MENU_MAP = { 'dashboard': { name: '综合概览', icon: 'dashboard', url: 'dashboard.html' }, - 'preliminary': { name: '碎片处理', icon: 'preliminary', url: 'preliminary.html' }, + 'preliminary': { name: '碎片处理', icon: 'preliminary', url: 'fragment.html' }, 'company': { name: '企业数据', icon: 'company', url: 'company.html' }, 'person': { name: '人员数据', icon: 'person', url: 'person.html' }, 'need': { name: '需求转盘', icon: 'need', url: 'need.html' }, @@ -58,7 +58,7 @@ var MENU_MAP = { */ var MENU_GROUPS = [ { key: 'dashboard', name: '综合概览', icon: 'dashboard', url: 'dashboard.html' }, - { key: 'preliminary', name: '碎片处理', icon: 'preliminary', url: 'preliminary.html' }, + { key: 'preliminary', name: '碎片处理', icon: 'preliminary', url: 'fragment.html' }, { key: 'data', name: '数据管理', icon: 'data', children: [ { key: 'company', name: '企业数据', icon: 'company', url: 'company.html' }, diff --git a/static/js/fragment.js b/static/js/fragment.js new file mode 100644 index 0000000..3fbbf5c --- /dev/null +++ b/static/js/fragment.js @@ -0,0 +1,346 @@ +/** + * fragment.js - 碎片处理中心(v1.0.27) + * 布局:顶部指标卡 + 【硬碎片】独立区块 + 【软碎片】独立区块(不用 Tab) + * 硬碎片:preliminary_data 中未终结记录(完整度/类型/内容摘要/来源/录入人/时间/状态/操作) + * 软碎片:四张主表 is_incomplete=1 记录(完整度/缺失层级标签/名称/缺失字段/所属表/时间/操作) + */ +$(function () { + renderShell('碎片处理中心', '数据管理 / 碎片处理'); + renderPage(); + initPage('preliminary', function (user) { + if (!checkPagePermission('preliminary')) return; + loadStats(); + loadHardList(1); + loadSoftList(1); + loadDicts(); + }); + + var hardPage = 1, softPage = 1; + var hardRows = [], softRows = []; + + /** 缺失层级标签样式 */ + function levelTagHtml(level) { + if (level === 'core') return '缺核心'; + if (level === 'important') return '缺重要'; + if (level === 'supplementary') return '待补充'; + return '完整'; + } + + /** 完整度进度条 + 百分比 */ + function completenessHtml(score) { + var color = score >= 80 ? '#2f8f46' : (score >= 65 ? '#e6a23c' : '#d9534f'); + return '
' + + '
' + + '
' + + '' + score + '%
'; + } + + /** 渲染页面内容:指标卡 + 硬碎片 + 软碎片 两个独立区块 */ + function renderPage() { + $('#page-content').html( + '
' + + // ================= 硬碎片 ================= + '
' + + '
' + + ' 【硬碎片】' + + ' 关键字段缺失、未入主表的原始线索(不含已转换/已废弃)' + + '
' + + '
' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + '
' + + '
' + + ' ' + + ' ' + + '
完整度类型内容摘要来源录入人录入时间状态操作
' + + ' ' + + '
' + + // ================= 软碎片 ================= + '
' + + '
' + + ' 【软碎片】' + + ' 已入主表但完整度低于阈值的记录(is_incomplete=1)' + + '
' + + '
' + + ' ' + + ' ' + + ' ' + + ' ' + + ' ' + + '
' + + '
' + + ' ' + + ' ' + + '
完整度缺失层级名称缺失字段所属表录入时间操作
' + + ' ' + + '
' + ); + + $('#hard-search').on('click', function () { loadHardList(1); }); + $('#hard-keyword').on('keydown', function (e) { if (e.keyCode === 13) loadHardList(1); }); + $('#hard-reset').on('click', function () { + $('#hard-type').val(''); $('#hard-source').val(''); $('#hard-status').val(''); $('#hard-keyword').val(''); + loadHardList(1); + }); + $('#soft-search').on('click', function () { loadSoftList(1); }); + $('#soft-keyword').on('keydown', function (e) { if (e.keyCode === 13) loadSoftList(1); }); + $('#soft-reset').on('click', function () { + $('#soft-table').val(''); $('#soft-level').val(''); $('#soft-keyword').val(''); + loadSoftList(1); + }); + } + + /** 来源渠道下拉选项 */ + function loadDicts() { + httpGet('common/dicts.php', { type: 'all' }).then(function (d) { + var html = ''; + (d.source_channels || []).forEach(function (c) { + html += ''; + }); + $('#hard-source').html(html); + }).catch(function () {}); + } + + /* ================= 顶部指标卡 ================= */ + function loadStats() { + httpGet('fragment/stats_overview.php').then(function (d) { + $('#stat-cards').html( + '
' + d.hard_total + '
硬碎片总数
' + + '
' + d.soft_total + '
软碎片总数
' + + '
' + d.month_new + '
本月新增
' + + '
' + d.month_processed + '
本月处理
' + + '
' + d.pending + '
待处理碎片
' + ); + }); + } + + /* ================= 硬碎片列表 ================= */ + function loadHardList(page) { + hardPage = page; + var params = { + page: page, + source_type: $('#hard-type').val(), + source_channel: $('#hard-source').val(), + status: $('#hard-status').val(), + keyword: $.trim($('#hard-keyword').val()) + }; + httpGet('fragment/hard_list.php', params).then(function (d) { + hardRows = d.list || []; + var html = ''; + hardRows.forEach(function (r) { + html += '' + + '' + completenessHtml(r.completeness) + '' + + '' + escHtml(r.source_type_label) + '' + + '' + escHtml(r.summary || '-') + '' + + '' + escHtml(r.source_channel || '-') + '' + + '' + escHtml(r.recorded_by || '-') + '' + + '' + fmtDate(r.created_at) + '' + + '' + escHtml(r.status || '-') + '' + + '' + + ' 查看' + + ' 补全录入' + + ' 废弃' + + ''; + }); + if (!hardRows.length) { + html = '暂无硬碎片'; + } + $('#hard-tbody').html(html); + renderPagination($('#hard-pagination'), d, loadHardList); + }); + } + + /* ================= 软碎片列表 ================= */ + function loadSoftList(page) { + softPage = page; + var params = { + page: page, + table: $('#soft-table').val(), + level: $('#soft-level').val(), + keyword: $.trim($('#soft-keyword').val()) + }; + httpGet('fragment/soft_list.php', params).then(function (d) { + softRows = d.list || []; + var html = ''; + softRows.forEach(function (r) { + var missing = (r.missing_fields || []).join('、') || '-'; + html += '' + + '' + completenessHtml(r.score) + '' + + '' + levelTagHtml(r.level) + '' + + '' + escHtml(r.name) + '' + + '' + escHtml(missing) + '' + + '' + escHtml(r.table_label) + '' + + '' + fmtDate(r.created_at) + '' + + '补全'; + }); + if (!softRows.length) { + html = '暂无软碎片'; + } + $('#soft-tbody').html(html); + renderPagination($('#soft-pagination'), d, loadSoftList); + }); + } + + /* ================= 硬碎片:查看 ================= */ + window.viewHard = function (id) { + httpGet('fragment/hard_detail.php', { id: id }).then(function (d) { + var missing = [].concat(d.missing.core || [], d.missing.important || [], d.missing.supplementary || []); + var rowHtml = ''; + var fields = [ + ['类型', d.source_type_label], ['公司名称', d.company_name], ['联系人', d.person_name], + ['联系电话', d.contact_phone], ['联系邮箱', d.contact_email], ['意向品类', d.target_product_category], + ['来源渠道', d.source_channel], ['录入人', d.recorded_by], ['状态', d.status], + ['录入时间', d.created_at], ['处理时间', d.processed_at] + ]; + fields.forEach(function (f) { + rowHtml += '
' + f[0] + '' + + '' + escHtml(f[1] || '-') + '
'; + }); + if (d.description) { + rowHtml += '
原始描述
' + escHtml(d.description) + '
'; + } + Dialog.open({ + title: '硬碎片详情(完整度 ' + d.completeness + '%)', + area: ['560px', 'auto'], + content: '
' + rowHtml + '
', + btn: ['关闭'] + }); + }); + }; + + /* ================= 硬碎片:补全录入(转换) ================= */ + window.convertHard = function (id) { + httpGet('fragment/hard_detail.php', { id: id }).then(function (d) { + var typeSel = ''; + var content = + '
' + + '
' + + '
' + typeSel + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '
'; + + var idx = Dialog.open({ + title: '硬碎片补全录入', + area: ['640px', 'auto'], + content: content, + btn: false + }); + + function syncFields() { + var t = $('#cv-type').val(); + $('#cv-f-company_name').toggle(t !== 'person'); + $('#cv-f-person_name').toggle(t === 'person'); + $('#cv-f-category').toggle(t === 'product' || t === 'need'); + $('#cv-f-industry').toggle(t === 'company'); + $('#cv-f-needcat').toggle(t === 'need'); + $('#cv-f-scenario').toggle(t === 'need'); + } + $('#cv-type').on('change', syncFields); + syncFields(); + $('#cv-cancel').on('click', function () { Dialog.close(idx); }); + + $('#cv-form').on('submit', function (e) { + e.preventDefault(); + var fd = {}; + $(this).find('input, select, textarea').each(function () { + var name = $(this).attr('name'); + if (name) fd[name] = $(this).val(); + }); + fd.id = id; + fd.target_type = $('#cv-type').val(); + httpPost('fragment/hard_convert.php', fd).then(function () { + Dialog.success('转换成功', function () { + Dialog.close(idx); + loadStats(); + loadHardList(hardPage); + loadSoftList(1); + }); + }); + }); + }); + }; + + /* ================= 硬碎片:废弃 ================= */ + window.discardHard = function (id) { + Dialog.confirm('确定废弃该碎片吗?废弃后不可恢复。', function () { + httpPost('fragment/hard_discard.php', { id: id }).then(function () { + Dialog.success('已废弃', function () { + loadStats(); + loadHardList(hardPage); + }); + }); + }); + }; + + /* ================= 软碎片:补全 ================= */ + window.completeSoft = function (table, id) { + httpGet('fragment/soft_detail.php', { table: table, id: id }).then(function (d) { + var allMissing = [].concat(d.missing_core || [], d.missing_important || [], d.missing_supp || []); + var inputs = ''; + allMissing.forEach(function (m) { + var isDate = (m.key === 'established_date'); + inputs += '
' + + '
'; + }); + var content = + '
' + + '
当前完整度 ' + d.score + '%(阈值 ' + d.threshold + '%),补全后自动重新计算。
' + + '
' + inputs + '
' + + '' + + '
'; + + var idx = Dialog.open({ + title: '软碎片补全 - ' + escHtml(d.name) + '(' + d.table_label + ')', + area: ['640px', 'auto'], + content: content, + btn: false + }); + $('#soft-cancel').on('click', function () { Dialog.close(idx); }); + + $('#soft-form').on('submit', function (e) { + e.preventDefault(); + var fd = { table: table, id: id }; + $(this).find('input').each(function () { + var name = $(this).attr('name'); + if (name) fd[name] = $(this).val(); + }); + httpPost('fragment/soft_update.php', fd).then(function (res) { + Dialog.success(res && res.is_complete ? '补全完成' : '已保存(完整度 ' + (res && res.score) + '%)', function () { + Dialog.close(idx); + loadStats(); + loadSoftList(softPage); + loadHardList(hardPage); + }); + }); + }); + }); + }; +});