Compare commits
10 Commits
f7d0601c4e
...
f5b706d892
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5b706d892 | ||
|
|
7f2036fbb2 | ||
|
|
38cb9345d6 | ||
|
|
2dcd7ce9f6 | ||
|
|
cedfd066c4 | ||
|
|
84f0943578 | ||
|
|
8efd86968f | ||
|
|
385ebe25e7 | ||
|
|
6d62120443 | ||
|
|
5279816452 |
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/backend/venv/
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
68
backend/app/ml/spam_categorizer.py
Normal file
68
backend/app/ml/spam_categorizer.py
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
"""垃圾信息分类标签模块
|
||||||
|
|
||||||
|
在朴素贝叶斯二分类(spam/ham)基础上,对判定为 spam 的文本进行细分类标签。
|
||||||
|
分类优先级:诈骗 > 骚扰 > 广告(按危害程度排序)
|
||||||
|
"""
|
||||||
|
|
||||||
|
CATEGORY_KEYWORDS = {
|
||||||
|
"fraud": [
|
||||||
|
"中奖", "幸运粉丝", "幸运用户", "银行卡异常", "社保异常", "账号冻结",
|
||||||
|
"解封", "立即验证", "验证码", "欠费停机", "退款待确认", "违章信息",
|
||||||
|
"紧急通知", "账户异常", "风险", "核验", "被冻结", "将被冻结",
|
||||||
|
],
|
||||||
|
"harassment": [
|
||||||
|
"兼职", "日结", "高薪", "刷单", "赚钱", "外快", "宝妈", "学生都能做",
|
||||||
|
"添加微信", "扫码进群", "进群立刻", "想赚", "零花钱", "在家办公",
|
||||||
|
"无需面试", "火热招募", "秒赚", "招募",
|
||||||
|
],
|
||||||
|
"advertisement": [
|
||||||
|
"领取", "优惠", "红包", "优惠券", "秒杀", "返现", "补贴", "会员",
|
||||||
|
"特价", "低价", "点击链接", "扫码", "免费领取", "无门槛", "现金券",
|
||||||
|
"盲盒", "百分百中奖", "隐藏优惠券", "内部价", "货到付款", "限时",
|
||||||
|
"最后", "名额", "先到先得",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
CATEGORY_LABELS = {
|
||||||
|
"fraud": "疑似诈骗",
|
||||||
|
"harassment": "疑似骚扰",
|
||||||
|
"advertisement": "疑似广告",
|
||||||
|
"spam": "疑似垃圾",
|
||||||
|
"ham": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
CATEGORY_PRIORITY = ["fraud", "harassment", "advertisement"]
|
||||||
|
|
||||||
|
|
||||||
|
def categorize_spam(text: str) -> tuple[str, str]:
|
||||||
|
"""根据关键词匹配判定垃圾信息的具体分类标签
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: 待分类的文本内容
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
tuple[str, str]: (category_code, category_label)
|
||||||
|
- category_code: fraud | harassment | advertisement | spam
|
||||||
|
- category_label: 疑似诈骗 | 疑似骚扰 | 疑似广告 | 疑似垃圾
|
||||||
|
"""
|
||||||
|
text_lower = text.lower()
|
||||||
|
|
||||||
|
for category in CATEGORY_PRIORITY:
|
||||||
|
keywords = CATEGORY_KEYWORDS.get(category, [])
|
||||||
|
for kw in keywords:
|
||||||
|
if kw.lower() in text_lower:
|
||||||
|
return category, CATEGORY_LABELS[category]
|
||||||
|
|
||||||
|
return "spam", CATEGORY_LABELS["spam"]
|
||||||
|
|
||||||
|
|
||||||
|
def get_category_label(category: str) -> str:
|
||||||
|
"""获取分类标签的中文显示文本
|
||||||
|
|
||||||
|
Args:
|
||||||
|
category: 分类代码
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
str: 中文标签文本
|
||||||
|
"""
|
||||||
|
return CATEGORY_LABELS.get(category, "")
|
||||||
@@ -16,6 +16,7 @@ class User(db.Model):
|
|||||||
title = db.Column(db.String(64), default="")
|
title = db.Column(db.String(64), default="")
|
||||||
phone = db.Column(db.String(32), default="")
|
phone = db.Column(db.String(32), default="")
|
||||||
is_admin = db.Column(db.Boolean, default=False)
|
is_admin = db.Column(db.Boolean, default=False)
|
||||||
|
credit_score = db.Column(db.Integer, default=100) # 信誉分 0-200,默认100
|
||||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||||
|
|
||||||
@@ -40,6 +41,7 @@ class User(db.Model):
|
|||||||
"title": self.title,
|
"title": self.title,
|
||||||
"phone": self.phone,
|
"phone": self.phone,
|
||||||
"is_admin": self.is_admin,
|
"is_admin": self.is_admin,
|
||||||
|
"credit_score": self.credit_score,
|
||||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||||
}
|
}
|
||||||
@@ -77,6 +79,7 @@ class SpamPredictionLog(db.Model):
|
|||||||
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
|
user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
|
||||||
text = db.Column(db.Text, nullable=False)
|
text = db.Column(db.Text, nullable=False)
|
||||||
prediction = db.Column(db.String(16), nullable=False) # spam | ham
|
prediction = db.Column(db.String(16), nullable=False) # spam | ham
|
||||||
|
category = db.Column(db.String(32), default="") # fraud | harassment | advertisement | spam | 空
|
||||||
spam_probability = db.Column(db.Float, nullable=False)
|
spam_probability = db.Column(db.Float, nullable=False)
|
||||||
ham_probability = db.Column(db.Float, nullable=False)
|
ham_probability = db.Column(db.Float, nullable=False)
|
||||||
confidence = db.Column(db.Float, nullable=False)
|
confidence = db.Column(db.Float, nullable=False)
|
||||||
@@ -90,6 +93,7 @@ class SpamPredictionLog(db.Model):
|
|||||||
"user_id": self.user_id,
|
"user_id": self.user_id,
|
||||||
"text": self.text,
|
"text": self.text,
|
||||||
"prediction": self.prediction,
|
"prediction": self.prediction,
|
||||||
|
"category": self.category or "",
|
||||||
"spam_probability": round(float(self.spam_probability), 4),
|
"spam_probability": round(float(self.spam_probability), 4),
|
||||||
"ham_probability": round(float(self.ham_probability), 4),
|
"ham_probability": round(float(self.ham_probability), 4),
|
||||||
"confidence": round(float(self.confidence), 4),
|
"confidence": round(float(self.confidence), 4),
|
||||||
@@ -128,6 +132,7 @@ class ContentPost(db.Model):
|
|||||||
|
|
||||||
status = db.Column(db.String(16), nullable=False, default="published") # published | blocked
|
status = db.Column(db.String(16), nullable=False, default="published") # published | blocked
|
||||||
prediction = db.Column(db.String(16), nullable=False, default="ham")
|
prediction = db.Column(db.String(16), nullable=False, default="ham")
|
||||||
|
category = db.Column(db.String(32), default="") # fraud | harassment | advertisement | spam | 空
|
||||||
spam_probability = db.Column(db.Float, nullable=False, default=0)
|
spam_probability = db.Column(db.Float, nullable=False, default=0)
|
||||||
ham_probability = db.Column(db.Float, nullable=False, default=0)
|
ham_probability = db.Column(db.Float, nullable=False, default=0)
|
||||||
confidence = db.Column(db.Float, nullable=False, default=0)
|
confidence = db.Column(db.Float, nullable=False, default=0)
|
||||||
@@ -161,6 +166,7 @@ class ContentPost(db.Model):
|
|||||||
"visibility": self.visibility,
|
"visibility": self.visibility,
|
||||||
"status": self.status,
|
"status": self.status,
|
||||||
"prediction": self.prediction,
|
"prediction": self.prediction,
|
||||||
|
"category": self.category or "",
|
||||||
"spam_probability": round(float(self.spam_probability), 4),
|
"spam_probability": round(float(self.spam_probability), 4),
|
||||||
"ham_probability": round(float(self.ham_probability), 4),
|
"ham_probability": round(float(self.ham_probability), 4),
|
||||||
"confidence": round(float(self.confidence), 4),
|
"confidence": round(float(self.confidence), 4),
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -147,6 +147,114 @@ def stats():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.get("/stats/report")
|
||||||
|
@admin_required
|
||||||
|
def generate_report():
|
||||||
|
"""生成运营报告:垃圾信息变化、风险词排名、误判率趋势"""
|
||||||
|
now = datetime.utcnow()
|
||||||
|
week_ago = now - timedelta(days=13) # 近14天
|
||||||
|
|
||||||
|
# 1. 垃圾信息数量变化(近14天)
|
||||||
|
blocked_trend_rows = (
|
||||||
|
db.session.query(func.date(ContentPost.created_at), func.count(ContentPost.id))
|
||||||
|
.filter(ContentPost.created_at >= week_ago, ContentPost.status == "blocked")
|
||||||
|
.group_by(func.date(ContentPost.created_at))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
published_trend_rows = (
|
||||||
|
db.session.query(func.date(ContentPost.created_at), func.count(ContentPost.id))
|
||||||
|
.filter(ContentPost.created_at >= week_ago, ContentPost.status == "published")
|
||||||
|
.group_by(func.date(ContentPost.created_at))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
blocked_map = {_day_key(day): int(count or 0) for day, count in blocked_trend_rows}
|
||||||
|
published_map = {_day_key(day): int(count or 0) for day, count in published_trend_rows}
|
||||||
|
|
||||||
|
spam_trend = []
|
||||||
|
today = now.date()
|
||||||
|
for offset in range(13, -1, -1):
|
||||||
|
day = today - timedelta(days=offset)
|
||||||
|
key = day.isoformat()
|
||||||
|
spam_trend.append({
|
||||||
|
"date": key,
|
||||||
|
"label": day.strftime("%m-%d"),
|
||||||
|
"blocked": blocked_map.get(key, 0),
|
||||||
|
"published": published_map.get(key, 0),
|
||||||
|
"total": blocked_map.get(key, 0) + published_map.get(key, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
# 2. 高频风险词排名(近14天)
|
||||||
|
blocked_logs = (
|
||||||
|
ContentPost.query.filter(ContentPost.created_at >= week_ago, ContentPost.status == "blocked")
|
||||||
|
.order_by(ContentPost.id.desc())
|
||||||
|
.limit(1000)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
token_counter = Counter()
|
||||||
|
for row in blocked_logs:
|
||||||
|
token_counter.update(_tokenize(row.text))
|
||||||
|
top_keywords = [{"token": token, "count": count} for token, count in token_counter.most_common(20)]
|
||||||
|
|
||||||
|
# 3. 误判率趋势(近14天,基于人工复核)
|
||||||
|
review_trend_rows = (
|
||||||
|
db.session.query(func.date(ContentPost.manual_review_at), func.count(ContentPost.id))
|
||||||
|
.filter(ContentPost.manual_review_at >= week_ago, ContentPost.manual_review_status != "none")
|
||||||
|
.group_by(func.date(ContentPost.manual_review_at))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
approved_trend_rows = (
|
||||||
|
db.session.query(func.date(ContentPost.manual_review_at), func.count(ContentPost.id))
|
||||||
|
.filter(ContentPost.manual_review_at >= week_ago, ContentPost.manual_review_status == "approved_ham")
|
||||||
|
.group_by(func.date(ContentPost.manual_review_at))
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
review_map = {_day_key(day): int(count or 0) for day, count in review_trend_rows}
|
||||||
|
approved_map = {_day_key(day): int(count or 0) for day, count in approved_trend_rows}
|
||||||
|
|
||||||
|
misjudge_trend = []
|
||||||
|
for offset in range(13, -1, -1):
|
||||||
|
day = today - timedelta(days=offset)
|
||||||
|
key = day.isoformat()
|
||||||
|
reviewed = review_map.get(key, 0)
|
||||||
|
approved = approved_map.get(key, 0)
|
||||||
|
misjudge_rate = round(approved / reviewed, 4) if reviewed > 0 else 0
|
||||||
|
misjudge_trend.append({
|
||||||
|
"date": key,
|
||||||
|
"label": day.strftime("%m-%d"),
|
||||||
|
"reviewed": reviewed,
|
||||||
|
"approved": approved,
|
||||||
|
"misjudge_rate": misjudge_rate,
|
||||||
|
"misjudge_rate_text": f"{misjudge_rate * 100:.1f}%"
|
||||||
|
})
|
||||||
|
|
||||||
|
# 4. 汇总统计
|
||||||
|
total_blocked_14d = sum(blocked_map.values())
|
||||||
|
total_published_14d = sum(published_map.values())
|
||||||
|
total_reviews_14d = sum(review_map.values())
|
||||||
|
total_approved_14d = sum(approved_map.values())
|
||||||
|
avg_misjudge_rate = round(total_approved_14d / total_reviews_14d, 4) if total_reviews_14d > 0 else 0
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
"report_date": now.isoformat(),
|
||||||
|
"period": "近14天",
|
||||||
|
"spam_trend": spam_trend,
|
||||||
|
"top_keywords": top_keywords,
|
||||||
|
"misjudge_trend": misjudge_trend,
|
||||||
|
"summary": {
|
||||||
|
"total_blocked": total_blocked_14d,
|
||||||
|
"total_published": total_published_14d,
|
||||||
|
"total_posts": total_blocked_14d + total_published_14d,
|
||||||
|
"blocked_ratio": round(total_blocked_14d / (total_blocked_14d + total_published_14d), 4) if (total_blocked_14d + total_published_14d) > 0 else 0,
|
||||||
|
"total_reviews": total_reviews_14d,
|
||||||
|
"total_approved": total_approved_14d,
|
||||||
|
"avg_misjudge_rate": avg_misjudge_rate,
|
||||||
|
"avg_misjudge_rate_text": f"{avg_misjudge_rate * 100:.1f}%"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@admin_bp.get("/detection/threshold")
|
@admin_bp.get("/detection/threshold")
|
||||||
@admin_required
|
@admin_required
|
||||||
def get_threshold():
|
def get_threshold():
|
||||||
@@ -309,11 +417,17 @@ def process_appeal(post_id: int):
|
|||||||
row.prediction = "ham"
|
row.prediction = "ham"
|
||||||
row.manual_review_status = "approved_ham"
|
row.manual_review_status = "approved_ham"
|
||||||
_upsert_manual_sample(row.text, "ham", admin.id if admin else None)
|
_upsert_manual_sample(row.text, "ham", admin.id if admin else None)
|
||||||
|
# 申诉通过,增加用户信誉分
|
||||||
|
if row.author:
|
||||||
|
row.author.credit_score = min(200, (row.author.credit_score or 100) + 10)
|
||||||
else:
|
else:
|
||||||
row.status = "blocked"
|
row.status = "blocked"
|
||||||
row.prediction = "spam"
|
row.prediction = "spam"
|
||||||
row.manual_review_status = "confirmed_spam"
|
row.manual_review_status = "confirmed_spam"
|
||||||
_upsert_manual_sample(row.text, "spam", admin.id if admin else None)
|
_upsert_manual_sample(row.text, "spam", admin.id if admin else None)
|
||||||
|
# 申诉驳回,减少用户信誉分
|
||||||
|
if row.author:
|
||||||
|
row.author.credit_score = max(0, (row.author.credit_score or 100) - 5)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return ok(_serialize_post(row), "申诉处理完成")
|
return ok(_serialize_post(row), "申诉处理完成")
|
||||||
@@ -404,6 +518,12 @@ def update_user(user_id: int):
|
|||||||
user.phone = (payload.get("phone") or "").strip()
|
user.phone = (payload.get("phone") or "").strip()
|
||||||
if "is_admin" in payload:
|
if "is_admin" in payload:
|
||||||
user.is_admin = bool(payload.get("is_admin"))
|
user.is_admin = bool(payload.get("is_admin"))
|
||||||
|
if "credit_score" in payload:
|
||||||
|
try:
|
||||||
|
credit = int(payload.get("credit_score", 100))
|
||||||
|
user.credit_score = max(0, min(200, credit))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
if payload.get("password"):
|
if payload.get("password"):
|
||||||
if len(payload["password"]) < 6:
|
if len(payload["password"]) < 6:
|
||||||
return fail("密码至少6位", 400)
|
return fail("密码至少6位", 400)
|
||||||
@@ -427,3 +547,73 @@ def delete_user(user_id: int):
|
|||||||
db.session.commit()
|
db.session.commit()
|
||||||
return ok({}, "用户已删除")
|
return ok({}, "用户已删除")
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.put("/users/<int:user_id>/credit")
|
||||||
|
@admin_required
|
||||||
|
def update_user_credit(user_id: int):
|
||||||
|
"""手动调整用户信誉分"""
|
||||||
|
user = User.query.get(user_id)
|
||||||
|
if not user:
|
||||||
|
return fail("用户不存在", 404)
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
try:
|
||||||
|
credit = int(payload.get("credit_score", user.credit_score or 100))
|
||||||
|
credit = max(0, min(200, credit))
|
||||||
|
except Exception:
|
||||||
|
return fail("信誉分必须是0-200之间的整数", 400)
|
||||||
|
|
||||||
|
user.credit_score = credit
|
||||||
|
db.session.commit()
|
||||||
|
return ok(user.to_dict(), "信誉分已更新")
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.post("/users/recalculate-credit")
|
||||||
|
@admin_required
|
||||||
|
def recalculate_all_credit():
|
||||||
|
"""根据用户发布历史和申诉通过率重新计算信誉分"""
|
||||||
|
users = User.query.filter_by(is_admin=False).all()
|
||||||
|
updated_count = 0
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
posts = ContentPost.query.filter_by(user_id=user.id).all()
|
||||||
|
if not posts:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 计算发布成功率
|
||||||
|
published_count = sum(1 for p in posts if p.status == "published")
|
||||||
|
blocked_count = sum(1 for p in posts if p.status == "blocked")
|
||||||
|
total_count = len(posts)
|
||||||
|
|
||||||
|
if total_count == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
publish_ratio = published_count / total_count
|
||||||
|
|
||||||
|
# 计算申诉通过率
|
||||||
|
appeals = [p for p in posts if p.appeal_status != "none"]
|
||||||
|
approved_appeals = sum(1 for p in appeals if p.appeal_status == "approved")
|
||||||
|
appeal_ratio = approved_appeals / len(appeals) if appeals else 0
|
||||||
|
|
||||||
|
# 基础信誉分:发布成功率贡献
|
||||||
|
base_score = 100
|
||||||
|
if publish_ratio >= 0.9:
|
||||||
|
base_score += 30 # 90%以上发布成功,+30
|
||||||
|
elif publish_ratio >= 0.7:
|
||||||
|
base_score += 15 # 70%以上,+15
|
||||||
|
elif publish_ratio < 0.5:
|
||||||
|
base_score -= 20 # 低于50%,-20
|
||||||
|
|
||||||
|
# 申诉通过率贡献
|
||||||
|
if appeal_ratio >= 0.8 and len(appeals) >= 3:
|
||||||
|
base_score += 20 # 80%以上申诉通过且有3次以上申诉,+20
|
||||||
|
elif appeal_ratio >= 0.5 and len(appeals) >= 2:
|
||||||
|
base_score += 10
|
||||||
|
|
||||||
|
# 限制范围
|
||||||
|
user.credit_score = max(0, min(200, base_score))
|
||||||
|
updated_count += 1
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
return ok({"updated_count": updated_count}, "信誉分批量重算完成")
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from flask_jwt_extended import jwt_required
|
|||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.ml.naive_bayes_classifier import NaiveBayesSpamClassifier
|
from app.ml.naive_bayes_classifier import NaiveBayesSpamClassifier
|
||||||
|
from app.ml.spam_categorizer import categorize_spam, get_category_label
|
||||||
from app.models import ContentPost, DetectionConfig, SpamPredictionLog, SpamTrainingSample, User
|
from app.models import ContentPost, DetectionConfig, SpamPredictionLog, SpamTrainingSample, User
|
||||||
from app.utils.auth import current_user
|
from app.utils.auth import current_user
|
||||||
from app.utils.response import fail, ok
|
from app.utils.response import fail, ok
|
||||||
@@ -77,12 +78,29 @@ def _resolve_recipient(payload: dict, visibility: str, current_user_id: int):
|
|||||||
return recipient, None
|
return recipient, None
|
||||||
|
|
||||||
|
|
||||||
def _predict_and_decide(text: str) -> tuple[dict, float, bool]:
|
def _predict_and_decide(text: str, user_credit: int = 100) -> tuple[dict, float, bool, str, str]:
|
||||||
|
"""根据用户信誉分调整阈值系数。信誉分越高,阈值越高(降低敏感度)"""
|
||||||
clf = _ensure_ready()
|
clf = _ensure_ready()
|
||||||
result = clf.predict(text)
|
result = clf.predict(text)
|
||||||
threshold = float(_get_config().spam_threshold)
|
base_threshold = float(_get_config().spam_threshold)
|
||||||
blocked = float(result["spam_probability"]) >= threshold
|
|
||||||
return result, threshold, blocked
|
# 信誉分影响阈值系数:credit 0-200,默认100
|
||||||
|
# credit > 100:阈值提高(降低敏感度,减少误判)
|
||||||
|
# credit < 100:阈值降低(提高敏感度,加强拦截)
|
||||||
|
# 系数范围:0.85 - 1.15
|
||||||
|
credit_factor = 1.0 + (user_credit - 100) * 0.0015 # 每10分变化1.5%
|
||||||
|
credit_factor = max(0.85, min(1.15, credit_factor))
|
||||||
|
|
||||||
|
adjusted_threshold = base_threshold * credit_factor
|
||||||
|
blocked = float(result["spam_probability"]) >= adjusted_threshold
|
||||||
|
|
||||||
|
# 分类标签
|
||||||
|
category = ""
|
||||||
|
category_label = ""
|
||||||
|
if blocked:
|
||||||
|
category, category_label = categorize_spam(result["text"])
|
||||||
|
|
||||||
|
return result, adjusted_threshold, blocked, category, category_label
|
||||||
|
|
||||||
|
|
||||||
@content_bp.post("/publish")
|
@content_bp.post("/publish")
|
||||||
@@ -103,7 +121,7 @@ def publish_text():
|
|||||||
if err:
|
if err:
|
||||||
return fail(err, 400)
|
return fail(err, 400)
|
||||||
|
|
||||||
result, threshold, blocked = _predict_and_decide(text)
|
result, threshold, blocked, category, category_label = _predict_and_decide(text, user.credit_score or 100)
|
||||||
|
|
||||||
post = ContentPost(
|
post = ContentPost(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
@@ -112,6 +130,7 @@ def publish_text():
|
|||||||
visibility=visibility,
|
visibility=visibility,
|
||||||
status="blocked" if blocked else "published",
|
status="blocked" if blocked else "published",
|
||||||
prediction=result["prediction"],
|
prediction=result["prediction"],
|
||||||
|
category=category,
|
||||||
spam_probability=result["spam_probability"],
|
spam_probability=result["spam_probability"],
|
||||||
ham_probability=result["ham_probability"],
|
ham_probability=result["ham_probability"],
|
||||||
confidence=result["confidence"],
|
confidence=result["confidence"],
|
||||||
@@ -125,6 +144,7 @@ def publish_text():
|
|||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
text=result["text"],
|
text=result["text"],
|
||||||
prediction=result["prediction"],
|
prediction=result["prediction"],
|
||||||
|
category=category,
|
||||||
spam_probability=result["spam_probability"],
|
spam_probability=result["spam_probability"],
|
||||||
ham_probability=result["ham_probability"],
|
ham_probability=result["ham_probability"],
|
||||||
confidence=result["confidence"],
|
confidence=result["confidence"],
|
||||||
@@ -134,16 +154,27 @@ def publish_text():
|
|||||||
|
|
||||||
db.session.add(post)
|
db.session.add(post)
|
||||||
db.session.add(detect_log)
|
db.session.add(detect_log)
|
||||||
|
|
||||||
|
# 发布成功(未被拦截),小幅增加信誉分;被拦截则小幅减少
|
||||||
|
if not blocked:
|
||||||
|
user.credit_score = min(200, (user.credit_score or 100) + 1)
|
||||||
|
else:
|
||||||
|
user.credit_score = max(0, (user.credit_score or 100) - 2)
|
||||||
|
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
feedback = "发布成功" if not blocked else "疑似垃圾信息,系统已拦截,可提交申诉"
|
feedback = "发布成功" if not blocked else f"{category_label or '疑似垃圾信息'},系统已拦截,可提交申诉"
|
||||||
return ok(
|
return ok(
|
||||||
{
|
{
|
||||||
"publish_allowed": not blocked,
|
"publish_allowed": not blocked,
|
||||||
"action": "published" if not blocked else "blocked",
|
"action": "published" if not blocked else "blocked",
|
||||||
"feedback": feedback,
|
"feedback": feedback,
|
||||||
"post": _serialize_post(post),
|
"post": _serialize_post(post),
|
||||||
"detect": result,
|
"detect": {
|
||||||
|
**result,
|
||||||
|
"category": category,
|
||||||
|
"category_label": category_label,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
feedback,
|
feedback,
|
||||||
)
|
)
|
||||||
@@ -171,13 +202,14 @@ def edit_post(post_id: int):
|
|||||||
if err:
|
if err:
|
||||||
return fail(err, 400)
|
return fail(err, 400)
|
||||||
|
|
||||||
result, threshold, blocked = _predict_and_decide(text)
|
result, threshold, blocked, category, category_label = _predict_and_decide(text, user.credit_score or 100)
|
||||||
|
|
||||||
post.text = result["text"]
|
post.text = result["text"]
|
||||||
post.visibility = visibility
|
post.visibility = visibility
|
||||||
post.recipient_user_id = recipient.id if recipient else None
|
post.recipient_user_id = recipient.id if recipient else None
|
||||||
post.status = "blocked" if blocked else "published"
|
post.status = "blocked" if blocked else "published"
|
||||||
post.prediction = result["prediction"]
|
post.prediction = result["prediction"]
|
||||||
|
post.category = category
|
||||||
post.spam_probability = result["spam_probability"]
|
post.spam_probability = result["spam_probability"]
|
||||||
post.ham_probability = result["ham_probability"]
|
post.ham_probability = result["ham_probability"]
|
||||||
post.confidence = result["confidence"]
|
post.confidence = result["confidence"]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ from flask_jwt_extended import jwt_required
|
|||||||
|
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
from app.ml.naive_bayes_classifier import NaiveBayesSpamClassifier
|
from app.ml.naive_bayes_classifier import NaiveBayesSpamClassifier
|
||||||
|
from app.ml.spam_categorizer import categorize_spam, get_category_label
|
||||||
from app.models import DetectionConfig, SpamPredictionLog, SpamTrainingSample
|
from app.models import DetectionConfig, SpamPredictionLog, SpamTrainingSample
|
||||||
from app.utils.auth import admin_required, current_user
|
from app.utils.auth import admin_required, current_user
|
||||||
from app.utils.response import fail, ok
|
from app.utils.response import fail, ok
|
||||||
@@ -32,6 +33,15 @@ def _threshold() -> float:
|
|||||||
return float(row.spam_threshold) if row else 0.75
|
return float(row.spam_threshold) if row else 0.75
|
||||||
|
|
||||||
|
|
||||||
|
def _adjusted_threshold(user_credit: int = 100) -> float:
|
||||||
|
"""根据用户信誉分调整阈值"""
|
||||||
|
base_threshold = _threshold()
|
||||||
|
# 系数范围:0.85 - 1.15
|
||||||
|
credit_factor = 1.0 + (user_credit - 100) * 0.0015
|
||||||
|
credit_factor = max(0.85, min(1.15, credit_factor))
|
||||||
|
return base_threshold * credit_factor
|
||||||
|
|
||||||
|
|
||||||
@spam_bp.post("/predict")
|
@spam_bp.post("/predict")
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
def predict_one():
|
def predict_one():
|
||||||
@@ -46,13 +56,20 @@ def predict_one():
|
|||||||
|
|
||||||
clf = _ensure_ready()
|
clf = _ensure_ready()
|
||||||
result = clf.predict(text)
|
result = clf.predict(text)
|
||||||
threshold = _threshold()
|
threshold = _adjusted_threshold(user.credit_score or 100)
|
||||||
blocked = float(result["spam_probability"]) >= threshold
|
blocked = float(result["spam_probability"]) >= threshold
|
||||||
|
|
||||||
|
# 分类标签:仅在判定为垃圾时进行细分
|
||||||
|
category = ""
|
||||||
|
category_label = ""
|
||||||
|
if blocked:
|
||||||
|
category, category_label = categorize_spam(result["text"])
|
||||||
|
|
||||||
row = SpamPredictionLog(
|
row = SpamPredictionLog(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
text=result["text"],
|
text=result["text"],
|
||||||
prediction=result["prediction"],
|
prediction=result["prediction"],
|
||||||
|
category=category,
|
||||||
spam_probability=result["spam_probability"],
|
spam_probability=result["spam_probability"],
|
||||||
ham_probability=result["ham_probability"],
|
ham_probability=result["ham_probability"],
|
||||||
confidence=result["confidence"],
|
confidence=result["confidence"],
|
||||||
@@ -62,7 +79,14 @@ def predict_one():
|
|||||||
db.session.add(row)
|
db.session.add(row)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
|
||||||
return ok({**result, "log_id": row.id, "threshold": threshold, "blocked_by_threshold": blocked}, "识别成功")
|
return ok({
|
||||||
|
**result,
|
||||||
|
"log_id": row.id,
|
||||||
|
"threshold": threshold,
|
||||||
|
"blocked_by_threshold": blocked,
|
||||||
|
"category": category,
|
||||||
|
"category_label": category_label,
|
||||||
|
}, "识别成功")
|
||||||
|
|
||||||
|
|
||||||
@spam_bp.post("/predict/batch")
|
@spam_bp.post("/predict/batch")
|
||||||
@@ -82,19 +106,30 @@ def predict_batch():
|
|||||||
clf = _ensure_ready()
|
clf = _ensure_ready()
|
||||||
rows = []
|
rows = []
|
||||||
results = []
|
results = []
|
||||||
threshold = _threshold()
|
threshold = _adjusted_threshold(user.credit_score or 100)
|
||||||
|
|
||||||
for text in items:
|
for text in items:
|
||||||
content = (text or "").strip()
|
content = (text or "").strip()
|
||||||
if len(content) < 2:
|
if len(content) < 2:
|
||||||
continue
|
continue
|
||||||
result = clf.predict(content)
|
result = clf.predict(content)
|
||||||
result["blocked_by_threshold"] = float(result["spam_probability"]) >= threshold
|
blocked = float(result["spam_probability"]) >= threshold
|
||||||
|
result["blocked_by_threshold"] = blocked
|
||||||
|
|
||||||
|
# 分类标签
|
||||||
|
category = ""
|
||||||
|
category_label = ""
|
||||||
|
if blocked:
|
||||||
|
category, category_label = categorize_spam(result["text"])
|
||||||
|
result["category"] = category
|
||||||
|
result["category_label"] = category_label
|
||||||
|
|
||||||
rows.append(
|
rows.append(
|
||||||
SpamPredictionLog(
|
SpamPredictionLog(
|
||||||
user_id=user.id,
|
user_id=user.id,
|
||||||
text=result["text"],
|
text=result["text"],
|
||||||
prediction=result["prediction"],
|
prediction=result["prediction"],
|
||||||
|
category=category,
|
||||||
spam_probability=result["spam_probability"],
|
spam_probability=result["spam_probability"],
|
||||||
ham_probability=result["ham_probability"],
|
ham_probability=result["ham_probability"],
|
||||||
confidence=result["confidence"],
|
confidence=result["confidence"],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pymysql
|
import pymysql
|
||||||
|
from pymysql import MySQLError
|
||||||
|
|
||||||
from app import create_app
|
from app import create_app
|
||||||
from app.extensions import db
|
from app.extensions import db
|
||||||
@@ -12,6 +13,7 @@ from app.models import DetectionConfig, SpamTrainingSample, User
|
|||||||
BASE_DIR = Path(__file__).resolve().parent
|
BASE_DIR = Path(__file__).resolve().parent
|
||||||
MYSQL_CONFIG_PATH = BASE_DIR / "mysqlconfig.json"
|
MYSQL_CONFIG_PATH = BASE_DIR / "mysqlconfig.json"
|
||||||
SPAM_SEED_PATH = BASE_DIR / "seed" / "spam_samples_seed.json"
|
SPAM_SEED_PATH = BASE_DIR / "seed" / "spam_samples_seed.json"
|
||||||
|
SQL_MIGRATIONS_DIR = BASE_DIR / "sql"
|
||||||
|
|
||||||
|
|
||||||
def load_mysql_cfg() -> dict:
|
def load_mysql_cfg() -> dict:
|
||||||
@@ -39,6 +41,46 @@ def create_database(mysql_cfg: dict) -> None:
|
|||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def run_sql_migrations(mysql_cfg: dict) -> list[str]:
|
||||||
|
"""执行 sql 目录下的迁移脚本"""
|
||||||
|
if not SQL_MIGRATIONS_DIR.exists():
|
||||||
|
return []
|
||||||
|
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host=mysql_cfg.get("host", "127.0.0.1"),
|
||||||
|
port=int(mysql_cfg.get("port", 3306)),
|
||||||
|
user=mysql_cfg["user"],
|
||||||
|
password=mysql_cfg["password"],
|
||||||
|
database=mysql_cfg["database"],
|
||||||
|
charset=mysql_cfg.get("charset", "utf8mb4"),
|
||||||
|
autocommit=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
executed = []
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cursor:
|
||||||
|
sql_files = sorted(SQL_MIGRATIONS_DIR.glob("*.sql"))
|
||||||
|
for sql_file in sql_files:
|
||||||
|
sql_content = sql_file.read_text(encoding="utf-8")
|
||||||
|
statements = [s.strip() for s in sql_content.split(";") if s.strip() and not s.strip().startswith("--")]
|
||||||
|
for stmt in statements:
|
||||||
|
if stmt:
|
||||||
|
try:
|
||||||
|
cursor.execute(stmt)
|
||||||
|
except MySQLError as e:
|
||||||
|
if "1060" in str(e):
|
||||||
|
pass
|
||||||
|
elif "1061" in str(e):
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
print(f"SQL 警告 ({sql_file.name}): {e}")
|
||||||
|
executed.append(sql_file.name)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return executed
|
||||||
|
|
||||||
|
|
||||||
def ensure_seed_file() -> None:
|
def ensure_seed_file() -> None:
|
||||||
if SPAM_SEED_PATH.exists():
|
if SPAM_SEED_PATH.exists():
|
||||||
return
|
return
|
||||||
@@ -135,6 +177,7 @@ def main():
|
|||||||
app = create_app()
|
app = create_app()
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
db.create_all()
|
db.create_all()
|
||||||
|
migrations = run_sql_migrations(mysql_cfg)
|
||||||
created, updated = seed_samples()
|
created, updated = seed_samples()
|
||||||
threshold = ensure_detection_config(mysql_cfg)
|
threshold = ensure_detection_config(mysql_cfg)
|
||||||
admin_msg = init_admin(mysql_cfg)
|
admin_msg = init_admin(mysql_cfg)
|
||||||
@@ -146,6 +189,8 @@ def main():
|
|||||||
print(f"- 初始阈值: {threshold}")
|
print(f"- 初始阈值: {threshold}")
|
||||||
print(f"- {admin_msg}")
|
print(f"- {admin_msg}")
|
||||||
print(f"- 模型版本: {model_meta.get('version')}")
|
print(f"- 模型版本: {model_meta.get('version')}")
|
||||||
|
if migrations:
|
||||||
|
print(f"- SQL迁移: {', '.join(migrations)}")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"host": "192.168.2.183",
|
"host": "127.0.0.1",
|
||||||
"port": 3308,
|
"port": 3306,
|
||||||
"user": "root",
|
"user": "root",
|
||||||
"password": "rootroot",
|
"password": "123456",
|
||||||
"database": "spam_nb_miniapp",
|
"database": "spam_nb_miniapp",
|
||||||
"charset": "utf8mb4",
|
"charset": "utf8mb4",
|
||||||
"admin_init": {
|
"admin_init": {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"host": "127.0.0.1",
|
"host": "192.168.2.183",
|
||||||
"port": 3306,
|
"port": 3308,
|
||||||
"user": "root",
|
"user": "root",
|
||||||
"password": "pk123123",
|
"password": "rootroot",
|
||||||
"database": "spam_nb_miniapp",
|
"database": "spam_nb_miniapp",
|
||||||
"charset": "utf8mb4",
|
"charset": "utf8mb4",
|
||||||
"admin_init": {
|
"admin_init": {
|
||||||
29
backend/project.config.json
Normal file
29
backend/project.config.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"appid": "wx42ba28b8e545ba14",
|
||||||
|
"compileType": "miniprogram",
|
||||||
|
"libVersion": "3.15.2",
|
||||||
|
"packOptions": {
|
||||||
|
"ignore": [],
|
||||||
|
"include": []
|
||||||
|
},
|
||||||
|
"setting": {
|
||||||
|
"coverView": true,
|
||||||
|
"es6": true,
|
||||||
|
"postcss": true,
|
||||||
|
"minified": true,
|
||||||
|
"enhance": true,
|
||||||
|
"showShadowRootInWxmlPanel": true,
|
||||||
|
"packNpmRelationList": [],
|
||||||
|
"babelSetting": {
|
||||||
|
"ignore": [],
|
||||||
|
"disablePlugins": [],
|
||||||
|
"outputPath": ""
|
||||||
|
},
|
||||||
|
"condition": false
|
||||||
|
},
|
||||||
|
"condition": {},
|
||||||
|
"editorSetting": {
|
||||||
|
"tabIndent": "tab",
|
||||||
|
"tabSize": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
7
backend/project.private.config.json
Normal file
7
backend/project.private.config.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档:https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html",
|
||||||
|
"projectname": "backend",
|
||||||
|
"setting": {
|
||||||
|
"compileHotReLoad": true
|
||||||
|
}
|
||||||
|
}
|
||||||
234
backend/spam_nb_miniapp.sql
Normal file
234
backend/spam_nb_miniapp.sql
Normal file
@@ -0,0 +1,234 @@
|
|||||||
|
/*
|
||||||
|
Navicat Premium Dump SQL
|
||||||
|
|
||||||
|
Source Server : 8.0.12_mysql
|
||||||
|
Source Server Type : MySQL
|
||||||
|
Source Server Version : 80012 (8.0.12)
|
||||||
|
Source Host : 192.168.2.183:3308
|
||||||
|
Source Schema : spam_nb_miniapp
|
||||||
|
|
||||||
|
Target Server Type : MySQL
|
||||||
|
Target Server Version : 80012 (8.0.12)
|
||||||
|
File Encoding : 65001
|
||||||
|
|
||||||
|
Date: 22/04/2026 23:17:44
|
||||||
|
*/
|
||||||
|
|
||||||
|
SET NAMES utf8mb4;
|
||||||
|
SET FOREIGN_KEY_CHECKS = 0;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table structure for content_posts
|
||||||
|
-- ----------------------------
|
||||||
|
DROP TABLE IF EXISTS `content_posts`;
|
||||||
|
CREATE TABLE `content_posts` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` int(11) NOT NULL,
|
||||||
|
`recipient_user_id` int(11) NULL DEFAULT NULL,
|
||||||
|
`text` text CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
`visibility` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
`status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
`prediction` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
`category` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT '',
|
||||||
|
`spam_probability` float NOT NULL,
|
||||||
|
`ham_probability` float NOT NULL,
|
||||||
|
`confidence` float NOT NULL,
|
||||||
|
`threshold` float NOT NULL,
|
||||||
|
`reason_tokens` json NULL,
|
||||||
|
`model_version` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
|
||||||
|
`manual_review_status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
`manual_review_by` int(11) NULL DEFAULT NULL,
|
||||||
|
`manual_review_note` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
|
||||||
|
`manual_review_at` datetime NULL DEFAULT NULL,
|
||||||
|
`appeal_status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
`appeal_reason_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT '' COMMENT '快捷申诉理由类型',
|
||||||
|
`appeal_reason` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
|
||||||
|
`appeal_evidence_urls` json NULL COMMENT '证据图片URL列表',
|
||||||
|
`appeal_admin_note` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
|
||||||
|
`appeal_submitted_at` datetime NULL DEFAULT NULL,
|
||||||
|
`appeal_processed_at` datetime NULL DEFAULT NULL,
|
||||||
|
`appeal_processed_by` int(11) NULL DEFAULT NULL,
|
||||||
|
`created_at` datetime NULL DEFAULT NULL,
|
||||||
|
`updated_at` datetime NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`) USING BTREE,
|
||||||
|
INDEX `manual_review_by`(`manual_review_by` ASC) USING BTREE,
|
||||||
|
INDEX `appeal_processed_by`(`appeal_processed_by` ASC) USING BTREE,
|
||||||
|
INDEX `ix_content_posts_created_at`(`created_at` ASC) USING BTREE,
|
||||||
|
INDEX `ix_content_posts_user_id`(`user_id` ASC) USING BTREE,
|
||||||
|
INDEX `ix_content_posts_recipient_user_id`(`recipient_user_id` ASC) USING BTREE,
|
||||||
|
CONSTRAINT `content_posts_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||||
|
CONSTRAINT `content_posts_ibfk_2` FOREIGN KEY (`recipient_user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||||
|
CONSTRAINT `content_posts_ibfk_3` FOREIGN KEY (`manual_review_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT,
|
||||||
|
CONSTRAINT `content_posts_ibfk_4` FOREIGN KEY (`appeal_processed_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT
|
||||||
|
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin ROW_FORMAT = Dynamic;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Records of content_posts
|
||||||
|
-- ----------------------------
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table structure for detection_configs
|
||||||
|
-- ----------------------------
|
||||||
|
DROP TABLE IF EXISTS `detection_configs`;
|
||||||
|
CREATE TABLE `detection_configs` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`spam_threshold` float NOT NULL,
|
||||||
|
`updated_by` int(11) NULL DEFAULT NULL,
|
||||||
|
`updated_at` datetime NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`) USING BTREE,
|
||||||
|
INDEX `updated_by`(`updated_by` ASC) USING BTREE,
|
||||||
|
CONSTRAINT `detection_configs_ibfk_1` FOREIGN KEY (`updated_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT
|
||||||
|
) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin ROW_FORMAT = Dynamic;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Records of detection_configs
|
||||||
|
-- ----------------------------
|
||||||
|
INSERT INTO `detection_configs` VALUES (1, 0.75, NULL, '2026-04-21 14:41:44');
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table structure for spam_prediction_logs
|
||||||
|
-- ----------------------------
|
||||||
|
DROP TABLE IF EXISTS `spam_prediction_logs`;
|
||||||
|
CREATE TABLE `spam_prediction_logs` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`user_id` int(11) NOT NULL,
|
||||||
|
`text` text CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
`prediction` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
`category` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT '',
|
||||||
|
`spam_probability` float NOT NULL,
|
||||||
|
`ham_probability` float NOT NULL,
|
||||||
|
`confidence` float NOT NULL,
|
||||||
|
`reason_tokens` json NULL,
|
||||||
|
`model_version` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
|
||||||
|
`created_at` datetime NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`) USING BTREE,
|
||||||
|
INDEX `ix_spam_prediction_logs_created_at`(`created_at` ASC) USING BTREE,
|
||||||
|
INDEX `ix_spam_prediction_logs_user_id`(`user_id` ASC) USING BTREE,
|
||||||
|
CONSTRAINT `spam_prediction_logs_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT
|
||||||
|
) ENGINE = InnoDB AUTO_INCREMENT = 8 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin ROW_FORMAT = Dynamic;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Records of spam_prediction_logs
|
||||||
|
-- ----------------------------
|
||||||
|
INSERT INTO `spam_prediction_logs` VALUES (1, 1, '大家好,今晚 8 点社区线上读书会,欢迎参加。', 'ham', '', 0.1688, 0.8312, 0.8312, '[\"今\", \"好\", \"会\", \"晚\", \"好,\"]', 'nb-6ec632453424290f', '2026-04-21 14:48:42');
|
||||||
|
INSERT INTO `spam_prediction_logs` VALUES (2, 1, '恭喜中奖领取大额现金,点击链接立即到账。', 'spam', '', 0.9679, 0.0321, 0.9679, '[{\"token\": \"立\", \"weight\": 1.3951}, {\"token\": \"领\", \"weight\": 1.3879}, {\"token\": \"即\", \"weight\": 1.3423}, {\"token\": \"取\", \"weight\": 1.2808}, {\"token\": \"立即\", \"weight\": 1.2778}]', 'nb-6ec632453424290f', '2026-04-21 14:54:30');
|
||||||
|
INSERT INTO `spam_prediction_logs` VALUES (3, 1, '哈哈哈哈哈', 'ham', '', 0.4923, 0.5077, 0.5077, '[]', 'nb-6ec632453424290f', '2026-04-21 15:30:44');
|
||||||
|
INSERT INTO `spam_prediction_logs` VALUES (4, 1, '季姬击鸡记', 'spam', '', 0.6253, 0.3747, 0.6253, '[{\"token\": \"击\", \"weight\": 1.2081}, {\"token\": \"记\", \"weight\": 0.397}, {\"token\": \"季\", \"weight\": -0.3778}]', 'nb-6ec632453424290f', '2026-04-21 15:30:44');
|
||||||
|
INSERT INTO `spam_prediction_logs` VALUES (5, 1, '鸡鸡棒', 'ham', '', 0.4923, 0.5077, 0.5077, '[]', 'nb-6ec632453424290f', '2026-04-21 15:30:44');
|
||||||
|
INSERT INTO `spam_prediction_logs` VALUES (6, 1, '本周活动报名已开放,请在群里接龙。', 'ham', '', 0.3055, 0.6945, 0.6945, '[{\"token\": \"已\", \"weight\": -1.1662}, {\"token\": \"周\", \"weight\": -1.0385}, {\"token\": \"报\", \"weight\": -0.9614}, {\"token\": \"本\", \"weight\": -0.945}, {\"token\": \"动\", \"weight\": 0.8401}]', 'nb-6ec632453424290f', '2026-04-22 13:17:08');
|
||||||
|
INSERT INTO `spam_prediction_logs` VALUES (7, 1, '高薪兼职日结,扫码进群立刻赚钱。', 'spam', 'harassment', 0.8926, 0.1074, 0.8926, '[{\"token\": \"立\", \"weight\": 1.3951}, {\"token\": \"赚\", \"weight\": 0.6924}, {\"token\": \"进群\", \"weight\": 0.6553}, {\"token\": \"扫\", \"weight\": 0.6547}, {\"token\": \"扫码\", \"weight\": 0.6547}]', 'nb-6ec632453424290f', '2026-04-22 13:17:22');
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table structure for spam_training_samples
|
||||||
|
-- ----------------------------
|
||||||
|
DROP TABLE IF EXISTS `spam_training_samples`;
|
||||||
|
CREATE TABLE `spam_training_samples` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`text` text CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
`label` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
`source` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
|
||||||
|
`created_by` int(11) NULL DEFAULT NULL,
|
||||||
|
`is_active` tinyint(1) NULL DEFAULT NULL,
|
||||||
|
`created_at` datetime NULL DEFAULT NULL,
|
||||||
|
`updated_at` datetime NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`) USING BTREE,
|
||||||
|
INDEX `ix_spam_training_samples_created_by`(`created_by` ASC) USING BTREE,
|
||||||
|
INDEX `ix_spam_training_samples_label`(`label` ASC) USING BTREE,
|
||||||
|
CONSTRAINT `spam_training_samples_ibfk_1` FOREIGN KEY (`created_by`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT
|
||||||
|
) ENGINE = InnoDB AUTO_INCREMENT = 66 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin ROW_FORMAT = Dynamic;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Records of spam_training_samples
|
||||||
|
-- ----------------------------
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (1, '尊敬的用户,您已获赠100元话费,点击链接立即到账', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (2, '本周五下午两点进行季度复盘,请准时参加', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (3, '最后3个名额,免费领取手机一台,回复1立即领取', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (4, '您好,合同已发送到邮箱,请查收并反馈修改意见', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (5, '你的快递因地址异常被退回,点击网址重新填写', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (6, '明天出差高铁票已订好,车次信息已同步到群里', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (7, '恭喜你成为平台幸运粉丝,马上领现金红包', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (8, '研发环境今晚22点维护,预计30分钟恢复', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (9, '内部渠道兼职,日结500,添加微信了解详情', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (10, '周报模板已更新,请使用新模板提交', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (11, '低价出售苹果手机,全新未拆封,先到先得', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (12, '客户反馈文档在共享盘,路径已发你私聊', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (13, '紧急通知:你的银行卡存在风险,请立即验证', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (14, '今天的日报我已补充到项目看板', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (15, '官方补贴发放中,输入验证码即可领取', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (16, '下午四点产品评审,麻烦准备交互稿', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (17, '无需面试,高薪在家办公,扫码进群', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (18, '发票已开具完成,纸质件今天寄出', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (19, '您的贷款已通过,点击查看额度', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (20, '会议纪要我整理好了,已上传飞书文档', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (21, '双十一秒杀提前抢,点此领隐藏优惠券', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (22, '新同事今天入职,请大家中午一起欢迎', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (23, '你有一笔退款待确认,马上处理避免失效', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (24, '设计稿第二版我已经按你建议调整完了', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (25, '点击领取年度会员,原价699现价9.9', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (26, '明天早会由我来同步上线计划', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (27, '官方通知:账号异常将被冻结,请立即解封', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (28, '请把测试环境数据库备份到指定目录', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (29, '刷单项目火热招募,宝妈学生都能做', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (30, '你发的需求我已经拆分成开发任务', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (31, '中奖通知:你获得平板电脑一台,限时领取', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (32, '客户明天下午三点会远程验收新功能', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (33, '陌生链接请勿泄露验证码,谨防被骗', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (34, '马上关注公众号领取无门槛现金券', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (35, '今天的构建失败是依赖冲突,我在修复', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (36, '免费领取课程资料,扫码后自动发放', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (37, '请确认一下下周排期是否需要调整', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (38, '特惠机票内部价,回复姓名立刻锁座', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (39, '我已经把版本回滚流程补充到Wiki', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (40, '贷款秒批到账,额度最高20万', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (41, '合同法务意见已返回,请你二次确认', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (42, '限时返现活动,点击进入马上到账', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (43, '这个bug我复现到了,定位在缓存层', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (44, '你有新的违章信息,点开链接立即处理', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (45, '早上好,今天先做性能压测再发版', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (46, '邀请码最后1小时有效,错过不再补发', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (47, '中午12点在会议室A开需求评审会', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (48, '苹果14只要1999,货到付款保真', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (49, '供应商报价单已更新到共享文件夹', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (50, '想赚外快吗?加我秒赚零花钱', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (51, '今天下午我去客户现场,晚些回公司', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (52, '官方补贴计划启动,名额有限速来登记', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (53, '测试报告已发你邮箱,包含复现步骤', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (54, '欠费停机提醒,立即充值恢复使用', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (55, '这个接口我加了幂等,避免重复提交', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (56, '点击抽取盲盒大奖,百分百中奖', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (57, '版本发布说明我已经整理成公告', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (58, '独家内部消息,股票必涨,速进群', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (59, '周一上午需要和财务对齐预算数据', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (60, '紧急!你的社保账户异常,立即核验', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (61, '我下午会把接口文档补全到OpenAPI', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (62, '游戏皮肤免费领,输入手机号立刻到账', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (63, '晚上的培训链接我刚刚发到部门群', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (64, '预约体检补贴开通,点击立即申请', 'spam', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
INSERT INTO `spam_training_samples` VALUES (65, '新需求优先级调高了,请先排进本周', 'ham', 'seed', NULL, 1, '2026-04-21 14:41:44', '2026-04-21 14:41:44');
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Table structure for users
|
||||||
|
-- ----------------------------
|
||||||
|
DROP TABLE IF EXISTS `users`;
|
||||||
|
CREATE TABLE `users` (
|
||||||
|
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||||
|
`username` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
`password_hash` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
`nickname` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
|
||||||
|
`company` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
|
||||||
|
`title` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
|
||||||
|
`phone` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NULL DEFAULT NULL,
|
||||||
|
`is_admin` tinyint(1) NULL DEFAULT NULL,
|
||||||
|
`credit_score` int(11) NULL DEFAULT 100 COMMENT '用户信誉分(0-200,默认100)',
|
||||||
|
`created_at` datetime NULL DEFAULT NULL,
|
||||||
|
`updated_at` datetime NULL DEFAULT NULL,
|
||||||
|
PRIMARY KEY (`id`) USING BTREE,
|
||||||
|
UNIQUE INDEX `ix_users_username`(`username` ASC) USING BTREE
|
||||||
|
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin ROW_FORMAT = Dynamic;
|
||||||
|
|
||||||
|
-- ----------------------------
|
||||||
|
-- Records of users
|
||||||
|
-- ----------------------------
|
||||||
|
INSERT INTO `users` VALUES (1, 'admin', 'scrypt:32768:8:1$7CMYgcG40rYR9VdJ$f65c7ea91736f37c5a2522ac8c0f3fe18dab047dba8b6bf88c789d3da97bd91115225620e2e3eb93ed684f8720bfa09e30cd09599ba708670ddb2738801030fe', '系统管理员', '', '', '', 1, 99, '2026-04-21 14:41:44', '2026-04-22 13:17:22');
|
||||||
|
|
||||||
|
SET FOREIGN_KEY_CHECKS = 1;
|
||||||
5
backend/sql/add_category_field.sql
Normal file
5
backend/sql/add_category_field.sql
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
-- 添加 category 字段到 spam_prediction_logs 表
|
||||||
|
ALTER TABLE `spam_prediction_logs` ADD COLUMN `category` VARCHAR(32) DEFAULT '' AFTER `prediction`;
|
||||||
|
|
||||||
|
-- 添加 category 字段到 content_posts 表
|
||||||
|
ALTER TABLE `content_posts` ADD COLUMN `category` VARCHAR(32) DEFAULT '' AFTER `prediction`;
|
||||||
10
backend/sql/update_credit_score.sql
Normal file
10
backend/sql/update_credit_score.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
-- 用户信誉分字段
|
||||||
|
-- 执行方式:mysql -u root -p database_name < update_credit_score.sql
|
||||||
|
|
||||||
|
-- 新增用户信誉分字段(范围 0-200,默认 100)
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN credit_score INT DEFAULT 100 COMMENT '用户信誉分(0-200,默认100)'
|
||||||
|
AFTER is_admin;
|
||||||
|
|
||||||
|
-- 更新索引(可选,便于按信誉分排序查询)
|
||||||
|
-- ALTER TABLE users ADD INDEX idx_credit_score (credit_score);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,9 @@ Page({
|
|||||||
kpis: [],
|
kpis: [],
|
||||||
bars: [],
|
bars: [],
|
||||||
sourceDist: [],
|
sourceDist: [],
|
||||||
topKeywords: []
|
topKeywords: [],
|
||||||
|
report: null,
|
||||||
|
reportLoading: false
|
||||||
},
|
},
|
||||||
|
|
||||||
formatPercent(value, digits = 2) {
|
formatPercent(value, digits = 2) {
|
||||||
@@ -67,5 +69,74 @@ Page({
|
|||||||
this.setData({ loading: false })
|
this.setData({ loading: false })
|
||||||
if (fromPullDown) wx.stopPullDownRefresh()
|
if (fromPullDown) wx.stopPullDownRefresh()
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async generateReport() {
|
||||||
|
this.setData({ reportLoading: true })
|
||||||
|
try {
|
||||||
|
const report = await request({ url: '/admin/stats/report' })
|
||||||
|
|
||||||
|
// 处理趋势数据,计算进度条宽度
|
||||||
|
const spamTrend = (report.spam_trend || []).map((item) => {
|
||||||
|
const maxBlocked = Math.max(...(report.spam_trend || []).map((r) => r.blocked || 0), 1)
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
blocked_percent: `${Math.max(4, Math.round((item.blocked || 0) / maxBlocked * 100))}%`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const misjudgeTrend = (report.misjudge_trend || []).map((item) => ({
|
||||||
|
...item,
|
||||||
|
rate_percent: `${Math.round((item.misjudge_rate || 0) * 100)}%`
|
||||||
|
}))
|
||||||
|
|
||||||
|
this.setData({
|
||||||
|
report: {
|
||||||
|
...report,
|
||||||
|
spam_trend: spamTrend,
|
||||||
|
misjudge_trend: misjudgeTrend
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
wx.showToast({ title: '报告已生成', icon: 'success' })
|
||||||
|
} finally {
|
||||||
|
this.setData({ reportLoading: false })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
closeReport() {
|
||||||
|
this.setData({ report: null })
|
||||||
|
},
|
||||||
|
|
||||||
|
copyReportText() {
|
||||||
|
const report = this.data.report
|
||||||
|
if (!report) return
|
||||||
|
|
||||||
|
const summary = report.summary || {}
|
||||||
|
const lines = [
|
||||||
|
`【垃圾信息运营报告】`,
|
||||||
|
`报告周期:${report.period}`,
|
||||||
|
`生成时间:${(report.report_date || '').replace('T', ' ').slice(0, 19)}`,
|
||||||
|
'',
|
||||||
|
`【汇总统计】`,
|
||||||
|
`总发布量:${summary.total_posts || 0} 条`,
|
||||||
|
`拦截量:${summary.total_blocked || 0} 条`,
|
||||||
|
`正常发布:${summary.total_published || 0} 条`,
|
||||||
|
`拦截率:${this.formatPercent(summary.blocked_ratio, 2)}`,
|
||||||
|
`复核总数:${summary.total_reviews || 0} 次`,
|
||||||
|
`误判放行:${summary.total_approved || 0} 次`,
|
||||||
|
`平均误判率:${summary.avg_misjudge_rate_text || '0%'}`,
|
||||||
|
'',
|
||||||
|
`【高频风险词 Top10】`,
|
||||||
|
(report.top_keywords || []).slice(0, 10).map((k) => `${k.token}(${k.count}次)`).join('、'),
|
||||||
|
'',
|
||||||
|
`【近7日趋势】`,
|
||||||
|
(report.spam_trend || []).slice(-7).map((t) => `${t.label}: 拦截${t.blocked}条,发布${t.published}条`).join('\n')
|
||||||
|
]
|
||||||
|
|
||||||
|
wx.setClipboardData({
|
||||||
|
data: lines.join('\n'),
|
||||||
|
success: () => wx.showToast({ title: '报告已复制', icon: 'success' })
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -54,4 +54,83 @@
|
|||||||
<text class="tag" wx:for="{{topKeywords}}" wx:key="token">{{item.token}} × {{item.count}}</text>
|
<text class="tag" wx:for="{{topKeywords}}" wx:key="token">{{item.token}} × {{item.count}}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<!-- 生成报告按钮 -->
|
||||||
|
<view class="card fade-up fade-up-delay-3">
|
||||||
|
<button class="btn btn-accent" loading="{{reportLoading}}" bindtap="generateReport">生成运营报告</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 报告展示区域 -->
|
||||||
|
<view class="report-modal" wx:if="{{report}}">
|
||||||
|
<view class="report-header">
|
||||||
|
<view class="report-title">垃圾信息运营报告</view>
|
||||||
|
<view class="report-period">{{report.period}}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="report-close" bindtap="closeReport">×</view>
|
||||||
|
|
||||||
|
<view class="report-section">
|
||||||
|
<view class="report-section-title">汇总统计</view>
|
||||||
|
<view class="grid-3">
|
||||||
|
<view class="report-kpi">
|
||||||
|
<view class="report-kpi-value">{{report.summary.total_posts}}</view>
|
||||||
|
<view class="report-kpi-label">总发布量</view>
|
||||||
|
</view>
|
||||||
|
<view class="report-kpi">
|
||||||
|
<view class="report-kpi-value">{{report.summary.total_blocked}}</view>
|
||||||
|
<view class="report-kpi-label">拦截量</view>
|
||||||
|
</view>
|
||||||
|
<view class="report-kpi">
|
||||||
|
<view class="report-kpi-value">{{report.summary.total_published}}</view>
|
||||||
|
<view class="report-kpi-label">正常发布</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="row">
|
||||||
|
<text class="label">拦截率</text>
|
||||||
|
<text class="value">{{report.summary.blocked_ratio * 100}}%</text>
|
||||||
|
</view>
|
||||||
|
<view class="row">
|
||||||
|
<text class="label">平均误判率</text>
|
||||||
|
<text class="value">{{report.summary.avg_misjudge_rate_text}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="report-section">
|
||||||
|
<view class="report-section-title">垃圾信息数量变化(近14天)</view>
|
||||||
|
<view class="report-trend-item" wx:for="{{report.spam_trend}}" wx:key="date">
|
||||||
|
<view class="row">
|
||||||
|
<text class="label">{{item.label}}</text>
|
||||||
|
<text class="value">拦截 {{item.blocked}} / 发布 {{item.published}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="progress-track">
|
||||||
|
<view class="progress-fill" style="width: {{item.blocked_percent}};"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="report-section">
|
||||||
|
<view class="report-section-title">高频风险词 Top10</view>
|
||||||
|
<view class="chip-group">
|
||||||
|
<text class="tag tag-danger" wx:for="{{report.topKeywords}}" wx:for-item="kw" wx:if="{{index < 10}}" wx:key="token">{{kw.token}} × {{kw.count}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="report-section">
|
||||||
|
<view class="report-section-title">误判率趋势(近14天)</view>
|
||||||
|
<view class="report-trend-item" wx:for="{{report.misjudge_trend}}" wx:key="date">
|
||||||
|
<view class="row">
|
||||||
|
<text class="label">{{item.label}}</text>
|
||||||
|
<text class="value">{{item.misjudge_rate_text}}</text>
|
||||||
|
</view>
|
||||||
|
<view class="progress-track">
|
||||||
|
<view class="progress-fill-safe" style="width: {{item.rate_percent}};"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="btn-row">
|
||||||
|
<button class="btn btn-primary" bindtap="copyReportText">复制报告文本</button>
|
||||||
|
<button class="btn btn-ghost" bindtap="closeReport">关闭</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ const APPEAL_STATUS_TEXT = {
|
|||||||
rejected: '已驳回'
|
rejected: '已驳回'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CATEGORY_LABELS = {
|
||||||
|
fraud: '疑似诈骗',
|
||||||
|
harassment: '疑似骚扰',
|
||||||
|
advertisement: '疑似广告',
|
||||||
|
spam: '疑似垃圾'
|
||||||
|
}
|
||||||
|
|
||||||
function buildPager(total, page, pageSize) {
|
function buildPager(total, page, pageSize) {
|
||||||
const totalValue = Number(total || 0)
|
const totalValue = Number(total || 0)
|
||||||
const totalPages = Math.max(Math.ceil(totalValue / pageSize), 1)
|
const totalPages = Math.max(Math.ceil(totalValue / pageSize), 1)
|
||||||
@@ -105,7 +112,8 @@ Page({
|
|||||||
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19),
|
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19),
|
||||||
review_status_text: REVIEW_STATUS_TEXT[item.manual_review_status] || item.manual_review_status,
|
review_status_text: REVIEW_STATUS_TEXT[item.manual_review_status] || item.manual_review_status,
|
||||||
spam_probability_text: this.formatPercent(item.spam_probability, 2),
|
spam_probability_text: this.formatPercent(item.spam_probability, 2),
|
||||||
appeal_status_text: APPEAL_STATUS_TEXT[item.appeal_status] || item.appeal_status
|
appeal_status_text: APPEAL_STATUS_TEXT[item.appeal_status] || item.appeal_status,
|
||||||
|
category_label: CATEGORY_LABELS[item.category] || ''
|
||||||
}))
|
}))
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -116,6 +124,7 @@ Page({
|
|||||||
...item,
|
...item,
|
||||||
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19),
|
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19),
|
||||||
appeal_status_text: APPEAL_STATUS_TEXT[item.appeal_status] || item.appeal_status,
|
appeal_status_text: APPEAL_STATUS_TEXT[item.appeal_status] || item.appeal_status,
|
||||||
|
category_label: CATEGORY_LABELS[item.category] || '',
|
||||||
appeal_evidence_urls: (item.appeal_evidence_urls || []).map((url) =>
|
appeal_evidence_urls: (item.appeal_evidence_urls || []).map((url) =>
|
||||||
url.startsWith('http') ? url : `${serverBase}${url}`
|
url.startsWith('http') ? url : `${serverBase}${url}`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
<view class="list-item" wx:for="{{intercepts}}" wx:key="id">
|
<view class="list-item" wx:for="{{intercepts}}" wx:key="id">
|
||||||
<view class="item-title">{{item.text}}</view>
|
<view class="item-title">{{item.text}}</view>
|
||||||
<view class="item-sub">用户:{{item.nickname || item.username}} · 垃圾概率:{{item.spam_probability_text}}</view>
|
<view class="item-sub">用户:{{item.nickname || item.username}} · 垃圾概率:{{item.spam_probability_text}}</view>
|
||||||
|
<view class="item-sub" wx:if="{{item.category_label}}">分类标签:<text class="status-spam">{{item.category_label}}</text></view>
|
||||||
<view class="item-sub">复核状态:{{item.review_status_text}} · 申诉状态:{{item.appeal_status_text}}</view>
|
<view class="item-sub">复核状态:{{item.review_status_text}} · 申诉状态:{{item.appeal_status_text}}</view>
|
||||||
<view class="item-sub">发布时间:{{item.created_text}}</view>
|
<view class="item-sub">发布时间:{{item.created_text}}</view>
|
||||||
|
|
||||||
@@ -100,6 +101,7 @@
|
|||||||
<view class="list-item" wx:for="{{appeals}}" wx:key="id">
|
<view class="list-item" wx:for="{{appeals}}" wx:key="id">
|
||||||
<view class="item-title">{{item.text}}</view>
|
<view class="item-title">{{item.text}}</view>
|
||||||
<view class="item-sub">申诉人:{{item.nickname || item.username}} · 当前状态:{{item.appeal_status_text}}</view>
|
<view class="item-sub">申诉人:{{item.nickname || item.username}} · 当前状态:{{item.appeal_status_text}}</view>
|
||||||
|
<view class="item-sub" wx:if="{{item.category_label}}">分类标签:<text class="status-spam">{{item.category_label}}</text></view>
|
||||||
<view class="item-sub">申诉理由类型:{{item.appeal_reason_type || '未选择'}}</view>
|
<view class="item-sub">申诉理由类型:{{item.appeal_reason_type || '未选择'}}</view>
|
||||||
<view class="item-sub">申诉理由:{{item.appeal_reason || '未填写'}}</view>
|
<view class="item-sub">申诉理由:{{item.appeal_reason || '未填写'}}</view>
|
||||||
<view class="item-sub">时间:{{item.created_text}}</view>
|
<view class="item-sub">时间:{{item.created_text}}</view>
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ Page({
|
|||||||
title: '',
|
title: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
is_admin: false,
|
is_admin: false,
|
||||||
password: ''
|
password: '',
|
||||||
|
credit_score: 100
|
||||||
},
|
},
|
||||||
importText: '[{"username":"operator01","password":"123456","nickname":"运营同学","company":"示例科技公司"}]'
|
importText: '[{"username":"operator01","password":"123456","nickname":"运营同学","company":"示例科技公司"}]'
|
||||||
},
|
},
|
||||||
@@ -60,7 +61,8 @@ Page({
|
|||||||
title: row.title || '',
|
title: row.title || '',
|
||||||
phone: row.phone || '',
|
phone: row.phone || '',
|
||||||
is_admin: !!row.is_admin,
|
is_admin: !!row.is_admin,
|
||||||
password: ''
|
password: '',
|
||||||
|
credit_score: row.credit_score || 100
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,6 +27,13 @@
|
|||||||
<view class="list-item" wx:for="{{users}}" wx:key="id">
|
<view class="list-item" wx:for="{{users}}" wx:key="id">
|
||||||
<view class="item-title">{{item.nickname}}({{item.username}})</view>
|
<view class="item-title">{{item.nickname}}({{item.username}})</view>
|
||||||
<view class="item-sub">{{item.company || '未填写公司'}} · {{item.title || '未填写岗位'}} · {{item.is_admin ? '管理员' : '普通用户'}}</view>
|
<view class="item-sub">{{item.company || '未填写公司'}} · {{item.title || '未填写岗位'}} · {{item.is_admin ? '管理员' : '普通用户'}}</view>
|
||||||
|
<view class="row">
|
||||||
|
<text class="label">信誉分</text>
|
||||||
|
<view class="credit-score-bar">
|
||||||
|
<view class="credit-fill" style="width: {{(item.credit_score || 100) / 2}}%;"></view>
|
||||||
|
<text class="credit-value">{{item.credit_score || 100}}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view wx:if="{{editUserId === item.id}}">
|
<view wx:if="{{editUserId === item.id}}">
|
||||||
<input class="input" placeholder="昵称" value="{{editForm.nickname}}" data-field="nickname" bindinput="onEditInput" />
|
<input class="input" placeholder="昵称" value="{{editForm.nickname}}" data-field="nickname" bindinput="onEditInput" />
|
||||||
@@ -34,6 +41,7 @@
|
|||||||
<input class="input" placeholder="岗位" value="{{editForm.title}}" data-field="title" bindinput="onEditInput" />
|
<input class="input" placeholder="岗位" value="{{editForm.title}}" data-field="title" bindinput="onEditInput" />
|
||||||
<input class="input" placeholder="手机号" value="{{editForm.phone}}" data-field="phone" bindinput="onEditInput" />
|
<input class="input" placeholder="手机号" value="{{editForm.phone}}" data-field="phone" bindinput="onEditInput" />
|
||||||
<input class="input" placeholder="新密码(可选)" password value="{{editForm.password}}" data-field="password" bindinput="onEditInput" />
|
<input class="input" placeholder="新密码(可选)" password value="{{editForm.password}}" data-field="password" bindinput="onEditInput" />
|
||||||
|
<input class="input" placeholder="信誉分(0-200)" type="number" value="{{editForm.credit_score}}" data-field="credit_score" bindinput="onEditInput" />
|
||||||
|
|
||||||
<view class="row">
|
<view class="row">
|
||||||
<text class="label">管理员权限</text>
|
<text class="label">管理员权限</text>
|
||||||
|
|||||||
@@ -79,5 +79,84 @@ Page({
|
|||||||
showCancel: false,
|
showCancel: false,
|
||||||
confirmText: '关闭'
|
confirmText: '关闭'
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
generateCSV() {
|
||||||
|
const items = this.data.items
|
||||||
|
if (!items.length) return ''
|
||||||
|
|
||||||
|
const headers = ['文本', '判定结果', '分类标签', '置信度', '垃圾概率', '正常概率', '风险关键词']
|
||||||
|
const rows = items.map((item) => {
|
||||||
|
const prediction = item.prediction === 'spam' ? '垃圾信息' : '正常信息'
|
||||||
|
const categoryLabel = item.category_label || ''
|
||||||
|
const confidence = item.confidence_text || '0%'
|
||||||
|
const spamProb = this.formatPercent(item.spam_probability, 4)
|
||||||
|
const hamProb = this.formatPercent(item.ham_probability, 4)
|
||||||
|
const tokens = (item.reason_tokens || []).map((t) => t.token || t).join('; ')
|
||||||
|
// CSV 转义:文本中的逗号和换行需要处理
|
||||||
|
const text = (item.text || '').replace(/"/g, '""')
|
||||||
|
const tokensEscaped = tokens.replace(/"/g, '""')
|
||||||
|
return `"${text}","${prediction}","${categoryLabel}","${confidence}","${spamProb}","${hamProb}","${tokensEscaped}"`
|
||||||
|
})
|
||||||
|
|
||||||
|
return [headers.join(','), ...rows].join('\n')
|
||||||
|
},
|
||||||
|
|
||||||
|
exportCSV() {
|
||||||
|
const items = this.data.items
|
||||||
|
if (!items.length) {
|
||||||
|
wx.showToast({ title: '暂无识别结果可导出', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const csvContent = this.generateCSV()
|
||||||
|
const timestamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-')
|
||||||
|
const filename = `batch_detect_${timestamp}.csv`
|
||||||
|
|
||||||
|
// 写入临时文件
|
||||||
|
const fs = wx.getFileSystemManager()
|
||||||
|
const tempPath = `${wx.env.USER_DATA_PATH}/${filename}`
|
||||||
|
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(tempPath, csvContent, 'utf8')
|
||||||
|
wx.showModal({
|
||||||
|
title: '导出成功',
|
||||||
|
content: `CSV文件已生成,是否打开查看?\n文件名:${filename}`,
|
||||||
|
confirmText: '打开',
|
||||||
|
cancelText: '关闭',
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
wx.openDocument({
|
||||||
|
filePath: tempPath,
|
||||||
|
fileType: 'csv',
|
||||||
|
showMenu: true,
|
||||||
|
fail: (err) => {
|
||||||
|
console.error('打开文件失败', err)
|
||||||
|
wx.showToast({ title: '打开失败,请检查文件管理器', icon: 'none' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('写入文件失败', err)
|
||||||
|
wx.showToast({ title: '导出失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
copyCSVToClipboard() {
|
||||||
|
const items = this.data.items
|
||||||
|
if (!items.length) {
|
||||||
|
wx.showToast({ title: '暂无识别结果可复制', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const csvContent = this.generateCSV()
|
||||||
|
wx.setClipboardData({
|
||||||
|
data: csvContent,
|
||||||
|
success: () => {
|
||||||
|
wx.showToast({ title: 'CSV内容已复制到剪贴板', icon: 'success' })
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -56,12 +56,20 @@
|
|||||||
|
|
||||||
<view class="card fade-up fade-up-delay-3" wx:if="{{items.length}}">
|
<view class="card fade-up fade-up-delay-3" wx:if="{{items.length}}">
|
||||||
<view class="card-title">明细结果</view>
|
<view class="card-title">明细结果</view>
|
||||||
|
<view class="btn-row" style="margin-bottom: 12rpx;">
|
||||||
|
<button class="btn btn-ghost" bindtap="exportCSV">导出CSV文件</button>
|
||||||
|
<button class="btn btn-ghost" bindtap="copyCSVToClipboard">复制CSV内容</button>
|
||||||
|
</view>
|
||||||
<view class="list-item" wx:for="{{items}}" wx:key="index">
|
<view class="list-item" wx:for="{{items}}" wx:key="index">
|
||||||
<view class="item-title">{{item.text}}</view>
|
<view class="item-title">{{item.text}}</view>
|
||||||
<view class="row">
|
<view class="row">
|
||||||
<text class="label">判定结果</text>
|
<text class="label">判定结果</text>
|
||||||
<text class="{{item.prediction === 'spam' ? 'status-spam' : 'status-ham'}}">{{item.prediction_text}}</text>
|
<text class="{{item.prediction === 'spam' ? 'status-spam' : 'status-ham'}}">{{item.prediction_text}}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="row" wx:if="{{item.category_label}}">
|
||||||
|
<text class="label">分类标签</text>
|
||||||
|
<text class="status-spam">{{item.category_label}}</text>
|
||||||
|
</view>
|
||||||
<view class="row">
|
<view class="row">
|
||||||
<text class="label">置信度</text>
|
<text class="label">置信度</text>
|
||||||
<text class="value">{{item.confidence_text}}</text>
|
<text class="value">{{item.confidence_text}}</text>
|
||||||
|
|||||||
@@ -46,6 +46,11 @@
|
|||||||
<text class="{{result.publish_allowed ? 'status-ham' : 'status-spam'}}">{{result.publish_allowed ? '发布成功' : '已拦截,需申诉'}}</text>
|
<text class="{{result.publish_allowed ? 'status-ham' : 'status-spam'}}">{{result.publish_allowed ? '发布成功' : '已拦截,需申诉'}}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="row" wx:if="{{result.detect.category_label}}">
|
||||||
|
<text class="label">分类标签</text>
|
||||||
|
<text class="status-spam">{{result.detect.category_label}}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="row">
|
<view class="row">
|
||||||
<text class="label">模型判断</text>
|
<text class="label">模型判断</text>
|
||||||
<text class="value">{{result.detect.prediction_text}}</text>
|
<text class="value">{{result.detect.prediction_text}}</text>
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ const APPEAL_STATUS_TEXT = {
|
|||||||
rejected: '已驳回'
|
rejected: '已驳回'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const CATEGORY_LABELS = {
|
||||||
|
fraud: '疑似诈骗',
|
||||||
|
harassment: '疑似骚扰',
|
||||||
|
advertisement: '疑似广告',
|
||||||
|
spam: '疑似垃圾'
|
||||||
|
}
|
||||||
|
|
||||||
const REASON_TYPE_OPTIONS = [
|
const REASON_TYPE_OPTIONS = [
|
||||||
{ value: '', label: '请选择申诉理由类型' },
|
{ value: '', label: '请选择申诉理由类型' },
|
||||||
{ value: '正常活动文案', label: '正常活动文案' },
|
{ value: '正常活动文案', label: '正常活动文案' },
|
||||||
@@ -86,7 +93,8 @@ Page({
|
|||||||
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19),
|
created_text: (item.created_at || '').replace('T', ' ').slice(0, 19),
|
||||||
spam_probability_text: this.formatPercent(item.spam_probability, 2),
|
spam_probability_text: this.formatPercent(item.spam_probability, 2),
|
||||||
visibility_text: VIS_LABEL[item.visibility] || item.visibility,
|
visibility_text: VIS_LABEL[item.visibility] || item.visibility,
|
||||||
appeal_status_text: APPEAL_STATUS_TEXT[item.appeal_status] || item.appeal_status
|
appeal_status_text: APPEAL_STATUS_TEXT[item.appeal_status] || item.appeal_status,
|
||||||
|
category_label: CATEGORY_LABELS[item.category] || ''
|
||||||
}))
|
}))
|
||||||
this.setData({ list })
|
this.setData({ list })
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -35,6 +35,11 @@
|
|||||||
<text class="{{item.status === 'blocked' ? 'status-spam' : 'status-ham'}}">{{item.status === 'blocked' ? '已拦截' : '已发布'}}</text>
|
<text class="{{item.status === 'blocked' ? 'status-spam' : 'status-ham'}}">{{item.status === 'blocked' ? '已拦截' : '已发布'}}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="row" wx:if="{{item.category_label}}">
|
||||||
|
<text class="label">分类标签</text>
|
||||||
|
<text class="status-spam">{{item.category_label}}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="row">
|
<view class="row">
|
||||||
<text class="label">垃圾概率</text>
|
<text class="label">垃圾概率</text>
|
||||||
<text class="value">{{item.spam_probability_text}}</text>
|
<text class="value">{{item.spam_probability_text}}</text>
|
||||||
|
|||||||
@@ -30,6 +30,10 @@
|
|||||||
"include": []
|
"include": []
|
||||||
},
|
},
|
||||||
"appid": "wx42ba28b8e545ba14",
|
"appid": "wx42ba28b8e545ba14",
|
||||||
"editorSetting": {},
|
"editorSetting": {
|
||||||
"libVersion": "3.15.0"
|
"tabIndent": "tab",
|
||||||
|
"tabSize": 4
|
||||||
|
},
|
||||||
|
"libVersion": "3.15.0",
|
||||||
|
"condition": {}
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,9 @@
|
|||||||
"libVersion": "3.15.0",
|
"libVersion": "3.15.0",
|
||||||
"projectname": "miniprogram",
|
"projectname": "miniprogram",
|
||||||
"setting": {
|
"setting": {
|
||||||
"urlCheck": true,
|
"urlCheck": false,
|
||||||
"coverView": true,
|
"coverView": true,
|
||||||
"lazyloadPlaceholderEnable": false,
|
"lazyloadPlaceholderEnable": false,
|
||||||
"skylineRenderEnable": false,
|
|
||||||
"preloadBackgroundData": false,
|
"preloadBackgroundData": false,
|
||||||
"autoAudits": false,
|
"autoAudits": false,
|
||||||
"showShadowRootInWxmlPanel": true,
|
"showShadowRootInWxmlPanel": true,
|
||||||
@@ -17,5 +16,6 @@
|
|||||||
"checkInvalidKey": true,
|
"checkInvalidKey": true,
|
||||||
"ignoreDevUnusedFiles": true,
|
"ignoreDevUnusedFiles": true,
|
||||||
"bigPackageSizeSupport": false
|
"bigPackageSizeSupport": false
|
||||||
}
|
},
|
||||||
|
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档:https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html"
|
||||||
}
|
}
|
||||||
257
设计风格.md
Normal file
257
设计风格.md
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
# Design System Inspired by xAI
|
||||||
|
|
||||||
|
## 1. Visual Theme & Atmosphere
|
||||||
|
|
||||||
|
xAI's website is a masterclass in dark-first, monospace-driven brutalist minimalism -- a design system that feels like it was built by engineers who understand that restraint is the ultimate form of sophistication. The entire experience is anchored to an almost-black background (`#1f2228`) with pure white text (`#ffffff`), creating a high-contrast, terminal-inspired aesthetic that signals deep technical credibility. There are no gradients, no decorative illustrations, no color accents competing for attention. This is a site that communicates through absence.
|
||||||
|
|
||||||
|
The typographic system is split between two carefully chosen typefaces. `GeistMono` (Vercel's monospace font) handles display-level headlines at an extraordinary 320px with weight 300, and also serves as the button typeface in uppercase with tracked-out letter-spacing (1.4px). `universalSans` handles all body and secondary heading text with a clean, geometric sans-serif voice. The monospace-as-display-font choice is the defining aesthetic decision -- it positions xAI not as a consumer product but as infrastructure, as something built by people who live in terminals.
|
||||||
|
|
||||||
|
The spacing system operates on an 8px base grid with values concentrated at the small end (4px, 8px, 24px, 48px), reflecting a dense, information-focused layout philosophy. Border radius is minimal -- the site barely rounds anything, maintaining sharp, architectural edges. There are no decorative shadows, no gradients, no layered elevation. Depth is communicated purely through contrast and whitespace.
|
||||||
|
|
||||||
|
**Key Characteristics:**
|
||||||
|
- Pure dark theme: `#1f2228` background with `#ffffff` text -- no gray middle ground
|
||||||
|
- GeistMono at extreme display sizes (320px, weight 300) -- monospace as luxury
|
||||||
|
- Uppercase monospace buttons with 1.4px letter-spacing -- technical, commanding
|
||||||
|
- universalSans for body text at 16px/1.5 and headings at 30px/1.2 -- clean contrast
|
||||||
|
- Zero decorative elements: no shadows, no gradients, no colored accents
|
||||||
|
- 8px spacing grid with a sparse, deliberate scale
|
||||||
|
- Heroicons SVG icon system -- minimal, functional
|
||||||
|
- Tailwind CSS with arbitrary values -- utility-first engineering approach
|
||||||
|
|
||||||
|
## 2. Color Palette & Roles
|
||||||
|
|
||||||
|
### Primary
|
||||||
|
- **Pure White** (`#ffffff`): The singular text color, link color, and all foreground elements. In xAI's system, white is not a background -- it is the voice.
|
||||||
|
- **Dark Background** (`#1f2228`): The canvas. A warm near-black with a subtle blue undertone (not pure black, not neutral gray). This specific hue prevents the harsh eye strain of `#000000` while maintaining deep darkness.
|
||||||
|
|
||||||
|
### Interactive
|
||||||
|
- **White Default** (`#ffffff`): Link and interactive element color in default state.
|
||||||
|
- **White Muted** (`rgba(255, 255, 255, 0.5)`): Hover state for links -- a deliberate dimming rather than brightening, which is unusual and distinctive.
|
||||||
|
- **White Subtle** (`rgba(255, 255, 255, 0.2)`): Borders, dividers, and subtle surface treatments.
|
||||||
|
- **Ring Blue** (`rgb(59, 130, 246) / 0.5`): Tailwind's default focus ring color (`--tw-ring-color`), used for keyboard accessibility focus states.
|
||||||
|
|
||||||
|
### Surface & Borders
|
||||||
|
- **Surface Elevated** (`rgba(255, 255, 255, 0.05)`): Subtle card backgrounds and hover surfaces -- barely visible lift.
|
||||||
|
- **Surface Hover** (`rgba(255, 255, 255, 0.08)`): Slightly more visible hover state for interactive containers.
|
||||||
|
- **Border Default** (`rgba(255, 255, 255, 0.1)`): Standard border for cards, dividers, and containers.
|
||||||
|
- **Border Strong** (`rgba(255, 255, 255, 0.2)`): Emphasized borders for active states and button outlines.
|
||||||
|
|
||||||
|
### Functional
|
||||||
|
- **Text Primary** (`#ffffff`): All headings, body text, labels.
|
||||||
|
- **Text Secondary** (`rgba(255, 255, 255, 0.7)`): Descriptions, captions, supporting text.
|
||||||
|
- **Text Tertiary** (`rgba(255, 255, 255, 0.5)`): Muted labels, placeholder text, timestamps.
|
||||||
|
- **Text Quaternary** (`rgba(255, 255, 255, 0.3)`): Disabled text, very subtle annotations.
|
||||||
|
|
||||||
|
## 3. Typography Rules
|
||||||
|
|
||||||
|
### Font Family
|
||||||
|
- **Display / Buttons**: `GeistMono`, with fallback: `ui-monospace, SFMono-Regular, Roboto Mono, Menlo, Monaco, Liberation Mono, DejaVu Sans Mono, Courier New`
|
||||||
|
- **Body / Headings**: `universalSans`, with fallback: `universalSans Fallback`
|
||||||
|
|
||||||
|
### Hierarchy
|
||||||
|
|
||||||
|
| Role | Font | Size | Weight | Line Height | Letter Spacing | Transform | Notes |
|
||||||
|
|------|------|------|--------|-------------|----------------|-----------|-------|
|
||||||
|
| Display Hero | GeistMono | 320px (20rem) | 300 | 1.50 | normal | none | Extreme scale, monospace luxury |
|
||||||
|
| Section Heading | universalSans | 30px (1.88rem) | 400 | 1.20 (tight) | normal | none | Clean sans-serif contrast |
|
||||||
|
| Body | universalSans | 16px (1rem) | 400 | 1.50 | normal | none | Standard reading text |
|
||||||
|
| Button | GeistMono | 14px (0.88rem) | 400 | 1.43 | 1.4px | uppercase | Tracked monospace, commanding |
|
||||||
|
| Label / Caption | universalSans | 14px (0.88rem) | 400 | 1.50 | normal | none | Supporting text |
|
||||||
|
| Small / Meta | universalSans | 12px (0.75rem) | 400 | 1.50 | normal | none | Timestamps, footnotes |
|
||||||
|
|
||||||
|
### Principles
|
||||||
|
- **Monospace as display**: GeistMono at 320px is not a gimmick -- it is the brand statement. The fixed-width characters at extreme scale create a rhythmic, architectural quality that no proportional font can achieve.
|
||||||
|
- **Light weight at scale**: Weight 300 for the 320px headline prevents the monospace from feeling heavy or brutish at extreme sizes. It reads as precise, not overwhelming.
|
||||||
|
- **Uppercase buttons**: All button text is uppercase GeistMono with 1.4px letter-spacing. This creates a distinctly technical, almost command-line aesthetic for interactive elements.
|
||||||
|
- **Sans-serif for reading**: universalSans at 16px/1.5 provides excellent readability for body content, creating a clean contrast against the monospace display elements.
|
||||||
|
- **Two-font clarity**: The system uses exactly two typefaces with clear roles -- monospace for impact and interaction, sans-serif for information and reading. No overlap, no ambiguity.
|
||||||
|
|
||||||
|
## 4. Component Stylings
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
**Primary (White on Dark)**
|
||||||
|
- Background: `#ffffff`
|
||||||
|
- Text: `#1f2228`
|
||||||
|
- Padding: 12px 24px
|
||||||
|
- Radius: 0px (sharp corners)
|
||||||
|
- Font: GeistMono 14px weight 400, uppercase, letter-spacing 1.4px
|
||||||
|
- Hover: `rgba(255, 255, 255, 0.9)` background
|
||||||
|
- Use: Primary CTA ("TRY GROK", "GET STARTED")
|
||||||
|
|
||||||
|
**Ghost / Outlined**
|
||||||
|
- Background: transparent
|
||||||
|
- Text: `#ffffff`
|
||||||
|
- Padding: 12px 24px
|
||||||
|
- Radius: 0px
|
||||||
|
- Border: `1px solid rgba(255, 255, 255, 0.2)`
|
||||||
|
- Font: GeistMono 14px weight 400, uppercase, letter-spacing 1.4px
|
||||||
|
- Hover: `rgba(255, 255, 255, 0.05)` background
|
||||||
|
- Use: Secondary actions ("LEARN MORE", "VIEW API")
|
||||||
|
|
||||||
|
**Text Link**
|
||||||
|
- Background: none
|
||||||
|
- Text: `#ffffff`
|
||||||
|
- Font: universalSans 16px weight 400
|
||||||
|
- Hover: `rgba(255, 255, 255, 0.5)` -- dims on hover
|
||||||
|
- Use: Inline links, navigation items
|
||||||
|
|
||||||
|
### Cards & Containers
|
||||||
|
- Background: `rgba(255, 255, 255, 0.03)` or transparent
|
||||||
|
- Border: `1px solid rgba(255, 255, 255, 0.1)`
|
||||||
|
- Radius: 0px (sharp) or 4px (subtle)
|
||||||
|
- Shadow: none -- xAI does not use box shadows
|
||||||
|
- Hover: border shifts to `rgba(255, 255, 255, 0.2)`
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
- Dark background matching page (`#1f2228`)
|
||||||
|
- Brand logotype: white text, left-aligned
|
||||||
|
- Links: universalSans 14px weight 400, `#ffffff` text
|
||||||
|
- Hover: `rgba(255, 255, 255, 0.5)` text color
|
||||||
|
- CTA: white primary button, right-aligned
|
||||||
|
- Mobile: hamburger toggle
|
||||||
|
|
||||||
|
### Badges / Tags
|
||||||
|
**Monospace Tag**
|
||||||
|
- Background: transparent
|
||||||
|
- Text: `#ffffff`
|
||||||
|
- Padding: 4px 8px
|
||||||
|
- Border: `1px solid rgba(255, 255, 255, 0.2)`
|
||||||
|
- Radius: 0px
|
||||||
|
- Font: GeistMono 12px uppercase, letter-spacing 1px
|
||||||
|
|
||||||
|
### Inputs & Forms
|
||||||
|
- Background: transparent or `rgba(255, 255, 255, 0.05)`
|
||||||
|
- Border: `1px solid rgba(255, 255, 255, 0.2)`
|
||||||
|
- Radius: 0px
|
||||||
|
- Focus: ring with `rgb(59, 130, 246) / 0.5`
|
||||||
|
- Text: `#ffffff`
|
||||||
|
- Placeholder: `rgba(255, 255, 255, 0.3)`
|
||||||
|
- Label: `rgba(255, 255, 255, 0.7)`, universalSans 14px
|
||||||
|
|
||||||
|
## 5. Layout Principles
|
||||||
|
|
||||||
|
### Spacing System
|
||||||
|
- Base unit: 8px
|
||||||
|
- Scale: 4px, 8px, 24px, 48px
|
||||||
|
- The scale is deliberately sparse -- xAI avoids granular spacing distinctions, preferring large jumps that create clear visual hierarchy through whitespace alone
|
||||||
|
|
||||||
|
### Grid & Container
|
||||||
|
- Max content width: approximately 1200px
|
||||||
|
- Hero: full-viewport height with massive centered monospace headline
|
||||||
|
- Feature sections: simple vertical stacking with generous section padding (48px-96px)
|
||||||
|
- Two-column layouts for feature descriptions at desktop
|
||||||
|
- Full-width dark sections maintain the single dark background throughout
|
||||||
|
|
||||||
|
### Whitespace Philosophy
|
||||||
|
- **Extreme generosity**: xAI uses vast amounts of whitespace. The 320px headline with 48px+ surrounding padding creates a sense of emptiness that is itself a design statement -- the content is so important it needs room to breathe.
|
||||||
|
- **Vertical rhythm over horizontal density**: Content stacks vertically with large gaps between sections rather than packing horizontally. This creates a scroll-driven experience that feels deliberate and cinematic.
|
||||||
|
- **No visual noise**: The absence of decorative elements, borders between sections, and color variety means whitespace is the primary structural tool.
|
||||||
|
|
||||||
|
### Breakpoints
|
||||||
|
- 2000px, 1536px, 1280px, 1024px, 1000px, 768px, 640px
|
||||||
|
- Tailwind responsive modifiers drive breakpoint behavior
|
||||||
|
|
||||||
|
### Border Radius Scale
|
||||||
|
- Sharp (0px): Primary treatment for buttons, cards, inputs -- the default
|
||||||
|
- Subtle (4px): Occasional softening on secondary containers
|
||||||
|
- The near-zero radius philosophy is core to the brand's brutalist identity
|
||||||
|
|
||||||
|
## 6. Depth & Elevation
|
||||||
|
|
||||||
|
| Level | Treatment | Use |
|
||||||
|
|-------|-----------|-----|
|
||||||
|
| Flat (Level 0) | No shadow, no border | Page background, body content |
|
||||||
|
| Surface (Level 1) | `rgba(255,255,255,0.03)` background | Subtle card surfaces |
|
||||||
|
| Bordered (Level 2) | `1px solid rgba(255,255,255,0.1)` border | Cards, containers, dividers |
|
||||||
|
| Active (Level 3) | `1px solid rgba(255,255,255,0.2)` border | Hover states, active elements |
|
||||||
|
| Focus (Accessibility) | `ring` with `rgb(59,130,246)/0.5` | Keyboard focus indicator |
|
||||||
|
|
||||||
|
**Elevation Philosophy**: xAI rejects the conventional shadow-based elevation system entirely. There are no box-shadows anywhere on the site. Instead, depth is communicated through three mechanisms: (1) opacity-based borders that brighten on interaction, creating a sense of elements "activating" rather than lifting; (2) extremely subtle background opacity shifts (`0.03` to `0.08`) that create barely-perceptible surface differentiation; and (3) the massive scale contrast between the 320px display type and 16px body text, which creates typographic depth. This is elevation through contrast and opacity, not through simulated light and shadow.
|
||||||
|
|
||||||
|
## 7. Do's and Don'ts
|
||||||
|
|
||||||
|
### Do
|
||||||
|
- Use `#1f2228` as the universal background -- never pure black `#000000`
|
||||||
|
- Use GeistMono for all display headlines and button text -- monospace IS the brand
|
||||||
|
- Apply uppercase + 1.4px letter-spacing to all button labels
|
||||||
|
- Use weight 300 for the massive display headline (320px)
|
||||||
|
- Keep borders at `rgba(255, 255, 255, 0.1)` -- barely visible, not absent
|
||||||
|
- Dim interactive elements on hover to `rgba(255, 255, 255, 0.5)` -- the reverse of convention
|
||||||
|
- Maintain sharp corners (0px radius) as the default -- brutalist precision
|
||||||
|
- Use universalSans for all body and reading text at 16px/1.5
|
||||||
|
|
||||||
|
### Don't
|
||||||
|
- Don't use box-shadows -- xAI has zero shadow elevation
|
||||||
|
- Don't introduce color accents beyond white and the dark background -- the monochromatic palette is sacred
|
||||||
|
- Don't use large border-radius (8px+, pill shapes) -- the sharp edge is intentional
|
||||||
|
- Don't use bold weights (600-700) for headlines -- weight 300-400 only
|
||||||
|
- Don't brighten elements on hover -- xAI dims to `0.5` opacity instead
|
||||||
|
- Don't add decorative gradients, illustrations, or color blocks
|
||||||
|
- Don't use proportional fonts for buttons -- GeistMono uppercase is mandatory
|
||||||
|
- Don't use colored status indicators unless absolutely necessary -- keep everything in the white/dark spectrum
|
||||||
|
|
||||||
|
## 8. Responsive Behavior
|
||||||
|
|
||||||
|
### Breakpoints
|
||||||
|
| Name | Width | Key Changes |
|
||||||
|
|------|-------|-------------|
|
||||||
|
| Mobile | <640px | Single column, hero headline scales dramatically down |
|
||||||
|
| Small Tablet | 640-768px | Slight increase in padding |
|
||||||
|
| Tablet | 768-1024px | Two-column layouts begin, heading sizes increase |
|
||||||
|
| Desktop | 1024-1280px | Full layout, generous whitespace |
|
||||||
|
| Large | 1280-1536px | Wider containers, more breathing room |
|
||||||
|
| Extra Large | 1536-2000px | Maximum content width, centered |
|
||||||
|
| Ultra | >2000px | Content stays centered, extreme margins |
|
||||||
|
|
||||||
|
### Touch Targets
|
||||||
|
- Buttons use 12px 24px padding for comfortable touch
|
||||||
|
- Navigation links spaced with 24px gaps
|
||||||
|
- Minimum tap target: 44px height
|
||||||
|
- Mobile: full-width buttons for easy thumb reach
|
||||||
|
|
||||||
|
### Collapsing Strategy
|
||||||
|
- Hero: 320px monospace headline scales down dramatically (to ~48px-64px on mobile)
|
||||||
|
- Navigation: horizontal links collapse to hamburger menu
|
||||||
|
- Feature sections: two-column to single-column stacking
|
||||||
|
- Section padding: 96px -> 48px -> 24px across breakpoints
|
||||||
|
- Massive display type is the first thing to resize -- it must remain impactful but not overflow
|
||||||
|
|
||||||
|
### Image Behavior
|
||||||
|
- Minimal imagery -- the site relies on typography and whitespace
|
||||||
|
- Any product screenshots maintain sharp corners
|
||||||
|
- Full-width media scales proportionally with viewport
|
||||||
|
|
||||||
|
## 9. Agent Prompt Guide
|
||||||
|
|
||||||
|
### Quick Color Reference
|
||||||
|
- Background: Dark (`#1f2228`)
|
||||||
|
- Text Primary: White (`#ffffff`)
|
||||||
|
- Text Secondary: White 70% (`rgba(255, 255, 255, 0.7)`)
|
||||||
|
- Text Muted: White 50% (`rgba(255, 255, 255, 0.5)`)
|
||||||
|
- Text Disabled: White 30% (`rgba(255, 255, 255, 0.3)`)
|
||||||
|
- Border Default: White 10% (`rgba(255, 255, 255, 0.1)`)
|
||||||
|
- Border Strong: White 20% (`rgba(255, 255, 255, 0.2)`)
|
||||||
|
- Surface Subtle: White 3% (`rgba(255, 255, 255, 0.03)`)
|
||||||
|
- Surface Hover: White 8% (`rgba(255, 255, 255, 0.08)`)
|
||||||
|
- Focus Ring: Blue (`rgb(59, 130, 246)` at 50% opacity)
|
||||||
|
- Button Primary BG: White (`#ffffff`), text Dark (`#1f2228`)
|
||||||
|
|
||||||
|
### Example Component Prompts
|
||||||
|
- "Create a hero section on #1f2228 background. Headline in GeistMono at 72px weight 300, color #ffffff, centered. Subtitle in universalSans 18px weight 400, rgba(255,255,255,0.7), max-width 600px centered. Two buttons: primary (white bg, #1f2228 text, 0px radius, GeistMono 14px uppercase, 1.4px letter-spacing, 12px 24px padding) and ghost (transparent bg, 1px solid rgba(255,255,255,0.2), white text, same font treatment)."
|
||||||
|
- "Design a card: transparent or rgba(255,255,255,0.03) background, 1px solid rgba(255,255,255,0.1) border, 0px radius, 24px padding. No shadow. Title in universalSans 22px weight 400, #ffffff. Body in universalSans 16px weight 400, rgba(255,255,255,0.7), line-height 1.5. Hover: border changes to rgba(255,255,255,0.2)."
|
||||||
|
- "Build navigation: #1f2228 background, full-width. Brand text left (GeistMono 14px uppercase). Links in universalSans 14px #ffffff with hover to rgba(255,255,255,0.5). White primary button right-aligned (GeistMono 14px uppercase, 1.4px letter-spacing)."
|
||||||
|
- "Create a form: dark background #1f2228. Label in universalSans 14px rgba(255,255,255,0.7). Input with transparent bg, 1px solid rgba(255,255,255,0.2) border, 0px radius, white text 16px universalSans. Focus: blue ring rgb(59,130,246)/0.5. Placeholder: rgba(255,255,255,0.3)."
|
||||||
|
- "Design a monospace tag/badge: transparent bg, 1px solid rgba(255,255,255,0.2), 0px radius, GeistMono 12px uppercase, 1px letter-spacing, white text, 4px 8px padding."
|
||||||
|
|
||||||
|
### Iteration Guide
|
||||||
|
1. Always start with `#1f2228` background -- never use pure black or gray backgrounds
|
||||||
|
2. GeistMono for display and buttons, universalSans for everything else -- never mix these roles
|
||||||
|
3. All buttons must be GeistMono uppercase with 1.4px letter-spacing -- this is non-negotiable
|
||||||
|
4. No shadows, ever -- depth comes from border opacity and background opacity only
|
||||||
|
5. Borders are always white with low opacity (0.1 default, 0.2 for emphasis)
|
||||||
|
6. Hover behavior dims to 0.5 opacity rather than brightening -- the reverse of most systems
|
||||||
|
7. Sharp corners (0px) by default -- only use 4px for specific secondary containers
|
||||||
|
8. Body text at 16px universalSans with 1.5 line-height for comfortable reading
|
||||||
|
9. Generous section padding (48px-96px) -- let content breathe in the darkness
|
||||||
|
10. The monochromatic white-on-dark palette is absolute -- resist adding color unless critical for function
|
||||||
Reference in New Issue
Block a user