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_processed + '
本月处理
' +
+ ''
+ );
+ });
+ }
+
+ /* ================= 硬碎片列表 ================= */
+ 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 =
+ '';
+
+ 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 =
+ '';
+
+ 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);
+ });
+ });
+ });
+ });
+ };
+});