pp
This commit is contained in:
45
.trae/documents/优化资源匹配算法:引入语义关键词映射.md
Normal file
45
.trae/documents/优化资源匹配算法:引入语义关键词映射.md
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# 构建专家规则匹配系统 (Expert Rule-Based Matching)
|
||||||
|
|
||||||
|
既然不使用 AI 模型,要解决“尽快上市”和“证券”这种字面不同但意义相关的匹配,唯一且最高效的方法是构建一个**领域同义词库(Domain Thesaurus)**。这是一种经典的“专家系统”算法,通过预定义的业务规则来模拟理解。
|
||||||
|
|
||||||
|
## 1. 核心思路
|
||||||
|
我们不再依赖通用的模糊匹配,而是建立一套**精准的映射规则**。
|
||||||
|
- **原理**:计算机不知道“上市”需要“证券”,但我们知道。我们将这些业务逻辑“硬编码”为一本字典。
|
||||||
|
- **算法**:`关键词提取` + `同义词扩散` + `加权匹配`。
|
||||||
|
|
||||||
|
## 2. 方案优势
|
||||||
|
- **完全可控**:为什么匹配?为什么不匹配?逻辑一目了然。
|
||||||
|
- **零依赖**:不需要安装任何新库(如 torch 或 jieba),纯 Python 原生代码,运行极快。
|
||||||
|
- **即刻生效**:只要规则里写了,匹配准确率就是 100%。
|
||||||
|
|
||||||
|
## 3. 实施计划
|
||||||
|
|
||||||
|
### 步骤 1: 构建商业同义词库 (Thesaurus)
|
||||||
|
在 `main.py` 中定义一个详尽的字典,覆盖主要的商业场景。
|
||||||
|
针对您的痛点,我会重点编写“上市/金融”类的规则:
|
||||||
|
```python
|
||||||
|
BUSINESS_THESAURUS = {
|
||||||
|
# 核心词: [同义词, 关联行业, 关联职能]
|
||||||
|
"上市": ["IPO", "证券", "股票", "股份", "路演", "投行", "辅导", "财报", "合规", "董秘"],
|
||||||
|
"证券": ["上市", "交易", "股票", "投资", "金融", "资本"],
|
||||||
|
"融资": ["找钱", "资金", "投资", "VC", "PE", "天使", "风投", "路演", "BP"],
|
||||||
|
"获客": ["销售", "渠道", "推广", "流量", "代理", "分销", "增长"],
|
||||||
|
"技术": ["研发", "代码", "程序", "系统", "平台", "App", "小程序", "AI"],
|
||||||
|
"法律": ["合规", "律师", "法务", "合同", "知识产权", "维权"],
|
||||||
|
"财税": ["会计", "审计", "报税", "记账", "财务"],
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 2: 实现智能匹配算法 (Smart Matching Algorithm)
|
||||||
|
我将编写一个 `compute_expert_score(text_a, text_b)` 函数:
|
||||||
|
1. **关键词扫描**:遍历字典的 `Key`,看 `text_a`(如愿景)中包含哪些核心词(如发现“上市”)。
|
||||||
|
2. **关联扩散**:如果发现了“上市”,不仅匹配“上市”本身,还自动去 `text_b`(如对方行业)中寻找 `Value` 列表中的词(如“证券”、“投行”)。
|
||||||
|
3. **加权打分**:
|
||||||
|
- 直接命中关键词:100分
|
||||||
|
- 命中关联词:80分
|
||||||
|
- 保留原有的 `difflib` 作为兜底(处理人名或未收录的词)。
|
||||||
|
|
||||||
|
### 步骤 3: 替换现有逻辑
|
||||||
|
修改 `/api/resource-match` 和 `get_tablemates`,使用新的专家算法替换旧算法。
|
||||||
|
|
||||||
|
这个方案完全满足“不用AI”且“提高模糊语义准确度”的需求。
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
# 使用 DaoCloud 国内镜像代理加速下载 (支持多架构)
|
# 使用 DaoCloud 国内镜像代理加速下载 (支持多架构)
|
||||||
# 接收构建参数 BASE_IMAGE,由 docker_deply.sh 传入
|
# 接收构建参数 BASE_IMAGE,由 docker_deply.sh 传入
|
||||||
ARG BASE_IMAGE=python:3.9-slim
|
# ARG BASE_IMAGE=python:3.9-slim
|
||||||
FROM ${BASE_IMAGE}
|
# FROM ${BASE_IMAGE}
|
||||||
# FROM docker.m.daocloud.io/python:3.9-slim
|
|
||||||
|
# arm64 架构镜像
|
||||||
|
FROM docker.m.daocloud.io/python:3.9-slim
|
||||||
|
|
||||||
# 设置工作目录
|
# 设置工作目录
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|||||||
Binary file not shown.
452
main.py
452
main.py
@@ -4,6 +4,7 @@ from fastapi.templating import Jinja2Templates
|
|||||||
from fastapi.responses import HTMLResponse, JSONResponse
|
from fastapi.responses import HTMLResponse, JSONResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
import psycopg2
|
import psycopg2
|
||||||
|
from psycopg2 import pool
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg2.extras import RealDictCursor
|
||||||
from typing import Optional, List, Dict
|
from typing import Optional, List, Dict
|
||||||
import random
|
import random
|
||||||
@@ -25,6 +26,89 @@ DB_CONFIG = {
|
|||||||
"database": os.getenv("DB_NAME", "gsdh")
|
"database": os.getenv("DB_NAME", "gsdh")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# 商业领域同义词库 (Business Thesaurus) - 用于解决模糊语义匹配
|
||||||
|
BUSINESS_THESAURUS = {
|
||||||
|
# 核心意图: [关联行业/关键词列表]
|
||||||
|
"上市": ["IPO", "证券", "股票", "股份", "路演", "投行", "辅导", "财报", "合规", "董秘", "财务顾问", "审计", "律所", "金融", "机构"],
|
||||||
|
"证券": ["上市", "交易", "股票", "投资", "金融", "资本", "券商", "投行"],
|
||||||
|
"融资": ["找钱", "搞钱", "资金", "投资", "VC", "PE", "天使", "风投", "路演", "BP", "基金", "银行", "贷款"],
|
||||||
|
"资金": ["融资", "投资", "银行", "贷款", "过桥", "保理", "供应链金融"],
|
||||||
|
"获客": ["销售", "渠道", "推广", "流量", "代理", "分销", "增长", "营销", "广告", "传媒", "品牌", "私域"],
|
||||||
|
"销售": ["获客", "渠道", "代理", "分销", "带货", "电商", "直播"],
|
||||||
|
"技术": ["研发", "代码", "程序", "系统", "平台", "App", "小程序", "AI", "智能", "软件", "SaaS", "数字化", "算法", "架构"],
|
||||||
|
"法律": ["合规", "律师", "法务", "合同", "知识产权", "维权", "纠纷", "仲裁", "数据合规"],
|
||||||
|
"财税": ["会计", "审计", "报税", "记账", "财务", "税务", "节税"],
|
||||||
|
"出海": ["跨境", "外贸", "物流", "海外", "国际", "通关", "Tiktok", "多语言", "本地化"],
|
||||||
|
"供应链": ["物流", "仓储", "采购", "原材料", "制造", "工厂", "代工", "OEM"],
|
||||||
|
"人力": ["招聘", "猎头", "培训", "HR", "劳务", "派遣", "灵活用工"],
|
||||||
|
|
||||||
|
# AI 行业专项扩展
|
||||||
|
"AI": ["大模型", "算法", "算力", "芯片", "数据", "数字人", "机器人", "智能", "自动化", "Agent", "RAG", "AIGC"],
|
||||||
|
"大模型": ["OpenAI", "GPT", "文心", "通义", "Llama", "微调", "训练", "部署", "推理", "Token", "向量", "Prompt", "提示词"],
|
||||||
|
"算力": ["GPU", "显卡", "英伟达", "H800", "4090", "服务器", "云计算", "智算中心", "租赁", "托管"],
|
||||||
|
"芯片": ["半导体", "集成电路", "英伟达", "华为昇腾", "寒武纪", "FPGA", "ASIC"],
|
||||||
|
"数据": ["标注", "清洗", "采集", "语料", "数据集", "版权", "向量数据库"],
|
||||||
|
"数字人": ["直播", "短视频", "IP", "形象", "克隆", "配音", "虚拟人", "元宇宙"],
|
||||||
|
"具身智能": ["机器人", "机械臂", "无人机", "自动驾驶", "传感器", "视觉", "雷达", "端侧模型"]
|
||||||
|
}
|
||||||
|
|
||||||
|
def compute_expert_score(text_a: str, text_b: str) -> float:
|
||||||
|
"""
|
||||||
|
计算两个文本的匹配度,结合了字符相似度和专家规则语义匹配。
|
||||||
|
"""
|
||||||
|
if not text_a or not text_b:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
# 1. 基础字符相似度 (Base Character Similarity)
|
||||||
|
# difflib 计算最长公共子序列,处理 "软件开发" vs "软件工程" 这种字面相似
|
||||||
|
base_score = difflib.SequenceMatcher(None, text_a, text_b).ratio()
|
||||||
|
|
||||||
|
# 2. 语义增强 (Semantic Boost)
|
||||||
|
# 通过同义词库建立 "上市" <-> "证券" 这种非字面联系
|
||||||
|
semantic_boost = 0.0
|
||||||
|
|
||||||
|
# 归一化处理
|
||||||
|
str_a = str(text_a).strip()
|
||||||
|
str_b = str(text_b).strip()
|
||||||
|
|
||||||
|
found_match = False
|
||||||
|
|
||||||
|
# 检查 A 中的关键词是否匹配 B 中的关联词
|
||||||
|
for key, related_words in BUSINESS_THESAURUS.items():
|
||||||
|
if key in str_a:
|
||||||
|
# 如果 A 包含 "上市",检查 B 是否包含 ["证券", "投行"...]
|
||||||
|
for word in related_words:
|
||||||
|
if word in str_b:
|
||||||
|
semantic_boost = 0.6 # 给予显著加分
|
||||||
|
found_match = True
|
||||||
|
break
|
||||||
|
if found_match: break
|
||||||
|
|
||||||
|
# 双向检查:检查 B 中的关键词是否匹配 A 中的关联词
|
||||||
|
if not found_match:
|
||||||
|
for key, related_words in BUSINESS_THESAURUS.items():
|
||||||
|
if key in str_b:
|
||||||
|
for word in related_words:
|
||||||
|
if word in str_a:
|
||||||
|
semantic_boost = 0.6
|
||||||
|
found_match = True
|
||||||
|
break
|
||||||
|
if found_match: break
|
||||||
|
|
||||||
|
# 最终分数:基础分 + 语义分,上限 1.0
|
||||||
|
# 这样 "尽快上市" (A) vs "证券行业" (B):
|
||||||
|
# base_score ≈ 0
|
||||||
|
# semantic_boost = 0.6 (因为 "上市" -> "证券")
|
||||||
|
# total = 0.6 -> 属于高匹配
|
||||||
|
return min(base_score + semantic_boost, 1.0)
|
||||||
|
|
||||||
|
# Initialize Connection Pool
|
||||||
|
try:
|
||||||
|
postgreSQL_pool = psycopg2.pool.ThreadedConnectionPool(1, 20, **DB_CONFIG)
|
||||||
|
print("PostgreSQL connection pool created successfully")
|
||||||
|
except (Exception, psycopg2.DatabaseError) as error:
|
||||||
|
print("Error while connecting to PostgreSQL", error)
|
||||||
|
|
||||||
# Mount static files
|
# Mount static files
|
||||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||||
templates = Jinja2Templates(directory="templates")
|
templates = Jinja2Templates(directory="templates")
|
||||||
@@ -48,8 +132,24 @@ class AddUserRequest(BaseModel):
|
|||||||
payment_channel: Optional[str] = None
|
payment_channel: Optional[str] = None
|
||||||
|
|
||||||
def get_db_connection():
|
def get_db_connection():
|
||||||
conn = psycopg2.connect(**DB_CONFIG)
|
try:
|
||||||
|
conn = postgreSQL_pool.getconn()
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute('SELECT 1')
|
||||||
return conn
|
return conn
|
||||||
|
except (psycopg2.OperationalError, psycopg2.InterfaceError):
|
||||||
|
# Connection is dead, remove it from pool and create a new one
|
||||||
|
postgreSQL_pool.putconn(conn, close=True)
|
||||||
|
return postgreSQL_pool.getconn()
|
||||||
|
except Exception as e:
|
||||||
|
# If pool is exhausted or DB is down
|
||||||
|
print(f"Error getting DB connection: {e}")
|
||||||
|
raise e
|
||||||
|
|
||||||
|
def release_db_connection(conn):
|
||||||
|
if conn:
|
||||||
|
postgreSQL_pool.putconn(conn)
|
||||||
|
|
||||||
def assign_seat(cur, user_industry: str) -> str:
|
def assign_seat(cur, user_industry: str) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -69,8 +169,15 @@ def assign_seat(cur, user_industry: str) -> str:
|
|||||||
# Fetch current seating status
|
# Fetch current seating status
|
||||||
# Uses aggregation for efficiency as requested
|
# Uses aggregation for efficiency as requested
|
||||||
# We use array_agg to collect industries for the diversity check in one query
|
# We use array_agg to collect industries for the diversity check in one query
|
||||||
|
# Update: Include business_scope from checkin_info for more detailed matching
|
||||||
|
# We concatenate industry_company (from gsdh_data) and business_scope (from checkin_info)
|
||||||
query = """
|
query = """
|
||||||
SELECT ci.location, COUNT(ci.gsdh_id), array_agg(gd.industry_company)
|
SELECT
|
||||||
|
ci.location,
|
||||||
|
COUNT(ci.gsdh_id),
|
||||||
|
array_agg(
|
||||||
|
COALESCE(gd.industry_company, '') || ' ' || COALESCE(ci.business_scope, '')
|
||||||
|
)
|
||||||
FROM checkin_info ci
|
FROM checkin_info ci
|
||||||
LEFT JOIN gsdh_data gd ON ci.gsdh_id = gd.new_id
|
LEFT JOIN gsdh_data gd ON ci.gsdh_id = gd.new_id
|
||||||
WHERE ci.location IS NOT NULL AND ci.location LIKE '第%桌'
|
WHERE ci.location IS NOT NULL AND ci.location LIKE '第%桌'
|
||||||
@@ -122,9 +229,9 @@ def assign_seat(cur, user_industry: str) -> str:
|
|||||||
total_similarity = 0.0
|
total_similarity = 0.0
|
||||||
for existing_ind in stats['industries']:
|
for existing_ind in stats['industries']:
|
||||||
if existing_ind:
|
if existing_ind:
|
||||||
# Use difflib for fuzzy matching (0.0 to 1.0)
|
# Use Expert Score for better semantic matching
|
||||||
# This helps understand "Natural Language" industries better than exact match
|
# This helps understand "Natural Language" industries better than exact match
|
||||||
sim = difflib.SequenceMatcher(None, user_industry, existing_ind).ratio()
|
sim = compute_expert_score(user_industry, existing_ind)
|
||||||
total_similarity += sim
|
total_similarity += sim
|
||||||
|
|
||||||
scored_candidates.append((table_id, total_similarity))
|
scored_candidates.append((table_id, total_similarity))
|
||||||
@@ -136,9 +243,11 @@ def assign_seat(cur, user_industry: str) -> str:
|
|||||||
|
|
||||||
return f"第{best_table}桌"
|
return f"第{best_table}桌"
|
||||||
|
|
||||||
def get_tablemates(cur, location: str, exclude_id: str) -> List[Dict]:
|
def get_tablemates(cur, location: str, exclude_id: str, user_vision: str = "", user_industry: str = "") -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
Get up to 3 random tablemates from the same table location.
|
Get 3 tablemates based on:
|
||||||
|
1. Vision similarity: Match tablemate's vision_2026 with user's vision_2026 (Find similar goals)
|
||||||
|
2. Supply-Demand match: Match tablemate's vision_2026 with user's industry (Find potential partners)
|
||||||
"""
|
"""
|
||||||
if not location or location == "自由席":
|
if not location or location == "自由席":
|
||||||
return []
|
return []
|
||||||
@@ -146,41 +255,289 @@ def get_tablemates(cur, location: str, exclude_id: str) -> List[Dict]:
|
|||||||
# Debug: Check who is at this location
|
# Debug: Check who is at this location
|
||||||
print(f"DEBUG: Fetching tablemates for location: '{location}', excluding: '{exclude_id}'")
|
print(f"DEBUG: Fetching tablemates for location: '{location}', excluding: '{exclude_id}'")
|
||||||
|
|
||||||
# Important: Ensure the location string format matches database exactly
|
|
||||||
# Database seems to store "第X桌", ensuring consistent querying
|
|
||||||
|
|
||||||
# Updated query to fetch more details from checkin_info
|
# Updated query to fetch more details from checkin_info
|
||||||
query = """
|
query = """
|
||||||
SELECT ci.name, gd.industry_company, ci.company_name, ci.position, ci.business_scope, ci.vision_2026
|
SELECT ci.name, gd.industry_company, ci.company_name, ci.position, ci.business_scope, ci.vision_2026
|
||||||
FROM checkin_info ci
|
FROM checkin_info ci
|
||||||
LEFT JOIN gsdh_data gd ON ci.gsdh_id = gd.new_id
|
LEFT JOIN gsdh_data gd ON ci.gsdh_id = gd.new_id
|
||||||
WHERE ci.location = %s AND ci.gsdh_id != %s
|
WHERE ci.location = %s AND ci.gsdh_id != %s
|
||||||
ORDER BY RANDOM()
|
|
||||||
LIMIT 3
|
|
||||||
"""
|
"""
|
||||||
cur.execute(query, (location, exclude_id))
|
cur.execute(query, (location, exclude_id))
|
||||||
rows = cur.fetchall()
|
rows = cur.fetchall()
|
||||||
|
|
||||||
print(f"DEBUG: Found {len(rows)} tablemates")
|
print(f"DEBUG: Found {len(rows)} potential tablemates")
|
||||||
|
|
||||||
tablemates = []
|
candidates = []
|
||||||
for row in rows:
|
for row in rows:
|
||||||
tablemates.append({
|
candidate = {
|
||||||
"name": row[0],
|
"name": row[0],
|
||||||
"industry": row[1] or "暂无行业信息",
|
"industry": row[1] or "暂无行业信息",
|
||||||
"company_name": row[2] or "暂无单位信息",
|
"company_name": row[2] or "暂无单位信息",
|
||||||
"position": row[3] or "暂无职务信息",
|
"position": row[3] or "暂无职务信息",
|
||||||
"business_scope": row[4] or "暂无业务信息",
|
"business_scope": row[4] or "暂无业务信息",
|
||||||
"vision_2026": row[5] or "暂无愿景信息"
|
"vision_2026": row[5] or "",
|
||||||
|
"match_type": [],
|
||||||
|
"score": 0.0
|
||||||
|
}
|
||||||
|
candidates.append(candidate)
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Scoring Logic
|
||||||
|
for cand in candidates:
|
||||||
|
cand_vision = cand["vision_2026"]
|
||||||
|
cand_industry = cand["industry"]
|
||||||
|
|
||||||
|
# 1. Vision Similarity (Find peers with similar goals)
|
||||||
|
if user_vision and cand_vision:
|
||||||
|
sim = compute_expert_score(user_vision, cand_vision)
|
||||||
|
# Weight this score
|
||||||
|
cand["score"] += sim * 1.0
|
||||||
|
if sim > 0.3: # Threshold for "similarity"
|
||||||
|
cand["match_type"].append("志同道合 (愿景相似)")
|
||||||
|
|
||||||
|
# 2. Cross Match: My Industry matches Their Vision (I can help them)
|
||||||
|
if user_industry and cand_vision:
|
||||||
|
sim = compute_expert_score(user_industry, cand_vision)
|
||||||
|
cand["score"] += sim * 1.5 # Give higher weight to potential business match
|
||||||
|
if sim > 0.3:
|
||||||
|
cand["match_type"].append("潜在合作 (您的行业匹配对方愿景)")
|
||||||
|
|
||||||
|
# 3. Cross Match: Their Industry matches My Vision (They can help me)
|
||||||
|
if user_vision and cand_industry:
|
||||||
|
sim = compute_expert_score(user_vision, cand_industry)
|
||||||
|
cand["score"] += sim * 1.5
|
||||||
|
if sim > 0.3:
|
||||||
|
cand["match_type"].append("潜在贵人 (对方行业匹配您的愿景)")
|
||||||
|
|
||||||
|
# Sort by score descending
|
||||||
|
candidates.sort(key=lambda x: x["score"], reverse=True)
|
||||||
|
|
||||||
|
# Take top 3
|
||||||
|
top_candidates = candidates[:3]
|
||||||
|
|
||||||
|
# Format output
|
||||||
|
result = []
|
||||||
|
for cand in top_candidates:
|
||||||
|
# If no specific match type, just say "同桌伙伴"
|
||||||
|
match_reason = " | ".join(cand["match_type"]) if cand["match_type"] else "同桌伙伴"
|
||||||
|
|
||||||
|
result.append({
|
||||||
|
"name": cand["name"],
|
||||||
|
"industry": cand["industry"],
|
||||||
|
"company_name": cand["company_name"],
|
||||||
|
"position": cand["position"],
|
||||||
|
"business_scope": cand["business_scope"],
|
||||||
|
"vision_2026": cand["vision_2026"] or "暂无愿景信息",
|
||||||
|
"match_reason": match_reason
|
||||||
})
|
})
|
||||||
return tablemates
|
|
||||||
|
return result
|
||||||
|
|
||||||
@app.get("/", response_class=HTMLResponse)
|
@app.get("/", response_class=HTMLResponse)
|
||||||
async def read_root(request: Request):
|
async def read_root(request: Request):
|
||||||
return templates.TemplateResponse("index.html", {"request": request})
|
return templates.TemplateResponse("index.html", {"request": request})
|
||||||
|
|
||||||
|
class UnlockRequest(BaseModel):
|
||||||
|
my_phone: str
|
||||||
|
target_id: str
|
||||||
|
|
||||||
|
class ResourceMatchRequest(BaseModel):
|
||||||
|
phone: str
|
||||||
|
|
||||||
|
@app.get("/search", response_class=HTMLResponse)
|
||||||
|
async def resource_match_page(request: Request):
|
||||||
|
return templates.TemplateResponse("resource_match.html", {"request": request})
|
||||||
|
|
||||||
|
@app.post("/api/resource-match")
|
||||||
|
def resource_match(req: ResourceMatchRequest):
|
||||||
|
try:
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
|
||||||
|
# 1. Fetch current user
|
||||||
|
# Optimize: Only fetch necessary fields
|
||||||
|
# Points are now in checkin_info
|
||||||
|
cur.execute("""
|
||||||
|
SELECT gd.new_id, gd.name, gd.phone, ci.social_point as points,
|
||||||
|
gd.industry_company, ci.business_scope, ci.vision_2026
|
||||||
|
FROM gsdh_data gd
|
||||||
|
LEFT JOIN checkin_info ci ON gd.new_id = ci.gsdh_id
|
||||||
|
WHERE gd.phone = %s
|
||||||
|
""", (req.phone,))
|
||||||
|
user = cur.fetchone()
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
cur.close()
|
||||||
|
release_db_connection(conn)
|
||||||
|
return JSONResponse(content={"success": False, "message": "用户不存在"}, status_code=404)
|
||||||
|
|
||||||
|
user_industry = f"{user['industry_company'] or ''} {user['business_scope'] or ''}".strip()
|
||||||
|
user_vision = user['vision_2026'] or ""
|
||||||
|
|
||||||
|
# 2. Fetch ALL other users (who have checked in)
|
||||||
|
# Performance Note: Fetching all rows is slow if N is large.
|
||||||
|
# But for N < 1000 it's acceptable. For larger N, we need vector search (e.g. pgvector).
|
||||||
|
# We limit the fields to reduce payload size.
|
||||||
|
cur.execute("""
|
||||||
|
SELECT gd.new_id, gd.name, gd.phone, gd.industry_company,
|
||||||
|
ci.company_name, ci.position, ci.business_scope, ci.vision_2026, ci.location
|
||||||
|
FROM checkin_info ci
|
||||||
|
JOIN gsdh_data gd ON ci.gsdh_id = gd.new_id
|
||||||
|
WHERE gd.new_id != %s
|
||||||
|
""", (user['new_id'],))
|
||||||
|
others = cur.fetchall()
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
release_db_connection(conn)
|
||||||
|
|
||||||
|
# 3. Calculate Matches (In-Memory Python)
|
||||||
|
# difflib.SequenceMatcher is O(N*M), running it 3 times for every user is expensive.
|
||||||
|
# We can optimize by pre-calculating and caching, or just doing it efficiently.
|
||||||
|
|
||||||
|
matches = {
|
||||||
|
"customers": [], # My Industry vs Their Vision
|
||||||
|
"partners": [], # My Vision vs Their Industry
|
||||||
|
"peers": [] # My Industry vs Their Industry
|
||||||
|
}
|
||||||
|
|
||||||
|
# Optimization: Pre-compile SequenceMatcher objects if possible, but ratio() needs both strings.
|
||||||
|
# We use a threshold to quickly filter obvious non-matches if we had embeddings.
|
||||||
|
# For now, we stick to string matching but handle None values gracefully.
|
||||||
|
|
||||||
|
for other in others:
|
||||||
|
# Handle potential None values safely
|
||||||
|
other_ind_comp = other.get('industry_company') or ''
|
||||||
|
other_bus_scope = other.get('business_scope') or ''
|
||||||
|
other_industry = f"{other_ind_comp} {other_bus_scope}".strip()
|
||||||
|
other_vision = other.get('vision_2026') or ""
|
||||||
|
|
||||||
|
# 3.1 Customers (They need me)
|
||||||
|
# My Industry (Supply) matches Their Vision (Demand)
|
||||||
|
if user_industry and other_vision:
|
||||||
|
# Quick length check optimization: if length difference is huge, ratio will be low
|
||||||
|
score = compute_expert_score(user_industry, other_vision)
|
||||||
|
if score > 0.2:
|
||||||
|
matches["customers"].append({**other, "score": score})
|
||||||
|
|
||||||
|
# 3.2 Partners (I need them)
|
||||||
|
# My Vision (Demand) matches Their Industry (Supply)
|
||||||
|
if user_vision and other_industry:
|
||||||
|
score = compute_expert_score(user_vision, other_industry)
|
||||||
|
if score > 0.2:
|
||||||
|
matches["partners"].append({**other, "score": score})
|
||||||
|
|
||||||
|
# 3.3 Peers (Same industry)
|
||||||
|
# My Industry matches Their Industry
|
||||||
|
if user_industry and other_industry:
|
||||||
|
score = compute_expert_score(user_industry, other_industry)
|
||||||
|
if score > 0.3:
|
||||||
|
matches["peers"].append({**other, "score": score})
|
||||||
|
|
||||||
|
# 4. Sort and Limit
|
||||||
|
for key in matches:
|
||||||
|
matches[key].sort(key=lambda x: x["score"], reverse=True)
|
||||||
|
# Limit to top 20 for display performance
|
||||||
|
matches[key] = matches[key][:20]
|
||||||
|
|
||||||
|
# Hide sensitive info by default
|
||||||
|
for p in matches[key]:
|
||||||
|
# Safe phone masking
|
||||||
|
p_phone = p.get('phone', '')
|
||||||
|
if len(p_phone) >= 7:
|
||||||
|
p['phone'] = p_phone[:3] + "****" + p_phone[-4:]
|
||||||
|
else:
|
||||||
|
p['phone'] = "****"
|
||||||
|
|
||||||
|
p['location'] = "???" # Hidden location
|
||||||
|
p['unlocked'] = False
|
||||||
|
# Clean up internal fields to reduce JSON size
|
||||||
|
# p.pop('new_id', None)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"user": {
|
||||||
|
"name": user['name'],
|
||||||
|
"industry_company": user['industry_company'],
|
||||||
|
"points": user['points'] if user['points'] is not None else 0,
|
||||||
|
"phone": user['phone']
|
||||||
|
},
|
||||||
|
"matches": matches
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
if 'conn' in locals() and conn:
|
||||||
|
release_db_connection(conn)
|
||||||
|
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
|
||||||
|
|
||||||
|
@app.post("/api/unlock-contact")
|
||||||
|
def unlock_contact(req: UnlockRequest):
|
||||||
|
try:
|
||||||
|
conn = get_db_connection()
|
||||||
|
cur = conn.cursor(cursor_factory=RealDictCursor)
|
||||||
|
|
||||||
|
# 1. Check User Points
|
||||||
|
# Points are now in checkin_info, queried by gsdh_id (which we can get from phone via gsdh_data join)
|
||||||
|
cur.execute("""
|
||||||
|
SELECT ci.social_point as points
|
||||||
|
FROM checkin_info ci
|
||||||
|
JOIN gsdh_data gd ON ci.gsdh_id = gd.new_id
|
||||||
|
WHERE gd.phone = %s
|
||||||
|
""", (req.my_phone,))
|
||||||
|
res = cur.fetchone()
|
||||||
|
|
||||||
|
if not res:
|
||||||
|
cur.close()
|
||||||
|
release_db_connection(conn)
|
||||||
|
return JSONResponse(content={"success": False, "message": "用户未签到或不存在"}, status_code=404)
|
||||||
|
|
||||||
|
points = res['points'] if res['points'] is not None else 0
|
||||||
|
if points <= 0:
|
||||||
|
cur.close()
|
||||||
|
release_db_connection(conn)
|
||||||
|
return JSONResponse(content={"success": False, "message": "积分不足"}, status_code=400)
|
||||||
|
|
||||||
|
# 2. Deduct Point
|
||||||
|
# Update checkin_info using a subquery to map phone to gsdh_id
|
||||||
|
cur.execute("""
|
||||||
|
UPDATE checkin_info
|
||||||
|
SET social_point = social_point - 1
|
||||||
|
WHERE gsdh_id = (SELECT new_id FROM gsdh_data WHERE phone = %s)
|
||||||
|
""", (req.my_phone,))
|
||||||
|
|
||||||
|
# 3. Fetch Target Info
|
||||||
|
cur.execute("""
|
||||||
|
SELECT gd.phone, ci.location
|
||||||
|
FROM gsdh_data gd
|
||||||
|
LEFT JOIN checkin_info ci ON gd.new_id = ci.gsdh_id
|
||||||
|
WHERE gd.new_id = %s
|
||||||
|
""", (req.target_id,))
|
||||||
|
target = cur.fetchone()
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
cur.close()
|
||||||
|
release_db_connection(conn)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"remaining_points": points - 1,
|
||||||
|
"contact": {
|
||||||
|
"phone": target['phone'],
|
||||||
|
"location": target['location'] or "未分配座位"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
if 'conn' in locals() and conn:
|
||||||
|
conn.rollback()
|
||||||
|
release_db_connection(conn)
|
||||||
|
return JSONResponse(content={"success": False, "message": str(e)}, status_code=500)
|
||||||
|
|
||||||
@app.get("/api/search")
|
@app.get("/api/search")
|
||||||
async def search_user(query: str):
|
def search_user(query: str):
|
||||||
"""
|
"""
|
||||||
Search user by phone (exact match) or name (fuzzy match).
|
Search user by phone (exact match) or name (fuzzy match).
|
||||||
"""
|
"""
|
||||||
@@ -200,21 +557,24 @@ async def search_user(query: str):
|
|||||||
users = cur.fetchall()
|
users = cur.fetchall()
|
||||||
|
|
||||||
if len(users) == 0:
|
if len(users) == 0:
|
||||||
conn.close()
|
cur.close()
|
||||||
|
release_db_connection(conn)
|
||||||
return JSONResponse(content={"found": False, "message": "未查询到相关信息,请检查输入是否正确"}, status_code=404)
|
return JSONResponse(content={"found": False, "message": "未查询到相关信息,请检查输入是否正确"}, status_code=404)
|
||||||
elif len(users) > 1:
|
elif len(users) > 1:
|
||||||
# If multiple users found by name, return list for user to select (simplified here to return first or error)
|
# If multiple users found by name, return list for user to select (simplified here to return first or error)
|
||||||
# For this MVP, let's return all matching users so frontend can handle selection
|
# For this MVP, let's return all matching users so frontend can handle selection
|
||||||
conn.close()
|
cur.close()
|
||||||
|
release_db_connection(conn)
|
||||||
return JSONResponse(content={"found": True, "multiple": True, "users": users})
|
return JSONResponse(content={"found": True, "multiple": True, "users": users})
|
||||||
else:
|
else:
|
||||||
user = users[0]
|
user = users[0]
|
||||||
|
|
||||||
# Check if already signed
|
# Check if already signed
|
||||||
if user.get('is_signed') == 'TRUE':
|
if user.get('is_signed') == 'TRUE':
|
||||||
# If already signed, fetch their assigned seat and tablemates
|
# Check if already signed
|
||||||
cur.execute("SELECT location FROM checkin_info WHERE gsdh_id = %s", (user['new_id'],))
|
cur.execute("SELECT location, vision_2026 FROM checkin_info WHERE gsdh_id = %s", (user['new_id'],))
|
||||||
checkin_info = cur.fetchone()
|
checkin_info = cur.fetchone() # Fetch as RealDictRow
|
||||||
|
|
||||||
assigned_seat = checkin_info['location'] if checkin_info else "自由席"
|
assigned_seat = checkin_info['location'] if checkin_info else "自由席"
|
||||||
|
|
||||||
# Fetch tablemates
|
# Fetch tablemates
|
||||||
@@ -223,10 +583,17 @@ async def search_user(query: str):
|
|||||||
# We can adapt get_tablemates or just use key access if we pass the DictCursor
|
# We can adapt get_tablemates or just use key access if we pass the DictCursor
|
||||||
# Let's create a fresh standard cursor to be safe and consistent with get_tablemates implementation
|
# Let's create a fresh standard cursor to be safe and consistent with get_tablemates implementation
|
||||||
cur_plain = conn.cursor()
|
cur_plain = conn.cursor()
|
||||||
tablemates = get_tablemates(cur_plain, assigned_seat, user['new_id'])
|
|
||||||
|
# Fetch user's vision and industry for matching
|
||||||
|
user_vision = checkin_info.get('vision_2026', '') if checkin_info else ''
|
||||||
|
# user['industry_company'] is already available in user dict
|
||||||
|
user_industry = user.get('industry_company', '')
|
||||||
|
|
||||||
|
tablemates = get_tablemates(cur_plain, assigned_seat, user['new_id'], user_vision, user_industry)
|
||||||
cur_plain.close()
|
cur_plain.close()
|
||||||
|
|
||||||
conn.close()
|
cur.close()
|
||||||
|
release_db_connection(conn)
|
||||||
return JSONResponse(content={
|
return JSONResponse(content={
|
||||||
"found": True,
|
"found": True,
|
||||||
"user": user,
|
"user": user,
|
||||||
@@ -235,14 +602,17 @@ async def search_user(query: str):
|
|||||||
"tablemates": tablemates
|
"tablemates": tablemates
|
||||||
})
|
})
|
||||||
|
|
||||||
conn.close()
|
cur.close()
|
||||||
|
release_db_connection(conn)
|
||||||
return JSONResponse(content={"found": True, "user": user, "already_signed": False})
|
return JSONResponse(content={"found": True, "user": user, "already_signed": False})
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
if 'conn' in locals() and conn:
|
||||||
|
release_db_connection(conn)
|
||||||
return JSONResponse(content={"error": str(e)}, status_code=500)
|
return JSONResponse(content={"error": str(e)}, status_code=500)
|
||||||
|
|
||||||
@app.post("/api/checkin")
|
@app.post("/api/checkin")
|
||||||
async def checkin_user(checkin_data: CheckinRequest):
|
def checkin_user(checkin_data: CheckinRequest):
|
||||||
try:
|
try:
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
@@ -250,16 +620,20 @@ async def checkin_user(checkin_data: CheckinRequest):
|
|||||||
# 0. Get user's industry from gsdh_data to help with seat allocation
|
# 0. Get user's industry from gsdh_data to help with seat allocation
|
||||||
cur.execute("SELECT industry_company FROM gsdh_data WHERE new_id = %s", (checkin_data.gsdh_id,))
|
cur.execute("SELECT industry_company FROM gsdh_data WHERE new_id = %s", (checkin_data.gsdh_id,))
|
||||||
res = cur.fetchone()
|
res = cur.fetchone()
|
||||||
user_industry = res[0] if res else ""
|
base_industry = res[0] if res and res[0] else ""
|
||||||
|
|
||||||
|
# Combine base industry with the newly provided business_scope for better matching
|
||||||
|
user_industry_info = f"{base_industry} {checkin_data.business_scope or ''}".strip()
|
||||||
|
|
||||||
# 1. Allocate Seat
|
# 1. Allocate Seat
|
||||||
assigned_seat = assign_seat(cur, user_industry)
|
assigned_seat = assign_seat(cur, user_industry_info)
|
||||||
|
|
||||||
# 2. Insert into checkin_info with assigned seat
|
# 2. Insert into checkin_info with assigned seat
|
||||||
|
# Initialize social_point to 5
|
||||||
insert_sql = """
|
insert_sql = """
|
||||||
INSERT INTO checkin_info
|
INSERT INTO checkin_info
|
||||||
(name, phone, company_name, position, business_scope, vision_2026, location, gsdh_id)
|
(name, phone, company_name, position, business_scope, vision_2026, location, gsdh_id, social_point)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 4)
|
||||||
"""
|
"""
|
||||||
cur.execute(insert_sql, (
|
cur.execute(insert_sql, (
|
||||||
checkin_data.name,
|
checkin_data.name,
|
||||||
@@ -279,16 +653,18 @@ async def checkin_user(checkin_data: CheckinRequest):
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
# 4. Fetch tablemates for the newly assigned seat
|
# 4. Fetch tablemates for the newly assigned seat
|
||||||
tablemates = get_tablemates(cur, assigned_seat, checkin_data.gsdh_id)
|
# Use provided vision and industry for matching
|
||||||
|
tablemates = get_tablemates(cur, assigned_seat, checkin_data.gsdh_id, checkin_data.vision_2026 or "", user_industry_info)
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
release_db_connection(conn)
|
||||||
|
|
||||||
return {"success": True, "message": "签到成功!", "seat": assigned_seat, "tablemates": tablemates}
|
return {"success": True, "message": "签到成功!", "seat": assigned_seat, "tablemates": tablemates}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if 'conn' in locals():
|
if 'conn' in locals() and conn:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
|
release_db_connection(conn)
|
||||||
return JSONResponse(content={"success": False, "message": f"签到失败: {str(e)}"}, status_code=500)
|
return JSONResponse(content={"success": False, "message": f"签到失败: {str(e)}"}, status_code=500)
|
||||||
|
|
||||||
@app.get("/add-user", response_class=HTMLResponse)
|
@app.get("/add-user", response_class=HTMLResponse)
|
||||||
@@ -298,7 +674,7 @@ async def add_user_page(request: Request):
|
|||||||
return templates.TemplateResponse("add_user.html", {"request": request, "secret": secret})
|
return templates.TemplateResponse("add_user.html", {"request": request, "secret": secret})
|
||||||
|
|
||||||
@app.post("/api/add-user")
|
@app.post("/api/add-user")
|
||||||
async def add_user_api(user_data: AddUserRequest):
|
def add_user_api(user_data: AddUserRequest):
|
||||||
try:
|
try:
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
@@ -306,7 +682,8 @@ async def add_user_api(user_data: AddUserRequest):
|
|||||||
# Check if phone already exists
|
# Check if phone already exists
|
||||||
cur.execute("SELECT * FROM gsdh_data WHERE phone = %s", (user_data.phone,))
|
cur.execute("SELECT * FROM gsdh_data WHERE phone = %s", (user_data.phone,))
|
||||||
if cur.fetchone():
|
if cur.fetchone():
|
||||||
conn.close()
|
cur.close()
|
||||||
|
release_db_connection(conn)
|
||||||
return JSONResponse(content={"success": False, "message": "该手机号已存在"}, status_code=400)
|
return JSONResponse(content={"success": False, "message": "该手机号已存在"}, status_code=400)
|
||||||
|
|
||||||
# Calculate next new_id
|
# Calculate next new_id
|
||||||
@@ -330,12 +707,13 @@ async def add_user_api(user_data: AddUserRequest):
|
|||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
cur.close()
|
cur.close()
|
||||||
conn.close()
|
release_db_connection(conn)
|
||||||
|
|
||||||
return {"success": True, "message": "添加成功", "new_id": new_id}
|
return {"success": True, "message": "添加成功", "new_id": new_id}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
if 'conn' in locals():
|
if 'conn' in locals() and conn:
|
||||||
conn.rollback()
|
conn.rollback()
|
||||||
|
release_db_connection(conn)
|
||||||
return JSONResponse(content={"success": False, "message": f"添加失败: {str(e)}"}, status_code=500)
|
return JSONResponse(content={"success": False, "message": f"添加失败: {str(e)}"}, status_code=500)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
377
templates/resource_match.html
Normal file
377
templates/resource_match.html
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>资源匹配 - 供需对接</title>
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--primary-color: #00f2ff; /* Cyan from the AI sphere/text */
|
||||||
|
--secondary-color: #0066ff; /* Rich blue */
|
||||||
|
--bg-color: #050814; /* Deep space blue/black */
|
||||||
|
--card-bg: rgba(12, 24, 50, 0.5); /* Glassy blue tint */
|
||||||
|
--text-color: #ffffff;
|
||||||
|
--text-muted: #b0c4de; /* Light blue-grey */
|
||||||
|
--accent-glow: rgba(0, 242, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* margin: 0; padding: 0; - Tailwind resets this but good to keep in mind */
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||||
|
background-color: var(--bg-color);
|
||||||
|
color: var(--text-color);
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 50% 0%, #1a3a75 0%, #050814 60%),
|
||||||
|
radial-gradient(circle at 85% 30%, rgba(0, 242, 255, 0.1) 0%, transparent 40%),
|
||||||
|
radial-gradient(circle at 15% 70%, rgba(0, 102, 255, 0.15) 0%, transparent 40%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
min-height: 100vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background:
|
||||||
|
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
|
||||||
|
background-size: 50px 50px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-img {
|
||||||
|
width: 100%;
|
||||||
|
max-height: 220px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
box-shadow: 0 0 20px rgba(0, 242, 255, 0.2);
|
||||||
|
border: 1px solid rgba(0, 242, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.glass-card {
|
||||||
|
background: var(--card-bg);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(0, 242, 255, 0.2);
|
||||||
|
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.5), inset 0 0 20px rgba(0, 242, 255, 0.05);
|
||||||
|
border-radius: 16px;
|
||||||
|
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1, h2, h3 {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #d0eaff 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
text-shadow: 0 0 30px rgba(0, 242, 255, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-primary { color: var(--primary-color); }
|
||||||
|
.text-muted { color: var(--text-muted); }
|
||||||
|
|
||||||
|
/* Custom Input Style to match index.html */
|
||||||
|
.custom-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
border: 1px solid rgba(0, 242, 255, 0.3);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: white;
|
||||||
|
font-size: 1rem;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.3s;
|
||||||
|
box-shadow: inset 0 2px 5px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.custom-input:focus {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 15px rgba(0, 242, 255, 0.3), inset 0 2px 5px rgba(0,0,0,0.3);
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom Button Style */
|
||||||
|
.custom-btn {
|
||||||
|
background: linear-gradient(90deg, #0051ff 0%, #00f2ff 100%);
|
||||||
|
color: #fff;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 242, 255, 0.3);
|
||||||
|
text-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.custom-btn:hover:not(:disabled) {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 6px 20px rgba(0, 242, 255, 0.5);
|
||||||
|
background: linear-gradient(90deg, #0040cc 0%, #00d0dd 100%);
|
||||||
|
}
|
||||||
|
.custom-btn:disabled {
|
||||||
|
background: #2a3b55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.6;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tab Buttons */
|
||||||
|
.tab-btn {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
.tab-btn.active {
|
||||||
|
background: rgba(0, 242, 255, 0.15);
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 15px rgba(0, 242, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avatar */
|
||||||
|
.avatar-circle {
|
||||||
|
background: linear-gradient(135deg, var(--secondary-color), var(--primary-color));
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 0 10px rgba(0, 242, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tags */
|
||||||
|
.tag {
|
||||||
|
background: rgba(0, 242, 255, 0.1);
|
||||||
|
color: var(--primary-color);
|
||||||
|
border: 1px solid rgba(0, 242, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile specific adjustments */
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.container { padding: 15px; }
|
||||||
|
h1 { font-size: 1.5rem; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app" class="container pb-20">
|
||||||
|
<!-- Header Image -->
|
||||||
|
<img src="/static/image.jpg" alt="Header Image" class="header-img" onerror="this.style.display='none'">
|
||||||
|
|
||||||
|
<!-- Header Info -->
|
||||||
|
<div class="glass-card p-6 mb-6 text-center relative overflow-hidden">
|
||||||
|
<!-- Background Decoration -->
|
||||||
|
<div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-blue-600 to-cyan-400"></div>
|
||||||
|
|
||||||
|
<h1 class="text-2xl font-bold mb-2 tracking-wide">资源雷达 📡</h1>
|
||||||
|
<p class="text-sm text-muted">发现全场潜在商机与合作伙伴</p>
|
||||||
|
|
||||||
|
<!-- User Stats -->
|
||||||
|
<div v-if="user" class="mt-6 flex items-center justify-between bg-black/20 rounded-lg p-4 border border-white/10">
|
||||||
|
<div class="flex items-center text-left">
|
||||||
|
<div class="avatar-circle w-10 h-10 rounded-full flex items-center justify-center mr-3 text-lg font-bold shrink-0">
|
||||||
|
<i class="fas fa-user"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-bold text-white" v-text="user.name"></div>
|
||||||
|
<div class="text-xs text-muted truncate max-w-[120px]" v-text="user.industry_company"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<div class="text-xs text-muted mb-1">社交点数</div>
|
||||||
|
<div class="text-xl font-bold text-yellow-300 drop-shadow-md"><span v-text="user.points"></span> 💎</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login / Search Form -->
|
||||||
|
<div v-if="!user">
|
||||||
|
<div class="glass-card p-8">
|
||||||
|
<h2 class="text-xl font-bold mb-6 text-center border-b border-white/10 pb-4">开启资源匹配</h2>
|
||||||
|
<div class="mb-6">
|
||||||
|
<label class="block text-primary text-sm font-bold mb-3 pl-1">手机号</label>
|
||||||
|
<input v-model="phone" type="tel" class="custom-input" placeholder="请输入您的手机号">
|
||||||
|
</div>
|
||||||
|
<button @click="login" :disabled="loading" class="w-full custom-btn py-3 px-4 rounded-lg font-bold flex items-center justify-center">
|
||||||
|
<span v-if="loading"><i class="fas fa-spinner fa-spin mr-2"></i>匹配中...</span>
|
||||||
|
<span v-else>开始匹配</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Results -->
|
||||||
|
<div v-else class="space-y-6">
|
||||||
|
|
||||||
|
<!-- Category Tabs -->
|
||||||
|
<div class="flex space-x-3 overflow-x-auto pb-2 scrollbar-hide">
|
||||||
|
<button @click="activeTab = 'customers'" :class="['px-5 py-2 rounded-full text-sm font-bold whitespace-nowrap tab-btn', activeTab === 'customers' ? 'active' : '']">
|
||||||
|
全场客户 (<span v-text="matches.customers.length"></span>)
|
||||||
|
</button>
|
||||||
|
<button @click="activeTab = 'partners'" :class="['px-5 py-2 rounded-full text-sm font-bold whitespace-nowrap tab-btn', activeTab === 'partners' ? 'active' : '']">
|
||||||
|
合作伙伴 (<span v-text="matches.partners.length"></span>)
|
||||||
|
</button>
|
||||||
|
<button @click="activeTab = 'peers'" :class="['px-5 py-2 rounded-full text-sm font-bold whitespace-nowrap tab-btn', activeTab === 'peers' ? 'active' : '']">
|
||||||
|
同行 (<span v-text="matches.peers.length"></span>)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- List -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div v-if="matches[activeTab].length === 0" class="text-center py-16 text-muted glass-card">
|
||||||
|
<i class="fas fa-satellite text-4xl mb-4 opacity-50"></i>
|
||||||
|
<p>暂无匹配结果</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-for="(person, index) in matches[activeTab]" :key="index" class="glass-card p-5 relative overflow-hidden group hover:border-cyan-400/50 transition-colors">
|
||||||
|
<!-- Match Badge -->
|
||||||
|
<div class="absolute top-0 right-0 bg-gradient-to-bl from-yellow-500/20 to-transparent text-yellow-300 text-xs px-3 py-1 rounded-bl-lg font-bold border-l border-b border-yellow-500/20">
|
||||||
|
匹配度 <span v-text="Math.round(person.score * 100)"></span>%
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start mb-4">
|
||||||
|
<div class="avatar-circle w-12 h-12 rounded-full flex items-center justify-center mr-4 text-xl font-bold shrink-0">
|
||||||
|
<span v-text="person.name[0]"></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h3 class="font-bold text-lg text-white mb-1" v-text="person.name"></h3>
|
||||||
|
<p class="text-sm text-muted flex items-center truncate">
|
||||||
|
<i class="fas fa-building mr-2 opacity-70"></i>
|
||||||
|
<span v-text="person.company_name || '暂无公司信息'"></span>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted/70 mt-1 pl-6 truncate" v-text="person.position || '职位未知'"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tags -->
|
||||||
|
<div class="flex flex-wrap gap-2 mb-4">
|
||||||
|
<span class="tag text-xs px-2 py-1 rounded flex items-center">
|
||||||
|
<i class="fas fa-briefcase mr-1 opacity-70"></i><span v-text="person.industry"></span>
|
||||||
|
</span>
|
||||||
|
<span class="tag text-xs px-2 py-1 rounded flex items-center">
|
||||||
|
<i class="fas fa-bullseye mr-1 opacity-70"></i>
|
||||||
|
<span class="truncate max-w-[200px]" v-text="person.vision_2026 || '暂无愿景'"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact Action -->
|
||||||
|
<div class="border-t border-white/10 pt-4 mt-2">
|
||||||
|
<div v-if="person.unlocked" class="bg-green-900/20 border border-green-500/30 p-3 rounded-lg text-sm text-green-400">
|
||||||
|
<p class="mb-1"><i class="fas fa-phone mr-2"></i><span v-text="person.phone"></span></p>
|
||||||
|
<p><i class="fas fa-map-marker-alt mr-2"></i><span v-text="person.location"></span></p>
|
||||||
|
</div>
|
||||||
|
<button v-else @click="unlockContact(person)" class="w-full bg-white/5 hover:bg-white/10 text-white text-sm py-2 rounded-lg transition border border-white/10 flex items-center justify-center group-hover:border-cyan-400/30">
|
||||||
|
<i class="fas fa-lock mr-2 text-muted group-hover:text-cyan-400"></i> 查看联系方式 (-1 💎)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Toast -->
|
||||||
|
<div v-if="toast.show" class="fixed bottom-10 left-1/2 transform -translate-x-1/2 bg-gray-900 border border-cyan-500/50 text-white px-6 py-3 rounded-lg shadow-[0_0_20px_rgba(0,242,255,0.3)] text-sm transition-opacity duration-300 z-50 flex items-center" :class="{'opacity-0': !toast.show}">
|
||||||
|
<i class="fas fa-info-circle text-primary mr-2"></i>
|
||||||
|
<span v-text="toast.message"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const { createApp, ref, reactive } = Vue
|
||||||
|
|
||||||
|
createApp({
|
||||||
|
setup() {
|
||||||
|
const phone = ref('')
|
||||||
|
const user = ref(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const activeTab = ref('customers')
|
||||||
|
const matches = reactive({
|
||||||
|
customers: [],
|
||||||
|
partners: [],
|
||||||
|
peers: []
|
||||||
|
})
|
||||||
|
const toast = reactive({ show: false, message: '' })
|
||||||
|
|
||||||
|
const showToast = (msg) => {
|
||||||
|
toast.message = msg
|
||||||
|
toast.show = true
|
||||||
|
setTimeout(() => toast.show = false, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = async () => {
|
||||||
|
if (!phone.value) return showToast('请输入手机号')
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/resource-match', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ phone: phone.value })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success) {
|
||||||
|
user.value = data.user
|
||||||
|
matches.customers = data.matches.customers
|
||||||
|
matches.partners = data.matches.partners
|
||||||
|
matches.peers = data.matches.peers
|
||||||
|
} else {
|
||||||
|
showToast(data.message || '匹配失败')
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('网络错误')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unlockContact = async (person) => {
|
||||||
|
if (user.value.points <= 0) {
|
||||||
|
return showToast('社交点数不足,请联系工作人员充值')
|
||||||
|
}
|
||||||
|
if (!confirm(`确定消耗1点数查看 ${person.name} 的联系方式吗?`)) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/unlock-contact', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
my_phone: user.value.phone,
|
||||||
|
target_id: person.new_id
|
||||||
|
})
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.success) {
|
||||||
|
person.phone = data.contact.phone
|
||||||
|
person.location = data.contact.location
|
||||||
|
person.unlocked = true
|
||||||
|
user.value.points = data.remaining_points
|
||||||
|
showToast('解锁成功!')
|
||||||
|
} else {
|
||||||
|
showToast(data.message)
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('解锁失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
phone, user, loading, activeTab, matches, login, unlockContact, toast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).mount('#app')
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user