```
feat(api): 新增AI生成内容检测功能 - 添加aigc-detect路由接口,用于检测图像是否为AI生成内容 - 集成阿里云AIGC检测服务,配置相关API参数 - 实现积分扣除机制,每次检测消耗1积分 feat(frontend): 前端集成AI生成检测UI组件 - 添加formatAigcDetectionResult函数用于格式化检测结果显示 - 实现detectAigcImage函数处理检测请求和响应 - 在图片展示区域添加AI生成检测按钮和结果展示框 - 更新图片容器布局,优化下载按钮显示效果 refactor(service): 优化积分管理逻辑 - 新增try_deduct_points函数实现积分预检查和扣除 - 改进错误处理机制,确保数据库事务安全 refactor(history): 过滤AI检测记录 - 在历史记录查询中排除AI生成检测类型的数据 - 使用image_urls字段过滤检测相关的记录 ```
This commit is contained in:
parent
2bcb53e2eb
commit
3d479c52e7
@ -22,6 +22,7 @@ from services.generation_service import (
|
|||||||
validate_video_request,
|
validate_video_request,
|
||||||
start_async_video_task,
|
start_async_video_task,
|
||||||
)
|
)
|
||||||
|
from services.aigc_detector_service import detect_aigc_image
|
||||||
|
|
||||||
api_bp = Blueprint("api", __name__)
|
api_bp = Blueprint("api", __name__)
|
||||||
|
|
||||||
@ -136,6 +137,22 @@ def generate():
|
|||||||
return jsonify({"error": str(e)}), 500
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@api_bp.route("/api/aigc-detect", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def aigc_detect():
|
||||||
|
try:
|
||||||
|
user_id = session.get("user_id")
|
||||||
|
data = request.json if request.is_json else request.form
|
||||||
|
result, status_code = detect_aigc_image(
|
||||||
|
user_id,
|
||||||
|
(data.get("image_url") or data.get("url") or "").strip(),
|
||||||
|
request.url_root,
|
||||||
|
)
|
||||||
|
return jsonify(result), status_code
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
@api_bp.route("/api/video/generate", methods=["POST"])
|
@api_bp.route("/api/video/generate", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def video_generate():
|
def video_generate():
|
||||||
|
|||||||
@ -61,6 +61,10 @@ class Config:
|
|||||||
# 阿里云短信配置
|
# 阿里云短信配置
|
||||||
ALIBABA_CLOUD_ACCESS_KEY_ID = "LTAI5tAbHKxmPKVPYsABEdyq"
|
ALIBABA_CLOUD_ACCESS_KEY_ID = "LTAI5tAbHKxmPKVPYsABEdyq"
|
||||||
ALIBABA_CLOUD_ACCESS_KEY_SECRET = "v6URREddBqvGfwZrWH1DWoxs3w6RxZ"
|
ALIBABA_CLOUD_ACCESS_KEY_SECRET = "v6URREddBqvGfwZrWH1DWoxs3w6RxZ"
|
||||||
|
AIGC_DETECTOR_SERVICE = os.getenv("AIGC_DETECTOR_SERVICE", "aigcDetector_ultra")
|
||||||
|
AIGC_DETECTOR_ENDPOINT = os.getenv("AIGC_DETECTOR_ENDPOINT", "green-cip.cn-beijing.aliyuncs.com")
|
||||||
|
AIGC_DETECTOR_REGION_ID = os.getenv("AIGC_DETECTOR_REGION_ID", "cn-beijing")
|
||||||
|
AIGC_DETECTOR_COST = int(os.getenv("AIGC_DETECTOR_COST", "1"))
|
||||||
SMS_SIGN_NAME = "速通互联验证码"
|
SMS_SIGN_NAME = "速通互联验证码"
|
||||||
SMS_TEMPLATE_CODE = "100001"
|
SMS_TEMPLATE_CODE = "100001"
|
||||||
SMS_NEED_PARAM = False # 该模板需要参数,如使用系统赠送模板请改为 False
|
SMS_NEED_PARAM = False # 该模板需要参数,如使用系统赠送模板请改为 False
|
||||||
|
|||||||
228
services/aigc_detector_service.py
Normal file
228
services/aigc_detector_service.py
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
import json
|
||||||
|
import io
|
||||||
|
import uuid
|
||||||
|
from urllib.parse import unquote, urljoin, urlparse
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from alibabacloud_tea_openapi.client import Client as OpenApiClient
|
||||||
|
from alibabacloud_tea_openapi import models as open_api_models
|
||||||
|
from alibabacloud_tea_openapi import utils_models as open_api_util_models
|
||||||
|
from alibabacloud_tea_util import models as util_models
|
||||||
|
|
||||||
|
from config import Config
|
||||||
|
from extensions import db, s3_client
|
||||||
|
from models import GenerationRecord
|
||||||
|
from services.generation_service import refund_points, try_deduct_points
|
||||||
|
from services.logger import system_logger
|
||||||
|
from utils import get_proxied_url
|
||||||
|
|
||||||
|
|
||||||
|
def _create_client():
|
||||||
|
config = open_api_models.Config(
|
||||||
|
access_key_id=Config.ALIBABA_CLOUD_ACCESS_KEY_ID,
|
||||||
|
access_key_secret=Config.ALIBABA_CLOUD_ACCESS_KEY_SECRET,
|
||||||
|
region_id=Config.AIGC_DETECTOR_REGION_ID,
|
||||||
|
endpoint=Config.AIGC_DETECTOR_ENDPOINT,
|
||||||
|
)
|
||||||
|
return OpenApiClient(config)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_image_url(image_url, request_root):
|
||||||
|
if not image_url:
|
||||||
|
return None
|
||||||
|
if image_url.startswith(("http://", "https://")):
|
||||||
|
return image_url
|
||||||
|
return urljoin(request_root, image_url.lstrip("/"))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_own_file_url(image_url, request_root):
|
||||||
|
public_path = Config.MINIO["public_url"].rstrip("/") + "/"
|
||||||
|
parsed = urlparse(image_url)
|
||||||
|
if not parsed.scheme:
|
||||||
|
return image_url.startswith(public_path)
|
||||||
|
|
||||||
|
root = urlparse(request_root)
|
||||||
|
return parsed.netloc == root.netloc and parsed.path.startswith(public_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_minio_object_key(image_url, request_root):
|
||||||
|
public_path = Config.MINIO["public_url"].rstrip("/") + "/"
|
||||||
|
parsed = urlparse(_normalize_image_url(image_url, request_root))
|
||||||
|
minio_endpoint = urlparse(Config.MINIO["endpoint"])
|
||||||
|
bucket_path = f"/{Config.MINIO['bucket']}/"
|
||||||
|
|
||||||
|
if parsed.netloc == urlparse(request_root).netloc and parsed.path.startswith(public_path):
|
||||||
|
return unquote(parsed.path[len(public_path):])
|
||||||
|
|
||||||
|
if parsed.netloc == minio_endpoint.netloc and parsed.path.startswith(bucket_path):
|
||||||
|
return unquote(parsed.path[len(bucket_path):])
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_presigned_minio_url(object_key):
|
||||||
|
return s3_client.generate_presigned_url(
|
||||||
|
"get_object",
|
||||||
|
Params={"Bucket": Config.MINIO["bucket"], "Key": object_key},
|
||||||
|
ExpiresIn=1800,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _mirror_image_for_detection(image_url, request_root):
|
||||||
|
"""把外部临时图转存到 MinIO,并返回给阿里云可直接下载的签名 URL。"""
|
||||||
|
normalized_url = _normalize_image_url(image_url, request_root)
|
||||||
|
object_key = _extract_minio_object_key(normalized_url, request_root)
|
||||||
|
if object_key:
|
||||||
|
return _get_presigned_minio_url(object_key)
|
||||||
|
|
||||||
|
resp = requests.get(get_proxied_url(normalized_url), timeout=Config.PROXY_TIMEOUT_SHORT)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
content_type = resp.headers.get("Content-Type") or "image/png"
|
||||||
|
ext = ".png"
|
||||||
|
if "jpeg" in content_type or "jpg" in content_type:
|
||||||
|
ext = ".jpg"
|
||||||
|
elif "webp" in content_type:
|
||||||
|
ext = ".webp"
|
||||||
|
|
||||||
|
filename = f"aigc-detect-{uuid.uuid4().hex}{ext}"
|
||||||
|
s3_client.upload_fileobj(
|
||||||
|
io.BytesIO(resp.content),
|
||||||
|
Config.MINIO["bucket"],
|
||||||
|
filename,
|
||||||
|
ExtraArgs={"ContentType": content_type},
|
||||||
|
)
|
||||||
|
return _get_presigned_minio_url(filename)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_result(resp):
|
||||||
|
body = resp.get("body", resp)
|
||||||
|
data = body.get("Data") or body.get("data") or {}
|
||||||
|
if isinstance(data, str):
|
||||||
|
try:
|
||||||
|
data = json.loads(data)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
data = {"raw": data}
|
||||||
|
|
||||||
|
result = data.get("Result") or data.get("result") or []
|
||||||
|
result_items = result if isinstance(result, list) else []
|
||||||
|
first = result_items[0] if result_items else {}
|
||||||
|
if not isinstance(first, dict):
|
||||||
|
first = {"raw": first}
|
||||||
|
|
||||||
|
label = first.get("Label") or first.get("label") or data.get("Label") or data.get("label")
|
||||||
|
confidence = (
|
||||||
|
first.get("Confidence")
|
||||||
|
or first.get("confidence")
|
||||||
|
or first.get("Rate")
|
||||||
|
or first.get("rate")
|
||||||
|
or data.get("Confidence")
|
||||||
|
or data.get("confidence")
|
||||||
|
)
|
||||||
|
risk_level = (
|
||||||
|
first.get("RiskLevel")
|
||||||
|
or first.get("riskLevel")
|
||||||
|
or data.get("RiskLevel")
|
||||||
|
or data.get("riskLevel")
|
||||||
|
)
|
||||||
|
suggestion = (
|
||||||
|
first.get("Suggestion")
|
||||||
|
or first.get("suggestion")
|
||||||
|
or data.get("Suggestion")
|
||||||
|
or data.get("suggestion")
|
||||||
|
)
|
||||||
|
description = (
|
||||||
|
first.get("Description")
|
||||||
|
or first.get("description")
|
||||||
|
or data.get("Description")
|
||||||
|
or data.get("description")
|
||||||
|
)
|
||||||
|
details = []
|
||||||
|
for item in result_items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
details.append({
|
||||||
|
"label": item.get("Label") or item.get("label"),
|
||||||
|
"description": item.get("Description") or item.get("description"),
|
||||||
|
"confidence": item.get("Confidence") or item.get("confidence"),
|
||||||
|
"risk_level": item.get("RiskLevel") or item.get("riskLevel") or risk_level,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"label": label,
|
||||||
|
"description": description,
|
||||||
|
"confidence": confidence,
|
||||||
|
"risk_level": risk_level,
|
||||||
|
"suggestion": suggestion,
|
||||||
|
"details": details,
|
||||||
|
"raw": body,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_success(resp):
|
||||||
|
body = resp.get("body", resp)
|
||||||
|
code = body.get("Code") if isinstance(body, dict) else None
|
||||||
|
if code is None and isinstance(body, dict):
|
||||||
|
code = body.get("code")
|
||||||
|
if code is None or str(code) == "200":
|
||||||
|
return
|
||||||
|
|
||||||
|
message = body.get("Msg") or body.get("Message") or body.get("msg") or body.get("message") or "检测接口返回失败"
|
||||||
|
raise RuntimeError(f"{message} (code={code})")
|
||||||
|
|
||||||
|
|
||||||
|
def detect_aigc_image(user_id, image_url, request_root):
|
||||||
|
cost = Config.AIGC_DETECTOR_COST
|
||||||
|
if not image_url:
|
||||||
|
return {"error": "缺少图片地址"}, 400
|
||||||
|
|
||||||
|
if not try_deduct_points(user_id, cost):
|
||||||
|
return {"error": f"积分不足,本次 AI 生成检测需要 {cost} 积分"}, 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
normalized_url = _mirror_image_for_detection(image_url, request_root)
|
||||||
|
client = _create_client()
|
||||||
|
params = open_api_util_models.Params(
|
||||||
|
action="ImageModeration",
|
||||||
|
version="2022-03-02",
|
||||||
|
protocol="HTTPS",
|
||||||
|
pathname="/",
|
||||||
|
method="POST",
|
||||||
|
auth_type="AK",
|
||||||
|
body_type="json",
|
||||||
|
req_body_type="json",
|
||||||
|
style="ROA",
|
||||||
|
)
|
||||||
|
request = open_api_util_models.OpenApiRequest(
|
||||||
|
body={
|
||||||
|
"Service": Config.AIGC_DETECTOR_SERVICE,
|
||||||
|
"ServiceParameters": json.dumps(
|
||||||
|
{"imageUrl": normalized_url},
|
||||||
|
ensure_ascii=False,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
runtime = util_models.RuntimeOptions(
|
||||||
|
read_timeout=Config.PROXY_TIMEOUT_DEFAULT * 1000,
|
||||||
|
connect_timeout=Config.PROXY_TIMEOUT_SHORT * 1000,
|
||||||
|
)
|
||||||
|
resp = client.do_request(params, request, runtime)
|
||||||
|
_ensure_success(resp)
|
||||||
|
result = _extract_result(resp)
|
||||||
|
|
||||||
|
record = GenerationRecord(
|
||||||
|
user_id=user_id,
|
||||||
|
prompt=f"AI生成检测: {normalized_url}",
|
||||||
|
model=Config.AIGC_DETECTOR_SERVICE,
|
||||||
|
cost=cost,
|
||||||
|
image_urls=json.dumps([{"type": "aigc_detection", "url": normalized_url, "result": result}], ensure_ascii=False),
|
||||||
|
)
|
||||||
|
db.session.add(record)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return {"message": "检测完成", "cost": cost, "result": result}, 200
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
refund_points(user_id, cost)
|
||||||
|
system_logger.error("AI生成检测失败", user_id=user_id, image_url=image_url, error=str(e))
|
||||||
|
return {"error": f"AI生成检测失败: {str(e)}"}, 500
|
||||||
@ -112,6 +112,18 @@ def refund_points(user_id, cost):
|
|||||||
db.session.rollback()
|
db.session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
def try_deduct_points(user_id, cost):
|
||||||
|
"""扣减积分,余额不足时返回 False。"""
|
||||||
|
user = db.session.query(User).filter_by(id=user_id).populate_existing().with_for_update().first()
|
||||||
|
if not user or user.points < cost:
|
||||||
|
return False
|
||||||
|
|
||||||
|
user.points -= cost
|
||||||
|
user.has_used_points = True
|
||||||
|
db.session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def handle_chat_generation_sync(user_id, api_key, model_value, prompt, use_trial, cost):
|
def handle_chat_generation_sync(user_id, api_key, model_value, prompt, use_trial, cost):
|
||||||
"""同步处理聊天类模型。"""
|
"""同步处理聊天类模型。"""
|
||||||
chat_payload = {
|
chat_payload = {
|
||||||
|
|||||||
@ -12,7 +12,8 @@ def get_user_history_data(user_id, page=1, per_page=10, filter_type='all'):
|
|||||||
query = GenerationRecord.query.filter(
|
query = GenerationRecord.query.filter(
|
||||||
GenerationRecord.user_id == user_id,
|
GenerationRecord.user_id == user_id,
|
||||||
GenerationRecord.created_at >= ninety_days_ago,
|
GenerationRecord.created_at >= ninety_days_ago,
|
||||||
GenerationRecord.prompt != "解读验光单"
|
GenerationRecord.prompt != "解读验光单",
|
||||||
|
~GenerationRecord.image_urls.like('%"type": "aigc_detection"%')
|
||||||
)
|
)
|
||||||
|
|
||||||
if filter_type == 'video':
|
if filter_type == 'video':
|
||||||
|
|||||||
@ -167,6 +167,103 @@ async function downloadImage(url, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatAigcDetectionResult(result) {
|
||||||
|
const riskMap = {
|
||||||
|
high: { text: '高风险', badge: 'bg-rose-50 text-rose-600 border-rose-100', bar: 'bg-rose-500' },
|
||||||
|
medium: { text: '中风险', badge: 'bg-amber-50 text-amber-600 border-amber-100', bar: 'bg-amber-500' },
|
||||||
|
low: { text: '低风险', badge: 'bg-emerald-50 text-emerald-600 border-emerald-100', bar: 'bg-emerald-500' },
|
||||||
|
none: { text: '未检出风险', badge: 'bg-emerald-50 text-emerald-600 border-emerald-100', bar: 'bg-emerald-500' }
|
||||||
|
};
|
||||||
|
const riskKey = String(result?.risk_level || '').toLowerCase();
|
||||||
|
const risk = riskMap[riskKey] || { text: result?.risk_level || '未知风险', badge: 'bg-slate-50 text-slate-600 border-slate-100', bar: 'bg-indigo-500' };
|
||||||
|
const confidence = Number(result?.confidence || 0);
|
||||||
|
const confidenceText = Number.isFinite(confidence) && confidence > 0 ? confidence.toFixed(confidence % 1 === 0 ? 0 : 1) : '--';
|
||||||
|
const confidenceWidth = Number.isFinite(confidence) ? Math.max(0, Math.min(100, confidence)) : 0;
|
||||||
|
const label = result?.description || result?.label || 'AI生成检测';
|
||||||
|
const details = Array.isArray(result?.details) ? result.details : [];
|
||||||
|
const detailHtml = details.length ? `
|
||||||
|
<div class="mt-3 space-y-2">
|
||||||
|
${details.map(item => {
|
||||||
|
const itemConfidence = item.confidence === undefined || item.confidence === null || item.confidence === '' ? '--' : item.confidence;
|
||||||
|
const itemRisk = riskMap[String(item.risk_level || '').toLowerCase()]?.text || item.risk_level || '未知';
|
||||||
|
return `
|
||||||
|
<div class="flex items-center justify-between gap-3 rounded-xl bg-white px-3 py-2 border border-slate-100">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-[11px] font-black text-slate-700 truncate">${item.description || item.label || '检测项'}</div>
|
||||||
|
<div class="text-[9px] font-bold text-slate-400 mt-0.5">${item.label || '--'} · ${itemRisk}</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-[11px] font-black text-indigo-600 shrink-0">${itemConfidence}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
` : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-[10px] font-black text-slate-400 uppercase tracking-widest">检测结果</div>
|
||||||
|
<div class="text-sm font-black text-slate-800 mt-0.5 truncate">${label}</div>
|
||||||
|
</div>
|
||||||
|
<span class="shrink-0 rounded-full border px-3 py-1 text-[10px] font-black ${risk.badge}">${risk.text}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between text-[10px] font-black text-slate-400 mb-1.5">
|
||||||
|
<span>AI生成置信度</span>
|
||||||
|
<span class="text-indigo-600">${confidenceText}</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-2 rounded-full bg-slate-200 overflow-hidden">
|
||||||
|
<div class="h-full rounded-full ${risk.bar}" style="width: ${confidenceWidth}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${detailHtml}
|
||||||
|
<div class="rounded-xl bg-amber-50 border border-amber-100 px-3 py-2 text-[10px] font-bold leading-relaxed text-amber-700">
|
||||||
|
提示:高风险图片大概率会被主流平台标记为 AI 生成内容。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function detectAigcImage(button) {
|
||||||
|
const imageUrl = decodeURIComponent(button.dataset.url || '');
|
||||||
|
const resultBox = button.closest('.image-frame')?.querySelector('.aigc-detect-result');
|
||||||
|
if (!imageUrl || !resultBox) return;
|
||||||
|
|
||||||
|
button.disabled = true;
|
||||||
|
const originalHtml = button.innerHTML;
|
||||||
|
button.innerHTML = '<i data-lucide="loader-2" class="w-4 h-4 animate-spin"></i><span>检测中</span>';
|
||||||
|
resultBox.classList.remove('hidden', 'text-rose-500');
|
||||||
|
resultBox.classList.add('text-slate-500');
|
||||||
|
resultBox.innerText = '正在调用 AI 生成检测,本次消耗 1 积分...';
|
||||||
|
lucide.createIcons();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/aigc-detect', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ image_url: imageUrl })
|
||||||
|
});
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok || d.error) throw new Error(d.error || '检测失败');
|
||||||
|
|
||||||
|
resultBox.classList.remove('text-slate-500');
|
||||||
|
resultBox.classList.add('text-indigo-600');
|
||||||
|
resultBox.innerHTML = formatAigcDetectionResult(d.result);
|
||||||
|
showToast('AI 生成检测完成', 'success');
|
||||||
|
checkAuth();
|
||||||
|
} catch (e) {
|
||||||
|
resultBox.classList.remove('text-slate-500', 'text-indigo-600');
|
||||||
|
resultBox.classList.add('text-rose-500');
|
||||||
|
resultBox.innerText = e.message || '检测失败';
|
||||||
|
showToast(resultBox.innerText, 'error');
|
||||||
|
} finally {
|
||||||
|
button.disabled = false;
|
||||||
|
button.innerHTML = originalHtml;
|
||||||
|
lucide.createIcons();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadHistory(isLoadMore = false) {
|
async function loadHistory(isLoadMore = false) {
|
||||||
if (isHistoryLoading || (!hasMoreHistory && isLoadMore)) return;
|
if (isHistoryLoading || (!hasMoreHistory && isLoadMore)) return;
|
||||||
|
|
||||||
@ -759,15 +856,24 @@ const displayResult = (slot, data) => {
|
|||||||
slot.innerHTML = `<div class="prose prose-slate prose-sm max-w-none text-slate-600 font-medium leading-relaxed">${data.content.replace(/\n/g, '<br>')}</div>`;
|
slot.innerHTML = `<div class="prose prose-slate prose-sm max-w-none text-slate-600 font-medium leading-relaxed">${data.content.replace(/\n/g, '<br>')}</div>`;
|
||||||
} else {
|
} else {
|
||||||
const imgUrl = data.url;
|
const imgUrl = data.url;
|
||||||
|
const encodedImgUrl = encodeURIComponent(imgUrl);
|
||||||
slot.className = 'image-frame group relative animate-in zoom-in-95 duration-700 flex flex-col items-center justify-center overflow-hidden bg-white shadow-2xl transition-all hover:shadow-indigo-100/50';
|
slot.className = 'image-frame group relative animate-in zoom-in-95 duration-700 flex flex-col items-center justify-center overflow-hidden bg-white shadow-2xl transition-all hover:shadow-indigo-100/50';
|
||||||
slot.innerHTML = `
|
slot.innerHTML = `
|
||||||
<div class="w-full h-full flex items-center justify-center bg-slate-50/20 p-2">
|
<div class="relative w-full flex items-center justify-center bg-slate-50/20 p-2">
|
||||||
<img src="${imgUrl}" class="max-w-full max-h-[60vh] md:max-h-[70vh] object-contain rounded-2xl shadow-sm transition-transform duration-500 group-hover:scale-[1.01]">
|
<img src="${imgUrl}" class="max-w-full max-h-[60vh] md:max-h-[70vh] object-contain rounded-2xl shadow-sm transition-transform duration-500 group-hover:scale-[1.01]">
|
||||||
|
<div class="absolute inset-2 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-slate-900/40 backdrop-blur-[2px] rounded-2xl pointer-events-none">
|
||||||
|
<button onclick="downloadImage('${imgUrl}')" class="bg-white/90 p-4 rounded-full text-indigo-600 shadow-xl hover:scale-110 transition-transform pointer-events-auto" title="下载">
|
||||||
|
<i data-lucide="download-cloud" class="w-6 h-6"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity bg-slate-900/40 backdrop-blur-[2px] rounded-[2.5rem]">
|
<div class="w-full px-3 pb-3 pt-2 flex flex-col gap-2 bg-white">
|
||||||
<button onclick="downloadImage('${imgUrl}')" class="bg-white/90 p-4 rounded-full text-indigo-600 shadow-xl hover:scale-110 transition-transform">
|
<button type="button" data-url="${encodedImgUrl}" onclick="detectAigcImage(this)"
|
||||||
<i data-lucide="download-cloud" class="w-6 h-6"></i>
|
class="w-full flex items-center justify-center gap-2 rounded-2xl border border-indigo-100 bg-indigo-50 px-4 py-2.5 text-[11px] font-black text-indigo-600 hover:bg-indigo-100 disabled:opacity-60 disabled:cursor-not-allowed transition-colors">
|
||||||
|
<i data-lucide="scan-search" class="w-4 h-4"></i>
|
||||||
|
<span>AI生成检测 · 1积分</span>
|
||||||
</button>
|
</button>
|
||||||
|
<div class="aigc-detect-result hidden rounded-2xl bg-slate-50 px-4 py-4 text-[11px] font-bold leading-relaxed"></div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user