feat: 新增平台
This commit is contained in:
@@ -1,31 +1,10 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__chrome-devtools__list_pages",
|
||||
"mcp__chrome-devtools__navigate_page",
|
||||
"mcp__chrome-devtools__take_snapshot",
|
||||
"mcp__chrome-devtools__fill",
|
||||
"mcp__chrome-devtools__click",
|
||||
"mcp__chrome-devtools__list_console_messages",
|
||||
"mcp__chrome-devtools__list_network_requests",
|
||||
"mcp__chrome-devtools__get_console_message",
|
||||
"mcp__chrome-devtools__get_network_request",
|
||||
"Bash(python:*)",
|
||||
"mcp__chrome-devtools__fill_form",
|
||||
"mcp__chrome-devtools__handle_dialog",
|
||||
"Bash(grep:*)",
|
||||
"mcp__sequential-thinking__sequentialthinking",
|
||||
"mcp__chrome-devtools__wait_for",
|
||||
"Bash(curl:*)",
|
||||
"Bash(mysql:*)",
|
||||
"Bash(dir:*)",
|
||||
"WebSearch",
|
||||
"Bash(findstr:*)",
|
||||
"Bash(cmd /c \"del NUL\")",
|
||||
"Bash(find:*)",
|
||||
"Bash(for file in *.html)",
|
||||
"Bash(do sed -i 's/ui-components.css?v=2/ui-components.css?v=3/g' \"$file\")",
|
||||
"Bash(done)"
|
||||
"Bash(python -m py_compile:*)",
|
||||
"Bash(curl -s \"https://api.bugpk.com/api/kuaishou?url=https://www.kuaishou.com/f/X-1ZPlLXEI6SP1mx\")",
|
||||
"Bash(curl -s \"https://api.bugpk.com/api/pipixia?url=https://h5.pipix.com/s/kLUS2a4iJ_M/\")",
|
||||
"Bash(curl:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
16
CLAUDE.md
16
CLAUDE.md
@@ -173,6 +173,22 @@ redis-server.exe redis.windows.conf
|
||||
- 站点配置(Logo、标题、公告等)存储在 `site_configs` 表中
|
||||
- 首次运行前必须执行 `init_admin.py` 和 `init_data.py` 初始化数据
|
||||
|
||||
## 解析请求流程
|
||||
|
||||
1. 用户提交视频URL → `routes/parser.py:parse_video()`
|
||||
2. 限流检查 → `utils/limiter.py:RateLimiter.check_limit()`
|
||||
3. 平台检测 → `parsers/factory.py:ParserFactory.detect_platform()`
|
||||
4. 并发检查 → `utils/queue.py:ConcurrencyController.can_process()`
|
||||
- 可处理:立即执行 `_process_task()`
|
||||
- 队列满:返回 `task_id`,前端轮询 `/api/task/<task_id>`
|
||||
5. 解析执行 → 遍历该平台所有启用的API,依次尝试直到成功(failover机制)
|
||||
6. 结果存储 → Redis(1小时过期)或内存队列
|
||||
|
||||
**API Failover 机制**(`routes/parser.py:110-170`):
|
||||
- 按顺序尝试所有启用的API(不考虑健康状态)
|
||||
- 任一成功即返回,失败则继续下一个
|
||||
- 全部失败才返回错误
|
||||
|
||||
## 访问地址
|
||||
|
||||
- 前台首页:`http://localhost:5000`
|
||||
|
||||
4
app.py
4
app.py
@@ -47,11 +47,15 @@ def create_app():
|
||||
from routes.parser import parser_bp
|
||||
from routes.admin import admin_bp
|
||||
from routes.main import main_bp
|
||||
from routes.api_v1 import api_v1_bp
|
||||
from routes.apikey import apikey_bp
|
||||
|
||||
app.register_blueprint(auth_bp, url_prefix='/auth')
|
||||
app.register_blueprint(parser_bp, url_prefix='/api')
|
||||
app.register_blueprint(admin_bp, url_prefix='/admin')
|
||||
app.register_blueprint(main_bp)
|
||||
app.register_blueprint(api_v1_bp) # /api/v1/*
|
||||
app.register_blueprint(apikey_bp) # /user/apikey/*
|
||||
|
||||
# 初始化定时任务(仅在非调试模式或主进程中启动)
|
||||
# 注意:初始化脚本运行时不启动调度器
|
||||
|
||||
35
database/api_key_tables.sql
Normal file
35
database/api_key_tables.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
-- API Key 相关表结构
|
||||
-- 执行方式: mysql -u root -p video_parser < database/api_key_tables.sql
|
||||
|
||||
-- 用户 API Key 表
|
||||
CREATE TABLE IF NOT EXISTS `user_api_keys` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL,
|
||||
`name` VARCHAR(100) NOT NULL COMMENT 'Key名称',
|
||||
`api_key` VARCHAR(64) NOT NULL UNIQUE COMMENT 'API Key',
|
||||
`is_active` TINYINT(1) DEFAULT 1 COMMENT '是否启用',
|
||||
`daily_limit` INT DEFAULT 100 COMMENT '每日调用限制',
|
||||
`total_calls` INT DEFAULT 0 COMMENT '总调用次数',
|
||||
`last_used_at` DATETIME COMMENT '最后使用时间',
|
||||
`last_used_ip` VARCHAR(45) COMMENT '最后使用IP',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
|
||||
INDEX `idx_api_key` (`api_key`),
|
||||
INDEX `idx_user_id` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户API Key表';
|
||||
|
||||
-- API Key 每日统计表
|
||||
CREATE TABLE IF NOT EXISTS `api_key_daily_stats` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`api_key_id` INT NOT NULL,
|
||||
`date` DATE NOT NULL,
|
||||
`call_count` INT DEFAULT 0 COMMENT '调用次数',
|
||||
`success_count` INT DEFAULT 0 COMMENT '成功次数',
|
||||
`fail_count` INT DEFAULT 0 COMMENT '失败次数',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (`api_key_id`) REFERENCES `user_api_keys`(`id`) ON DELETE CASCADE,
|
||||
UNIQUE KEY `uk_api_key_date` (`api_key_id`, `date`),
|
||||
INDEX `idx_date` (`date`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='API Key每日统计表';
|
||||
23
database/new_platforms.sql
Normal file
23
database/new_platforms.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- 新平台解析接口初始化数据
|
||||
-- 执行方式: mysql -u root -p video_parser < database/new_platforms.sql
|
||||
|
||||
-- 快手解析接口
|
||||
INSERT INTO `parser_apis` (`name`, `platform`, `api_url`, `api_key`, `weight`, `is_enabled`, `health_status`) VALUES
|
||||
('BugPK快手API', 'kuaishou', 'https://api.bugpk.com', '', 1, 1, 1),
|
||||
('优创快手API', 'kuaishou', 'http://apis.uctb.cn', '', 1, 1, 1);
|
||||
|
||||
-- 皮皮虾解析接口
|
||||
INSERT INTO `parser_apis` (`name`, `platform`, `api_url`, `api_key`, `weight`, `is_enabled`, `health_status`) VALUES
|
||||
('BugPK皮皮虾API', 'pipixia', 'https://api.bugpk.com', '', 1, 1, 1),
|
||||
('优创皮皮虾API', 'pipixia', 'http://apis.uctb.cn', '', 1, 1, 1);
|
||||
|
||||
-- 微博解析接口
|
||||
INSERT INTO `parser_apis` (`name`, `platform`, `api_url`, `api_key`, `weight`, `is_enabled`, `health_status`) VALUES
|
||||
('优创微博API', 'weibo', 'http://apis.uctb.cn', '', 1, 1, 1),
|
||||
('妖狐微博API', 'weibo', 'https://api.yaohud.cn', 'SM227DLC0ZgJ6DXJhAx', 1, 1, 1);
|
||||
|
||||
-- 健康检查配置
|
||||
INSERT INTO `health_check_config` (`platform`, `test_url`, `check_interval`, `is_enabled`) VALUES
|
||||
('kuaishou', 'https://www.kuaishou.com/f/X-1ZPlLXEI6SP1mx', 300, 1),
|
||||
('pipixia', 'https://h5.pipix.com/s/kLUS2a4iJ_M/', 300, 1),
|
||||
('weibo', 'https://video.weibo.com/show?fid=1034:5233299304415254', 300, 1);
|
||||
37
database/redeem_codes.sql
Normal file
37
database/redeem_codes.sql
Normal file
@@ -0,0 +1,37 @@
|
||||
-- 兑换码功能数据库表
|
||||
-- 执行方式: mysql -u root -p video_parser < database/redeem_codes.sql
|
||||
|
||||
-- 兑换码表
|
||||
CREATE TABLE IF NOT EXISTS `redeem_codes` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`code` VARCHAR(32) NOT NULL UNIQUE COMMENT '兑换码',
|
||||
`batch_id` VARCHAR(32) COMMENT '批次ID',
|
||||
`target_group_id` INT NOT NULL COMMENT '目标用户组ID',
|
||||
`duration_days` INT DEFAULT 30 COMMENT '有效期天数',
|
||||
`is_used` TINYINT(1) DEFAULT 0 COMMENT '是否已使用',
|
||||
`used_by` INT COMMENT '使用者用户ID',
|
||||
`used_at` DATETIME COMMENT '使用时间',
|
||||
`expires_at` DATETIME COMMENT '兑换码过期时间',
|
||||
`remark` VARCHAR(255) COMMENT '备注',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (`target_group_id`) REFERENCES `user_groups`(`id`),
|
||||
FOREIGN KEY (`used_by`) REFERENCES `users`(`id`),
|
||||
INDEX `idx_code` (`code`),
|
||||
INDEX `idx_batch_id` (`batch_id`),
|
||||
INDEX `idx_is_used` (`is_used`),
|
||||
INDEX `idx_expires_at` (`expires_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='兑换码表';
|
||||
|
||||
-- 用户组到期时间表
|
||||
CREATE TABLE IF NOT EXISTS `user_group_expiry` (
|
||||
`id` INT PRIMARY KEY AUTO_INCREMENT,
|
||||
`user_id` INT NOT NULL UNIQUE COMMENT '用户ID',
|
||||
`group_id` INT NOT NULL COMMENT '用户组ID',
|
||||
`expires_at` DATETIME NOT NULL COMMENT '到期时间',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`),
|
||||
FOREIGN KEY (`group_id`) REFERENCES `user_groups`(`id`),
|
||||
INDEX `idx_user_id` (`user_id`),
|
||||
INDEX `idx_expires_at` (`expires_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户组到期时间表';
|
||||
@@ -159,3 +159,73 @@ class HealthCheckLog(db.Model):
|
||||
response_time = db.Column(db.Integer)
|
||||
error_message = db.Column(db.Text)
|
||||
checked_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
|
||||
class UserApiKey(db.Model):
|
||||
"""用户 API Key"""
|
||||
__tablename__ = 'user_api_keys'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
name = db.Column(db.String(100), nullable=False) # Key 名称,方便用户区分
|
||||
api_key = db.Column(db.String(64), unique=True, nullable=False) # API Key
|
||||
is_active = db.Column(db.Boolean, default=True)
|
||||
daily_limit = db.Column(db.Integer, default=100) # 每日调用限制
|
||||
total_calls = db.Column(db.Integer, default=0) # 总调用次数
|
||||
last_used_at = db.Column(db.DateTime) # 最后使用时间
|
||||
last_used_ip = db.Column(db.String(45)) # 最后使用IP
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
user = db.relationship('User', backref='api_keys')
|
||||
|
||||
|
||||
class ApiKeyDailyStat(db.Model):
|
||||
"""API Key 每日统计"""
|
||||
__tablename__ = 'api_key_daily_stats'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
api_key_id = db.Column(db.Integer, db.ForeignKey('user_api_keys.id'), nullable=False)
|
||||
date = db.Column(db.Date, nullable=False)
|
||||
call_count = db.Column(db.Integer, default=0)
|
||||
success_count = db.Column(db.Integer, default=0)
|
||||
fail_count = db.Column(db.Integer, default=0)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
api_key = db.relationship('UserApiKey', backref='daily_stats')
|
||||
|
||||
|
||||
class RedeemCode(db.Model):
|
||||
"""兑换码"""
|
||||
__tablename__ = 'redeem_codes'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
code = db.Column(db.String(32), unique=True, nullable=False) # 兑换码
|
||||
batch_id = db.Column(db.String(32)) # 批次ID,用于批量管理
|
||||
target_group_id = db.Column(db.Integer, db.ForeignKey('user_groups.id'), nullable=False) # 兑换后的用户组
|
||||
duration_days = db.Column(db.Integer, default=30) # 有效期天数
|
||||
is_used = db.Column(db.Boolean, default=False) # 是否已使用
|
||||
used_by = db.Column(db.Integer, db.ForeignKey('users.id')) # 使用者
|
||||
used_at = db.Column(db.DateTime) # 使用时间
|
||||
expires_at = db.Column(db.DateTime) # 兑换码过期时间
|
||||
remark = db.Column(db.String(255)) # 备注
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
target_group = db.relationship('UserGroup', backref='redeem_codes')
|
||||
user = db.relationship('User', backref='redeemed_codes')
|
||||
|
||||
|
||||
class UserGroupExpiry(db.Model):
|
||||
"""用户组到期时间记录"""
|
||||
__tablename__ = 'user_group_expiry'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, unique=True)
|
||||
group_id = db.Column(db.Integer, db.ForeignKey('user_groups.id'), nullable=False)
|
||||
expires_at = db.Column(db.DateTime, nullable=False) # 到期时间
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
|
||||
|
||||
user = db.relationship('User', backref='group_expiry')
|
||||
group = db.relationship('UserGroup')
|
||||
|
||||
@@ -44,11 +44,12 @@ class BaseParser(ABC):
|
||||
except requests.RequestException as e:
|
||||
raise Exception(f"请求失败: {str(e)}")
|
||||
|
||||
def _normalize_response(self, cover: str, video_url: str, title: str, description: str) -> Dict:
|
||||
def _normalize_response(self, cover: str, video_url: str, title: str, description: str, author: str = "") -> Dict:
|
||||
"""标准化返回数据"""
|
||||
return {
|
||||
"cover": cover or "",
|
||||
"video_url": video_url or "",
|
||||
"title": title or "",
|
||||
"description": description or ""
|
||||
"description": description or "",
|
||||
"author": author or ""
|
||||
}
|
||||
|
||||
@@ -32,8 +32,9 @@ class BilibiliMirParser(BaseParser):
|
||||
video_url = video_data.get("url", "") or video_data.get("video_url", "")
|
||||
title = video_data.get("title", "")
|
||||
description = video_data.get("desc", "") or video_data.get("description", "")
|
||||
author = video_data.get("author", "") or video_data.get("owner", {}).get("name", "")
|
||||
|
||||
return self._normalize_response(cover, video_url, title, description)
|
||||
return self._normalize_response(cover, video_url, title, description, author)
|
||||
else:
|
||||
raise Exception(f"解析失败: {data.get('msg', '未知错误')}")
|
||||
except Exception as e:
|
||||
@@ -70,8 +71,9 @@ class BilibiliBugPKParser(BaseParser):
|
||||
video_url = video_data.get("url", "") or video_data.get("video_url", "")
|
||||
title = video_data.get("title", "")
|
||||
description = video_data.get("desc", "") or video_data.get("description", "")
|
||||
author = video_data.get("author", "") or video_data.get("owner", {}).get("name", "")
|
||||
|
||||
return self._normalize_response(cover, video_url, title, description)
|
||||
return self._normalize_response(cover, video_url, title, description, author)
|
||||
else:
|
||||
raise Exception(f"解析失败: {data.get('msg', '未知错误')}")
|
||||
except Exception as e:
|
||||
@@ -105,6 +107,7 @@ class BilibiliYaohuParser(BaseParser):
|
||||
cover = basic.get("cover", "")
|
||||
title = basic.get("title", "")
|
||||
description = basic.get("description", "")
|
||||
author = basic.get("author", "") or basic.get("owner", {}).get("name", "")
|
||||
|
||||
# 提取视频URL - 优先使用data.video_url,其次使用videos[0].url
|
||||
video_url = video_data.get("video_url", "")
|
||||
@@ -113,7 +116,7 @@ class BilibiliYaohuParser(BaseParser):
|
||||
if isinstance(videos, list) and videos:
|
||||
video_url = videos[0].get("url", "")
|
||||
|
||||
return self._normalize_response(cover, video_url, title, description)
|
||||
return self._normalize_response(cover, video_url, title, description, author)
|
||||
else:
|
||||
raise Exception(f"解析失败: 不支持的类型 {data.get('parse_type')}")
|
||||
except Exception as e:
|
||||
|
||||
@@ -57,15 +57,15 @@ class DouyinParser(BaseParser):
|
||||
# 提取标题(描述)
|
||||
title = video_info.get("desc", "")
|
||||
|
||||
# 提取作者信息作为简介
|
||||
author = video_info.get("author", {})
|
||||
author_name = author.get("nickname", "")
|
||||
author_signature = author.get("signature", "")
|
||||
description = f"作者: {author_name}"
|
||||
if author_signature:
|
||||
description += f" | {author_signature}"
|
||||
# 提取作者信息
|
||||
author_info = video_info.get("author", {})
|
||||
author_name = author_info.get("nickname", "")
|
||||
author_signature = author_info.get("signature", "")
|
||||
|
||||
return self._normalize_response(cover, video_url, title, description)
|
||||
# 简介使用作者签名
|
||||
description = author_signature or ""
|
||||
|
||||
return self._normalize_response(cover, video_url, title, description, author_name)
|
||||
except Exception as e:
|
||||
raise Exception(f"数据提取失败: {str(e)}")
|
||||
|
||||
|
||||
@@ -1,12 +1,48 @@
|
||||
from parsers.douyin import DouyinParser
|
||||
from parsers.tiktok import TikTokParser
|
||||
from parsers.bilibili import BilibiliMirParser, BilibiliBugPKParser, BilibiliYaohuParser
|
||||
from parsers.kuaishou import KuaishouBugPKParser, KuaishouUctbParser
|
||||
from parsers.pipixia import PipixiaBugPKParser, PipixiaUctbParser
|
||||
from parsers.weibo import WeiboUctbParser, WeiboYaohuParser
|
||||
from models import ParserAPI
|
||||
import random
|
||||
import requests
|
||||
|
||||
class ParserFactory:
|
||||
"""解析器工厂类"""
|
||||
|
||||
@staticmethod
|
||||
def expand_short_url(url: str) -> str:
|
||||
"""展开短链接,获取真实URL"""
|
||||
short_domains = ['b23.tv', 'v.douyin.com', 't.cn']
|
||||
|
||||
# 检查是否是短链接
|
||||
is_short = any(domain in url.lower() for domain in short_domains)
|
||||
if not is_short:
|
||||
return url
|
||||
|
||||
try:
|
||||
# 发送请求,不跟随重定向,获取 Location 头
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
}
|
||||
response = requests.head(url, headers=headers, allow_redirects=False, timeout=10)
|
||||
|
||||
if response.status_code in [301, 302, 303, 307, 308]:
|
||||
location = response.headers.get('Location', '')
|
||||
if location:
|
||||
# 如果还是短链接,继续展开
|
||||
if any(domain in location.lower() for domain in short_domains):
|
||||
return ParserFactory.expand_short_url(location)
|
||||
return location
|
||||
|
||||
# 如果 HEAD 请求不行,尝试 GET 请求跟随重定向
|
||||
response = requests.get(url, headers=headers, allow_redirects=True, timeout=10)
|
||||
return response.url
|
||||
except Exception:
|
||||
# 展开失败,返回原URL
|
||||
return url
|
||||
|
||||
@staticmethod
|
||||
def create_parser(api_config: ParserAPI):
|
||||
"""根据API配置创建解析器实例"""
|
||||
@@ -28,6 +64,30 @@ class ParserFactory:
|
||||
return BilibiliYaohuParser(api_url, api_key)
|
||||
else:
|
||||
return BilibiliMirParser(api_url, api_key)
|
||||
elif platform == 'kuaishou':
|
||||
# 快手解析器
|
||||
if 'bugpk' in api_url:
|
||||
return KuaishouBugPKParser(api_url, api_key)
|
||||
elif 'uctb' in api_url:
|
||||
return KuaishouUctbParser(api_url, api_key)
|
||||
else:
|
||||
return KuaishouBugPKParser(api_url, api_key)
|
||||
elif platform == 'pipixia':
|
||||
# 皮皮虾解析器
|
||||
if 'bugpk' in api_url:
|
||||
return PipixiaBugPKParser(api_url, api_key)
|
||||
elif 'uctb' in api_url:
|
||||
return PipixiaUctbParser(api_url, api_key)
|
||||
else:
|
||||
return PipixiaBugPKParser(api_url, api_key)
|
||||
elif platform == 'weibo':
|
||||
# 微博解析器
|
||||
if 'uctb' in api_url:
|
||||
return WeiboUctbParser(api_url, api_key)
|
||||
elif 'yaohud' in api_url:
|
||||
return WeiboYaohuParser(api_url, api_key)
|
||||
else:
|
||||
return WeiboUctbParser(api_url, api_key)
|
||||
else:
|
||||
raise ValueError(f"不支持的平台: {platform}")
|
||||
|
||||
@@ -83,5 +143,11 @@ class ParserFactory:
|
||||
return 'tiktok'
|
||||
elif 'bilibili.com' in url_lower or 'b23.tv' in url_lower:
|
||||
return 'bilibili'
|
||||
elif 'kuaishou.com' in url_lower or 'gifshow.com' in url_lower:
|
||||
return 'kuaishou'
|
||||
elif 'pipix.com' in url_lower or 'pipixia.com' in url_lower or 'h5.pipix.com' in url_lower:
|
||||
return 'pipixia'
|
||||
elif 'weibo.com' in url_lower or 'weibo.cn' in url_lower:
|
||||
return 'weibo'
|
||||
else:
|
||||
raise ValueError("无法识别的视频平台")
|
||||
|
||||
89
parsers/kuaishou.py
Normal file
89
parsers/kuaishou.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from parsers.base import BaseParser
|
||||
from typing import Dict
|
||||
from urllib.parse import urlencode
|
||||
|
||||
|
||||
class KuaishouBugPKParser(BaseParser):
|
||||
"""快手解析器 - BugPK API"""
|
||||
|
||||
def parse(self, video_url: str) -> Dict:
|
||||
"""解析快手视频"""
|
||||
try:
|
||||
url = f"{self.api_url}/api/kuaishou?{urlencode({'url': video_url})}"
|
||||
response = self._make_request(url)
|
||||
data = response.json()
|
||||
return self._extract_data(data)
|
||||
except Exception as e:
|
||||
raise Exception(f"快手解析失败(BugPK API): {str(e)}")
|
||||
|
||||
def _extract_data(self, data: Dict) -> Dict:
|
||||
"""提取并标准化数据
|
||||
实际返回格式:
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "解析成功",
|
||||
"data": {
|
||||
"title": "视频标题",
|
||||
"cover": "封面URL",
|
||||
"url": "视频URL"
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if data.get("code") == 200:
|
||||
video_data = data.get("data", {})
|
||||
|
||||
cover = video_data.get("cover", "")
|
||||
video_url = video_data.get("url", "")
|
||||
title = video_data.get("title", "")
|
||||
description = "" # BugPK API 不返回简介
|
||||
author = "" # BugPK API 不返回作者
|
||||
|
||||
return self._normalize_response(cover, video_url, title, description, author)
|
||||
else:
|
||||
raise Exception(f"解析失败: {data.get('msg', '未知错误')}")
|
||||
except Exception as e:
|
||||
raise Exception(f"数据提取失败: {str(e)}")
|
||||
|
||||
|
||||
class KuaishouUctbParser(BaseParser):
|
||||
"""快手解析器 - 优创 API"""
|
||||
|
||||
def parse(self, video_url: str) -> Dict:
|
||||
"""解析快手视频"""
|
||||
try:
|
||||
url = f"{self.api_url}/api/videojx?{urlencode({'url': video_url})}"
|
||||
response = self._make_request(url)
|
||||
data = response.json()
|
||||
return self._extract_data(data)
|
||||
except Exception as e:
|
||||
raise Exception(f"快手解析失败(优创 API): {str(e)}")
|
||||
|
||||
def _extract_data(self, data: Dict) -> Dict:
|
||||
"""提取并标准化数据
|
||||
实际返回格式:
|
||||
{
|
||||
"code": 200,
|
||||
"data": {
|
||||
"desc": "视频描述",
|
||||
"cover": "封面URL",
|
||||
"playurl": "视频URL"
|
||||
},
|
||||
"msg": "请求成功"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if data.get("code") == 200:
|
||||
video_data = data.get("data", {})
|
||||
|
||||
cover = video_data.get("cover", "")
|
||||
video_url = video_data.get("playurl", "")
|
||||
title = video_data.get("desc", "")
|
||||
description = ""
|
||||
author = "" # 优创API不返回作者
|
||||
|
||||
return self._normalize_response(cover, video_url, title, description, author)
|
||||
else:
|
||||
raise Exception(f"解析失败: {data.get('msg', '未知错误')}")
|
||||
except Exception as e:
|
||||
raise Exception(f"数据提取失败: {str(e)}")
|
||||
91
parsers/pipixia.py
Normal file
91
parsers/pipixia.py
Normal file
@@ -0,0 +1,91 @@
|
||||
from parsers.base import BaseParser
|
||||
from typing import Dict
|
||||
from urllib.parse import urlencode
|
||||
|
||||
|
||||
class PipixiaBugPKParser(BaseParser):
|
||||
"""皮皮虾解析器 - BugPK API"""
|
||||
|
||||
def parse(self, video_url: str) -> Dict:
|
||||
"""解析皮皮虾视频"""
|
||||
try:
|
||||
url = f"{self.api_url}/api/pipixia?{urlencode({'url': video_url})}"
|
||||
response = self._make_request(url)
|
||||
data = response.json()
|
||||
return self._extract_data(data)
|
||||
except Exception as e:
|
||||
raise Exception(f"皮皮虾解析失败(BugPK API): {str(e)}")
|
||||
|
||||
def _extract_data(self, data: Dict) -> Dict:
|
||||
"""提取并标准化数据
|
||||
实际返回格式:
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "解析成功",
|
||||
"data": {
|
||||
"author": "作者名",
|
||||
"avatar": "头像URL",
|
||||
"title": "标题",
|
||||
"cover": "封面URL",
|
||||
"url": "视频URL"
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if data.get("code") == 200:
|
||||
video_data = data.get("data", {})
|
||||
|
||||
cover = video_data.get("cover", "")
|
||||
video_url = video_data.get("url", "")
|
||||
title = video_data.get("title", "")
|
||||
description = ""
|
||||
author = video_data.get("author", "")
|
||||
|
||||
return self._normalize_response(cover, video_url, title, description, author)
|
||||
else:
|
||||
raise Exception(f"解析失败: {data.get('msg', '未知错误')}")
|
||||
except Exception as e:
|
||||
raise Exception(f"数据提取失败: {str(e)}")
|
||||
|
||||
|
||||
class PipixiaUctbParser(BaseParser):
|
||||
"""皮皮虾解析器 - 优创 API"""
|
||||
|
||||
def parse(self, video_url: str) -> Dict:
|
||||
"""解析皮皮虾视频"""
|
||||
try:
|
||||
url = f"{self.api_url}/api/videojx?{urlencode({'url': video_url})}"
|
||||
response = self._make_request(url)
|
||||
data = response.json()
|
||||
return self._extract_data(data)
|
||||
except Exception as e:
|
||||
raise Exception(f"皮皮虾解析失败(优创 API): {str(e)}")
|
||||
|
||||
def _extract_data(self, data: Dict) -> Dict:
|
||||
"""提取并标准化数据
|
||||
实际返回格式:
|
||||
{
|
||||
"code": 200,
|
||||
"data": {
|
||||
"desc": null,
|
||||
"cover": "封面URL",
|
||||
"playurl": "视频URL"
|
||||
},
|
||||
"msg": "请求成功"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if data.get("code") == 200:
|
||||
video_data = data.get("data", {})
|
||||
|
||||
cover = video_data.get("cover", "")
|
||||
video_url = video_data.get("playurl", "")
|
||||
title = video_data.get("desc", "") or ""
|
||||
description = ""
|
||||
author = "" # 优创API不返回作者
|
||||
|
||||
return self._normalize_response(cover, video_url, title, description, author)
|
||||
else:
|
||||
raise Exception(f"解析失败: {data.get('msg', '未知错误')}")
|
||||
except Exception as e:
|
||||
raise Exception(f"数据提取失败: {str(e)}")
|
||||
@@ -58,13 +58,14 @@ class TikTokParser(BaseParser):
|
||||
|
||||
title = video_info.get("desc", "")
|
||||
|
||||
author = video_info.get("author", {})
|
||||
author_name = author.get("nickname", "")
|
||||
author_signature = author.get("signature", "")
|
||||
description = f"Author: {author_name}"
|
||||
if author_signature:
|
||||
description += f" | {author_signature}"
|
||||
# 提取作者信息
|
||||
author_info = video_info.get("author", {})
|
||||
author_name = author_info.get("nickname", "")
|
||||
author_signature = author_info.get("signature", "")
|
||||
|
||||
return self._normalize_response(cover, video_url, title, description)
|
||||
# 简介使用作者签名
|
||||
description = author_signature or ""
|
||||
|
||||
return self._normalize_response(cover, video_url, title, description, author_name)
|
||||
except Exception as e:
|
||||
raise Exception(f"数据提取失败: {str(e)}")
|
||||
|
||||
105
parsers/weibo.py
Normal file
105
parsers/weibo.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from parsers.base import BaseParser
|
||||
from typing import Dict
|
||||
from urllib.parse import urlencode
|
||||
|
||||
|
||||
class WeiboUctbParser(BaseParser):
|
||||
"""微博解析器 - 优创 API"""
|
||||
|
||||
def parse(self, video_url: str) -> Dict:
|
||||
"""解析微博视频"""
|
||||
try:
|
||||
url = f"{self.api_url}/api/videojx?{urlencode({'url': video_url})}"
|
||||
response = self._make_request(url)
|
||||
data = response.json()
|
||||
return self._extract_data(data)
|
||||
except Exception as e:
|
||||
raise Exception(f"微博解析失败(优创 API): {str(e)}")
|
||||
|
||||
def _extract_data(self, data: Dict) -> Dict:
|
||||
"""提取并标准化数据
|
||||
实际返回格式:
|
||||
{
|
||||
"code": 200,
|
||||
"data": {
|
||||
"desc": "视频描述",
|
||||
"cover": "封面URL",
|
||||
"playurl": "视频URL"
|
||||
},
|
||||
"msg": "请求成功"
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if data.get("code") == 200:
|
||||
video_data = data.get("data", {})
|
||||
|
||||
cover = video_data.get("cover", "")
|
||||
video_url = video_data.get("playurl", "")
|
||||
title = video_data.get("desc", "") or ""
|
||||
description = ""
|
||||
author = "" # 优创API不返回作者
|
||||
|
||||
return self._normalize_response(cover, video_url, title, description, author)
|
||||
else:
|
||||
raise Exception(f"解析失败: {data.get('msg', '未知错误')}")
|
||||
except Exception as e:
|
||||
raise Exception(f"数据提取失败: {str(e)}")
|
||||
|
||||
|
||||
class WeiboYaohuParser(BaseParser):
|
||||
"""微博解析器 - 妖狐 API"""
|
||||
|
||||
def parse(self, video_url: str) -> Dict:
|
||||
"""解析微博视频"""
|
||||
try:
|
||||
url = f"{self.api_url}/api/v6/video/weibo?{urlencode({'key': self.api_key, 'url': video_url})}"
|
||||
response = self._make_request(url, verify=False)
|
||||
data = response.json()
|
||||
return self._extract_data(data)
|
||||
except Exception as e:
|
||||
raise Exception(f"微博解析失败(妖狐 API): {str(e)}")
|
||||
|
||||
def _extract_data(self, data: Dict) -> Dict:
|
||||
"""提取并标准化数据
|
||||
实际返回格式:
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "解析成功",
|
||||
"data": {
|
||||
"author": "作者名",
|
||||
"title": "标题",
|
||||
"cover": "封面URL",
|
||||
"quality_urls": {
|
||||
"超清 2K60": "视频URL",
|
||||
"高清 1080P": "视频URL",
|
||||
...
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
try:
|
||||
if data.get("code") == 200:
|
||||
video_data = data.get("data", {})
|
||||
|
||||
cover = video_data.get("cover", "")
|
||||
title = video_data.get("title", "")
|
||||
description = ""
|
||||
author = video_data.get("author", "")
|
||||
|
||||
# 从 quality_urls 中获取最高质量的视频URL
|
||||
quality_urls = video_data.get("quality_urls", {})
|
||||
video_url = ""
|
||||
# 按优先级选择:超清 > 高清1080P > 高清720P > 其他
|
||||
for quality in ["超清 2K60", "高清 1080P", "高清 720P"]:
|
||||
if quality in quality_urls:
|
||||
video_url = quality_urls[quality]
|
||||
break
|
||||
# 如果没找到,取第一个可用的
|
||||
if not video_url and quality_urls:
|
||||
video_url = list(quality_urls.values())[0]
|
||||
|
||||
return self._normalize_response(cover, video_url, title, description, author)
|
||||
else:
|
||||
raise Exception(f"解析失败: {data.get('msg', '未知错误')}")
|
||||
except Exception as e:
|
||||
raise Exception(f"数据提取失败: {str(e)}")
|
||||
286
routes/admin.py
286
routes/admin.py
@@ -113,6 +113,8 @@ def users_page():
|
||||
@admin_required
|
||||
def get_users():
|
||||
"""获取用户列表API"""
|
||||
from models import UserGroupExpiry
|
||||
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 20, type=int)
|
||||
group_id = request.args.get('group_id', type=int)
|
||||
@@ -126,16 +128,28 @@ def get_users():
|
||||
page=page, per_page=per_page, error_out=False
|
||||
)
|
||||
|
||||
users = [{
|
||||
'id': u.id,
|
||||
'username': u.username,
|
||||
'email': u.email,
|
||||
'group_id': u.group_id,
|
||||
'group_name': u.group.name if u.group else '',
|
||||
'total_parse_count': u.total_parse_count,
|
||||
'is_active': u.is_active,
|
||||
'created_at': u.created_at.isoformat()
|
||||
} for u in pagination.items]
|
||||
users = []
|
||||
for u in pagination.items:
|
||||
# 获取套餐到期时间
|
||||
expiry = UserGroupExpiry.query.filter_by(user_id=u.id).first()
|
||||
expires_at = None
|
||||
is_expired = False
|
||||
if expiry and expiry.expires_at:
|
||||
expires_at = expiry.expires_at.strftime('%Y-%m-%dT%H:%M')
|
||||
is_expired = expiry.expires_at < datetime.utcnow()
|
||||
|
||||
users.append({
|
||||
'id': u.id,
|
||||
'username': u.username,
|
||||
'email': u.email,
|
||||
'group_id': u.group_id,
|
||||
'group_name': u.group.name if u.group else '',
|
||||
'total_parse_count': u.total_parse_count,
|
||||
'is_active': u.is_active,
|
||||
'created_at': u.created_at.isoformat(),
|
||||
'expires_at': expires_at,
|
||||
'is_expired': is_expired
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
@@ -152,6 +166,8 @@ def get_users():
|
||||
@admin_required
|
||||
def update_user(user_id):
|
||||
"""更新用户信息"""
|
||||
from models import UserGroupExpiry
|
||||
|
||||
user = User.query.get_or_404(user_id)
|
||||
data = request.get_json()
|
||||
|
||||
@@ -160,6 +176,29 @@ def update_user(user_id):
|
||||
if 'is_active' in data:
|
||||
user.is_active = data['is_active']
|
||||
|
||||
# 处理套餐到期时间
|
||||
if 'expires_at' in data:
|
||||
expiry = UserGroupExpiry.query.filter_by(user_id=user_id).first()
|
||||
|
||||
if data['expires_at']:
|
||||
# 解析时间字符串
|
||||
expires_at = datetime.strptime(data['expires_at'], '%Y-%m-%dT%H:%M')
|
||||
|
||||
if expiry:
|
||||
expiry.group_id = user.group_id
|
||||
expiry.expires_at = expires_at
|
||||
else:
|
||||
expiry = UserGroupExpiry(
|
||||
user_id=user_id,
|
||||
group_id=user.group_id,
|
||||
expires_at=expires_at
|
||||
)
|
||||
db.session.add(expiry)
|
||||
else:
|
||||
# 清空到期时间(游客/普通用户)
|
||||
if expiry:
|
||||
db.session.delete(expiry)
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'message': '更新成功'})
|
||||
|
||||
@@ -766,3 +805,230 @@ def change_email():
|
||||
admin.email = email
|
||||
db.session.commit()
|
||||
return jsonify({'success': True, 'message': '邮箱修改成功'})
|
||||
|
||||
|
||||
# ==================== 用户组 API ====================
|
||||
|
||||
@admin_bp.route('/api/user-groups', methods=['GET'])
|
||||
@admin_required
|
||||
def get_user_groups():
|
||||
"""获取用户组列表(仅返回可兑换的套餐,排除游客和普通用户)"""
|
||||
# 排除游客(id=1)和普通用户(id=2)
|
||||
groups = UserGroup.query.filter(UserGroup.id > 2).all()
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': [{'id': g.id, 'name': g.name, 'daily_limit': g.daily_limit} for g in groups]
|
||||
})
|
||||
|
||||
|
||||
# ==================== 兑换码管理 ====================
|
||||
|
||||
@admin_bp.route('/redeem-codes')
|
||||
@admin_required
|
||||
def redeem_codes_page():
|
||||
"""兑换码管理页面"""
|
||||
from flask import render_template
|
||||
return render_template('admin_redeem_codes.html')
|
||||
|
||||
|
||||
@admin_bp.route('/api/redeem-codes', methods=['GET'])
|
||||
@admin_required
|
||||
def get_redeem_codes():
|
||||
"""获取兑换码列表"""
|
||||
from models import RedeemCode
|
||||
|
||||
page = request.args.get('page', 1, type=int)
|
||||
per_page = request.args.get('per_page', 20, type=int)
|
||||
batch_id = request.args.get('batch_id', '')
|
||||
status = request.args.get('status', '') # unused, used, expired
|
||||
|
||||
query = RedeemCode.query
|
||||
|
||||
if batch_id:
|
||||
query = query.filter(RedeemCode.batch_id == batch_id)
|
||||
|
||||
if status == 'unused':
|
||||
query = query.filter(RedeemCode.is_used == False)
|
||||
elif status == 'used':
|
||||
query = query.filter(RedeemCode.is_used == True)
|
||||
elif status == 'expired':
|
||||
query = query.filter(RedeemCode.is_used == False, RedeemCode.expires_at < datetime.utcnow())
|
||||
|
||||
pagination = query.order_by(RedeemCode.created_at.desc()).paginate(page=page, per_page=per_page)
|
||||
|
||||
codes = []
|
||||
for code in pagination.items:
|
||||
codes.append({
|
||||
'id': code.id,
|
||||
'code': code.code,
|
||||
'batch_id': code.batch_id,
|
||||
'target_group': code.target_group.name if code.target_group else '',
|
||||
'target_group_id': code.target_group_id,
|
||||
'duration_days': code.duration_days,
|
||||
'is_used': code.is_used,
|
||||
'used_by': code.user.username if code.user else None,
|
||||
'used_at': code.used_at.strftime('%Y-%m-%d %H:%M') if code.used_at else None,
|
||||
'expires_at': code.expires_at.strftime('%Y-%m-%d %H:%M') if code.expires_at else None,
|
||||
'is_expired': code.expires_at and code.expires_at < datetime.utcnow() and not code.is_used,
|
||||
'remark': code.remark,
|
||||
'created_at': code.created_at.strftime('%Y-%m-%d %H:%M')
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': codes,
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
'total': pagination.total,
|
||||
'pages': pagination.pages
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@admin_bp.route('/api/redeem-codes/generate', methods=['POST'])
|
||||
@admin_required
|
||||
def generate_redeem_codes():
|
||||
"""批量生成兑换码"""
|
||||
from models import RedeemCode
|
||||
import secrets
|
||||
import string
|
||||
|
||||
data = request.get_json()
|
||||
count = data.get('count', 1)
|
||||
target_group_id = data.get('target_group_id')
|
||||
duration_days = data.get('duration_days', 30)
|
||||
expires_days = data.get('expires_days') # 兑换码有效期(天)
|
||||
remark = data.get('remark', '')
|
||||
prefix = data.get('prefix', '') # 兑换码前缀
|
||||
|
||||
if not target_group_id:
|
||||
return jsonify({'success': False, 'message': '请选择目标用户组'}), 400
|
||||
|
||||
if count < 1 or count > 1000:
|
||||
return jsonify({'success': False, 'message': '生成数量必须在1-1000之间'}), 400
|
||||
|
||||
# 验证用户组存在
|
||||
group = UserGroup.query.get(target_group_id)
|
||||
if not group:
|
||||
return jsonify({'success': False, 'message': '用户组不存在'}), 400
|
||||
|
||||
# 生成批次ID
|
||||
batch_id = datetime.now().strftime('%Y%m%d%H%M%S') + secrets.token_hex(4)
|
||||
|
||||
# 计算过期时间
|
||||
expires_at = None
|
||||
if expires_days:
|
||||
expires_at = datetime.utcnow() + timedelta(days=expires_days)
|
||||
|
||||
# 生成兑换码
|
||||
codes = []
|
||||
chars = string.ascii_uppercase + string.digits
|
||||
|
||||
for _ in range(count):
|
||||
# 生成随机码
|
||||
random_part = ''.join(secrets.choice(chars) for _ in range(12))
|
||||
code_str = f"{prefix}{random_part}" if prefix else random_part
|
||||
|
||||
code = RedeemCode(
|
||||
code=code_str,
|
||||
batch_id=batch_id,
|
||||
target_group_id=target_group_id,
|
||||
duration_days=duration_days,
|
||||
expires_at=expires_at,
|
||||
remark=remark
|
||||
)
|
||||
db.session.add(code)
|
||||
codes.append(code_str)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'成功生成 {count} 个兑换码',
|
||||
'data': {
|
||||
'batch_id': batch_id,
|
||||
'codes': codes
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@admin_bp.route('/api/redeem-codes/<int:code_id>', methods=['DELETE'])
|
||||
@admin_required
|
||||
def delete_redeem_code(code_id):
|
||||
"""删除兑换码"""
|
||||
from models import RedeemCode
|
||||
|
||||
code = RedeemCode.query.get(code_id)
|
||||
if not code:
|
||||
return jsonify({'success': False, 'message': '兑换码不存在'}), 404
|
||||
|
||||
if code.is_used:
|
||||
return jsonify({'success': False, 'message': '已使用的兑换码不能删除'}), 400
|
||||
|
||||
db.session.delete(code)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': '删除成功'})
|
||||
|
||||
|
||||
@admin_bp.route('/api/redeem-codes/batch/<batch_id>', methods=['DELETE'])
|
||||
@admin_required
|
||||
def delete_batch_codes(batch_id):
|
||||
"""删除整批兑换码"""
|
||||
from models import RedeemCode
|
||||
|
||||
# 只删除未使用的
|
||||
deleted = RedeemCode.query.filter_by(batch_id=batch_id, is_used=False).delete()
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({'success': True, 'message': f'成功删除 {deleted} 个未使用的兑换码'})
|
||||
|
||||
|
||||
@admin_bp.route('/api/redeem-codes/batches', methods=['GET'])
|
||||
@admin_required
|
||||
def get_batch_list():
|
||||
"""获取批次列表"""
|
||||
from models import RedeemCode
|
||||
|
||||
batches = db.session.query(
|
||||
RedeemCode.batch_id,
|
||||
func.count(RedeemCode.id).label('total'),
|
||||
func.sum(db.case((RedeemCode.is_used == True, 1), else_=0)).label('used'),
|
||||
func.min(RedeemCode.created_at).label('created_at')
|
||||
).group_by(RedeemCode.batch_id).order_by(func.min(RedeemCode.created_at).desc()).all()
|
||||
|
||||
result = []
|
||||
for batch in batches:
|
||||
result.append({
|
||||
'batch_id': batch.batch_id,
|
||||
'total': batch.total,
|
||||
'used': int(batch.used or 0),
|
||||
'unused': batch.total - int(batch.used or 0),
|
||||
'created_at': batch.created_at.strftime('%Y-%m-%d %H:%M') if batch.created_at else ''
|
||||
})
|
||||
|
||||
return jsonify({'success': True, 'data': result})
|
||||
|
||||
|
||||
@admin_bp.route('/api/redeem-codes/export/<batch_id>', methods=['GET'])
|
||||
@admin_required
|
||||
def export_batch_codes(batch_id):
|
||||
"""导出批次兑换码"""
|
||||
from models import RedeemCode
|
||||
|
||||
codes = RedeemCode.query.filter_by(batch_id=batch_id).all()
|
||||
|
||||
if not codes:
|
||||
return jsonify({'success': False, 'message': '批次不存在'}), 404
|
||||
|
||||
code_list = [code.code for code in codes]
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'batch_id': batch_id,
|
||||
'codes': code_list,
|
||||
'total': len(code_list)
|
||||
}
|
||||
})
|
||||
|
||||
212
routes/api_v1.py
Normal file
212
routes/api_v1.py
Normal file
@@ -0,0 +1,212 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from models import ParserAPI, ParseLog, UserApiKey, ApiKeyDailyStat, db
|
||||
from parsers.factory import ParserFactory
|
||||
from utils.security import get_client_ip
|
||||
from datetime import datetime, date
|
||||
import time
|
||||
|
||||
api_v1_bp = Blueprint('api_v1', __name__, url_prefix='/api/v1')
|
||||
|
||||
|
||||
def validate_and_get_key(api_key):
|
||||
"""验证 API Key 并返回 key 对象"""
|
||||
if not api_key:
|
||||
return None, 'API Key 不能为空'
|
||||
|
||||
key_obj = UserApiKey.query.filter_by(api_key=api_key).first()
|
||||
if not key_obj:
|
||||
return None, 'API Key 无效'
|
||||
if not key_obj.is_active:
|
||||
return None, 'API Key 已被禁用'
|
||||
if not key_obj.user.is_active:
|
||||
return None, '用户账号已被禁用'
|
||||
|
||||
# 检查每日限额
|
||||
today_stat = ApiKeyDailyStat.query.filter_by(
|
||||
api_key_id=key_obj.id,
|
||||
date=date.today()
|
||||
).first()
|
||||
if today_stat and today_stat.call_count >= key_obj.daily_limit:
|
||||
return None, f'已达到每日调用限额({key_obj.daily_limit}次)'
|
||||
|
||||
return key_obj, None
|
||||
|
||||
|
||||
def record_api_call(key_obj, ip_address, success=True):
|
||||
"""记录 API 调用"""
|
||||
key_obj.total_calls = (key_obj.total_calls or 0) + 1
|
||||
key_obj.last_used_at = datetime.utcnow()
|
||||
key_obj.last_used_ip = ip_address
|
||||
|
||||
today_stat = ApiKeyDailyStat.query.filter_by(
|
||||
api_key_id=key_obj.id,
|
||||
date=date.today()
|
||||
).first()
|
||||
|
||||
if not today_stat:
|
||||
today_stat = ApiKeyDailyStat(api_key_id=key_obj.id, date=date.today())
|
||||
db.session.add(today_stat)
|
||||
|
||||
today_stat.call_count = (today_stat.call_count or 0) + 1
|
||||
if success:
|
||||
today_stat.success_count = (today_stat.success_count or 0) + 1
|
||||
else:
|
||||
today_stat.fail_count = (today_stat.fail_count or 0) + 1
|
||||
|
||||
|
||||
@api_v1_bp.route('/parse', methods=['GET'])
|
||||
def parse_video():
|
||||
"""
|
||||
对外解析API - 简化版
|
||||
|
||||
请求方式: GET
|
||||
参数:
|
||||
key: API Key
|
||||
url: 视频链接
|
||||
|
||||
示例: /api/v1/parse?key=sk_xxx&url=https://v.douyin.com/xxx
|
||||
|
||||
返回:
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "解析成功",
|
||||
"data": {
|
||||
"cover": "封面URL",
|
||||
"title": "标题",
|
||||
"description": "简介",
|
||||
"author": "作者",
|
||||
"video_url": "无水印视频链接"
|
||||
}
|
||||
}
|
||||
"""
|
||||
# 获取参数
|
||||
api_key = request.args.get('key')
|
||||
video_url = request.args.get('url')
|
||||
|
||||
# 验证 API Key
|
||||
key_obj, error = validate_and_get_key(api_key)
|
||||
if error:
|
||||
return jsonify({'code': 401, 'msg': error})
|
||||
|
||||
# 验证 URL
|
||||
if not video_url:
|
||||
return jsonify({'code': 400, 'msg': '请提供视频链接'})
|
||||
|
||||
# 检测平台
|
||||
try:
|
||||
platform = ParserFactory.detect_platform(video_url)
|
||||
except ValueError as e:
|
||||
return jsonify({'code': 400, 'msg': str(e)})
|
||||
|
||||
# 展开短链接
|
||||
video_url = ParserFactory.expand_short_url(video_url)
|
||||
|
||||
# 获取客户端IP
|
||||
ip_address = get_client_ip(request)
|
||||
start_time = time.time()
|
||||
|
||||
# 获取该平台所有可用的API
|
||||
available_apis = ParserAPI.query.filter_by(
|
||||
platform=platform.lower(),
|
||||
is_enabled=True
|
||||
).all()
|
||||
|
||||
if not available_apis:
|
||||
return jsonify({'code': 503, 'msg': f'没有可用的{platform}解析接口'})
|
||||
|
||||
last_error = None
|
||||
user_id = key_obj.user_id
|
||||
|
||||
# 尝试所有可用的API(failover机制)
|
||||
for api_config in available_apis:
|
||||
try:
|
||||
parser = ParserFactory.create_parser(api_config)
|
||||
result = parser.parse(video_url)
|
||||
|
||||
# 验证解析结果,video_url 不能为空
|
||||
if not result.get('video_url'):
|
||||
raise Exception('未能获取到视频链接')
|
||||
|
||||
response_time = int((time.time() - start_time) * 1000)
|
||||
|
||||
# 记录成功日志
|
||||
log = ParseLog(
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
platform=platform,
|
||||
video_url=video_url,
|
||||
parser_api_id=api_config.id,
|
||||
status='success',
|
||||
response_time=response_time
|
||||
)
|
||||
db.session.add(log)
|
||||
|
||||
# 更新API统计
|
||||
api_config.total_calls = (api_config.total_calls or 0) + 1
|
||||
api_config.success_calls = (api_config.success_calls or 0) + 1
|
||||
avg_time = api_config.avg_response_time or 0
|
||||
api_config.avg_response_time = int(
|
||||
(avg_time * (api_config.total_calls - 1) + response_time) / api_config.total_calls
|
||||
)
|
||||
api_config.fail_count = 0
|
||||
|
||||
# 记录 API Key 调用
|
||||
record_api_call(key_obj, ip_address, success=True)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '解析成功',
|
||||
'data': {
|
||||
'cover': result.get('cover', ''),
|
||||
'title': result.get('title', ''),
|
||||
'description': result.get('description', ''),
|
||||
'author': result.get('author', ''),
|
||||
'video_url': result.get('video_url', '')
|
||||
}
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
api_config.total_calls = (api_config.total_calls or 0) + 1
|
||||
api_config.fail_count = (api_config.fail_count or 0) + 1
|
||||
db.session.commit()
|
||||
continue
|
||||
|
||||
# 所有API都失败
|
||||
response_time = int((time.time() - start_time) * 1000)
|
||||
|
||||
log = ParseLog(
|
||||
user_id=user_id,
|
||||
ip_address=ip_address,
|
||||
platform=platform,
|
||||
video_url=video_url,
|
||||
status='failed',
|
||||
error_message=last_error or '所有接口都失败',
|
||||
response_time=response_time
|
||||
)
|
||||
db.session.add(log)
|
||||
record_api_call(key_obj, ip_address, success=False)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'code': 500,
|
||||
'msg': last_error or '解析失败,请稍后重试'
|
||||
})
|
||||
|
||||
|
||||
@api_v1_bp.route('/platforms', methods=['GET'])
|
||||
def get_platforms():
|
||||
"""获取支持的平台列表"""
|
||||
return jsonify({
|
||||
'code': 200,
|
||||
'msg': '获取成功',
|
||||
'data': [
|
||||
{'name': 'douyin', 'display_name': '抖音'},
|
||||
{'name': 'tiktok', 'display_name': 'TikTok'},
|
||||
{'name': 'bilibili', 'display_name': '哔哩哔哩'},
|
||||
{'name': 'kuaishou', 'display_name': '快手'},
|
||||
{'name': 'pipixia', 'display_name': '皮皮虾'},
|
||||
{'name': 'weibo', 'display_name': '微博'}
|
||||
]
|
||||
})
|
||||
174
routes/apikey.py
Normal file
174
routes/apikey.py
Normal file
@@ -0,0 +1,174 @@
|
||||
from flask import Blueprint, request, jsonify, render_template
|
||||
from flask_login import login_required, current_user
|
||||
from models import db, UserApiKey, ApiKeyDailyStat
|
||||
from datetime import datetime, date
|
||||
import secrets
|
||||
|
||||
apikey_bp = Blueprint('apikey', __name__, url_prefix='/user/apikey')
|
||||
|
||||
|
||||
def generate_api_key():
|
||||
"""生成 API Key"""
|
||||
return f"sk_{secrets.token_hex(24)}"
|
||||
|
||||
|
||||
@apikey_bp.route('/')
|
||||
@login_required
|
||||
def manage_page():
|
||||
"""API Key 管理页面"""
|
||||
return render_template('apikey/manage.html')
|
||||
|
||||
|
||||
@apikey_bp.route('/list', methods=['GET'])
|
||||
@login_required
|
||||
def list_keys():
|
||||
"""获取用户的 API Key 列表"""
|
||||
keys = UserApiKey.query.filter_by(user_id=current_user.id).order_by(UserApiKey.created_at.desc()).all()
|
||||
|
||||
result = []
|
||||
for key in keys:
|
||||
# 获取今日调用次数
|
||||
today_stat = ApiKeyDailyStat.query.filter_by(
|
||||
api_key_id=key.id,
|
||||
date=date.today()
|
||||
).first()
|
||||
|
||||
result.append({
|
||||
'id': key.id,
|
||||
'name': key.name,
|
||||
'api_key': key.api_key[:12] + '...' + key.api_key[-4:], # 隐藏中间部分
|
||||
'is_active': key.is_active,
|
||||
'daily_limit': key.daily_limit,
|
||||
'total_calls': key.total_calls,
|
||||
'today_calls': today_stat.call_count if today_stat else 0,
|
||||
'last_used_at': key.last_used_at.strftime('%Y-%m-%d %H:%M:%S') if key.last_used_at else None,
|
||||
'created_at': key.created_at.strftime('%Y-%m-%d %H:%M:%S')
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': result
|
||||
})
|
||||
|
||||
|
||||
@apikey_bp.route('/create', methods=['POST'])
|
||||
@login_required
|
||||
def create_key():
|
||||
"""创建新的 API Key"""
|
||||
data = request.get_json()
|
||||
name = data.get('name', '').strip()
|
||||
|
||||
if not name:
|
||||
return jsonify({'success': False, 'message': '请输入 Key 名称'}), 400
|
||||
|
||||
if len(name) > 100:
|
||||
return jsonify({'success': False, 'message': 'Key 名称不能超过100个字符'}), 400
|
||||
|
||||
# 检查用户已有的 Key 数量(限制每个用户最多5个)
|
||||
key_count = UserApiKey.query.filter_by(user_id=current_user.id).count()
|
||||
if key_count >= 5:
|
||||
return jsonify({'success': False, 'message': '每个用户最多创建5个 API Key'}), 400
|
||||
|
||||
# 生成新的 API Key
|
||||
api_key = generate_api_key()
|
||||
|
||||
# 根据用户等级设置每日限制
|
||||
daily_limit = 100 # 默认
|
||||
if current_user.group:
|
||||
if current_user.group.id == 3: # VIP
|
||||
daily_limit = 500
|
||||
elif current_user.group.id == 4: # SVIP
|
||||
daily_limit = 2000
|
||||
|
||||
new_key = UserApiKey(
|
||||
user_id=current_user.id,
|
||||
name=name,
|
||||
api_key=api_key,
|
||||
daily_limit=daily_limit
|
||||
)
|
||||
|
||||
db.session.add(new_key)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'API Key 创建成功',
|
||||
'data': {
|
||||
'id': new_key.id,
|
||||
'name': new_key.name,
|
||||
'api_key': api_key, # 只在创建时返回完整的 Key
|
||||
'daily_limit': new_key.daily_limit
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@apikey_bp.route('/delete/<int:key_id>', methods=['DELETE'])
|
||||
@login_required
|
||||
def delete_key(key_id):
|
||||
"""删除 API Key"""
|
||||
key = UserApiKey.query.filter_by(id=key_id, user_id=current_user.id).first()
|
||||
|
||||
if not key:
|
||||
return jsonify({'success': False, 'message': 'API Key 不存在'}), 404
|
||||
|
||||
db.session.delete(key)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'API Key 已删除'
|
||||
})
|
||||
|
||||
|
||||
@apikey_bp.route('/toggle/<int:key_id>', methods=['POST'])
|
||||
@login_required
|
||||
def toggle_key(key_id):
|
||||
"""启用/禁用 API Key"""
|
||||
key = UserApiKey.query.filter_by(id=key_id, user_id=current_user.id).first()
|
||||
|
||||
if not key:
|
||||
return jsonify({'success': False, 'message': 'API Key 不存在'}), 404
|
||||
|
||||
key.is_active = not key.is_active
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f"API Key 已{'启用' if key.is_active else '禁用'}",
|
||||
'is_active': key.is_active
|
||||
})
|
||||
|
||||
|
||||
@apikey_bp.route('/stats/<int:key_id>', methods=['GET'])
|
||||
@login_required
|
||||
def get_stats(key_id):
|
||||
"""获取 API Key 统计数据"""
|
||||
key = UserApiKey.query.filter_by(id=key_id, user_id=current_user.id).first()
|
||||
|
||||
if not key:
|
||||
return jsonify({'success': False, 'message': 'API Key 不存在'}), 404
|
||||
|
||||
# 获取最近7天的统计
|
||||
from datetime import timedelta
|
||||
stats = ApiKeyDailyStat.query.filter(
|
||||
ApiKeyDailyStat.api_key_id == key_id,
|
||||
ApiKeyDailyStat.date >= date.today() - timedelta(days=6)
|
||||
).order_by(ApiKeyDailyStat.date.asc()).all()
|
||||
|
||||
result = []
|
||||
for stat in stats:
|
||||
result.append({
|
||||
'date': stat.date.strftime('%Y-%m-%d'),
|
||||
'call_count': stat.call_count,
|
||||
'success_count': stat.success_count,
|
||||
'fail_count': stat.fail_count
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'data': {
|
||||
'key_name': key.name,
|
||||
'total_calls': key.total_calls,
|
||||
'daily_stats': result
|
||||
}
|
||||
})
|
||||
@@ -219,14 +219,22 @@ def profile():
|
||||
@login_required
|
||||
def get_profile():
|
||||
"""获取用户个人中心数据"""
|
||||
from models import UserGroup, DailyParseStat, ParseLog
|
||||
from datetime import date
|
||||
from models import UserGroup, DailyParseStat, ParseLog, UserGroupExpiry
|
||||
from datetime import date, datetime
|
||||
|
||||
# 获取用户组信息
|
||||
user_group = UserGroup.query.get(current_user.group_id)
|
||||
daily_limit = user_group.daily_limit if user_group else 10
|
||||
group_name = user_group.name if user_group else '普通用户'
|
||||
|
||||
# 获取套餐到期时间
|
||||
group_expiry = UserGroupExpiry.query.filter_by(user_id=current_user.id).first()
|
||||
expires_at = None
|
||||
is_expired = False
|
||||
if group_expiry:
|
||||
expires_at = group_expiry.expires_at.strftime('%Y-%m-%d %H:%M') if group_expiry.expires_at else None
|
||||
is_expired = group_expiry.expires_at < datetime.utcnow() if group_expiry.expires_at else False
|
||||
|
||||
# 获取今日使用次数
|
||||
today = date.today()
|
||||
today_stat = DailyParseStat.query.filter_by(
|
||||
@@ -263,7 +271,9 @@ def get_profile():
|
||||
'group': {
|
||||
'id': current_user.group_id,
|
||||
'name': group_name,
|
||||
'daily_limit': daily_limit
|
||||
'daily_limit': daily_limit,
|
||||
'expires_at': expires_at,
|
||||
'is_expired': is_expired
|
||||
},
|
||||
'usage': {
|
||||
'daily_limit': daily_limit,
|
||||
@@ -274,3 +284,72 @@ def get_profile():
|
||||
'parse_logs': logs_data
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@auth_bp.route('/api/redeem', methods=['POST'])
|
||||
@login_required
|
||||
def redeem_code():
|
||||
"""用户兑换码"""
|
||||
from models import RedeemCode, UserGroupExpiry
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
data = request.get_json()
|
||||
code_str = data.get('code', '').strip().upper()
|
||||
|
||||
if not code_str:
|
||||
return jsonify({'success': False, 'message': '请输入兑换码'}), 400
|
||||
|
||||
# 查找兑换码
|
||||
code = RedeemCode.query.filter_by(code=code_str).first()
|
||||
|
||||
if not code:
|
||||
return jsonify({'success': False, 'message': '兑换码不存在'}), 404
|
||||
|
||||
if code.is_used:
|
||||
return jsonify({'success': False, 'message': '兑换码已被使用'}), 400
|
||||
|
||||
if code.expires_at and code.expires_at < datetime.utcnow():
|
||||
return jsonify({'success': False, 'message': '兑换码已过期'}), 400
|
||||
|
||||
# 执行兑换
|
||||
user = current_user
|
||||
|
||||
# 更新用户组
|
||||
old_group_id = user.group_id
|
||||
user.group_id = code.target_group_id
|
||||
|
||||
# 计算到期时间
|
||||
expires_at = datetime.utcnow() + timedelta(days=code.duration_days)
|
||||
|
||||
# 更新或创建到期记录
|
||||
expiry = UserGroupExpiry.query.filter_by(user_id=user.id).first()
|
||||
if expiry:
|
||||
# 如果当前套餐未过期且是同一套餐,则叠加时间
|
||||
if expiry.group_id == code.target_group_id and expiry.expires_at > datetime.utcnow():
|
||||
expires_at = expiry.expires_at + timedelta(days=code.duration_days)
|
||||
expiry.group_id = code.target_group_id
|
||||
expiry.expires_at = expires_at
|
||||
else:
|
||||
expiry = UserGroupExpiry(
|
||||
user_id=user.id,
|
||||
group_id=code.target_group_id,
|
||||
expires_at=expires_at
|
||||
)
|
||||
db.session.add(expiry)
|
||||
|
||||
# 标记兑换码已使用
|
||||
code.is_used = True
|
||||
code.used_by = user.id
|
||||
code.used_at = datetime.utcnow()
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'兑换成功!您已升级为 {code.target_group.name},有效期至 {expires_at.strftime("%Y-%m-%d %H:%M")}',
|
||||
'data': {
|
||||
'group_name': code.target_group.name,
|
||||
'expires_at': expires_at.strftime('%Y-%m-%d %H:%M'),
|
||||
'duration_days': code.duration_days
|
||||
}
|
||||
})
|
||||
|
||||
112
routes/main.py
112
routes/main.py
@@ -1,4 +1,5 @@
|
||||
from flask import Blueprint, render_template, redirect
|
||||
from flask import Blueprint, render_template, redirect, request, Response
|
||||
import requests
|
||||
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
@@ -11,6 +12,12 @@ def index():
|
||||
config[c.config_key] = c.config_value
|
||||
return render_template('index.html', config=config)
|
||||
|
||||
@main_bp.route('/api-docs')
|
||||
def api_docs():
|
||||
"""API 文档页面"""
|
||||
return render_template('api_docs.html')
|
||||
|
||||
|
||||
@main_bp.route('/favicon.ico')
|
||||
def favicon():
|
||||
"""处理 favicon 请求"""
|
||||
@@ -24,3 +31,106 @@ def favicon():
|
||||
return redirect(logo_config.config_value)
|
||||
# 返回空响应避免 404
|
||||
return '', 204
|
||||
|
||||
|
||||
@main_bp.route('/proxy/download')
|
||||
def proxy_download():
|
||||
"""代理下载视频/图片,绕过防盗链,支持 Range 请求"""
|
||||
url = request.args.get('url')
|
||||
if not url:
|
||||
return 'Missing url parameter', 400
|
||||
|
||||
# 限制只能代理特定域名
|
||||
allowed_domains = [
|
||||
'weibocdn.com', 'sinaimg.cn', 'weibo.com',
|
||||
'ppxvod.com', 'byteimg.com', 'pipix.com', '365yg.com', 'ixigua.com',
|
||||
'kwimgs.com', 'kwaicdn.com', 'kuaishou.com', 'gifshow.com', 'yximgs.com',
|
||||
'douyinvod.com', 'douyin.com', 'tiktokcdn.com', 'bytedance.com',
|
||||
'bilivideo.com', 'bilibili.com', 'hdslb.com',
|
||||
'zjcdn.com', 'douyinpic.com'
|
||||
]
|
||||
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(url)
|
||||
domain = parsed.netloc.lower()
|
||||
|
||||
is_allowed = any(d in domain for d in allowed_domains)
|
||||
if not is_allowed:
|
||||
return f'Domain not allowed: {domain}', 403
|
||||
|
||||
# 根据域名设置不同的 Referer
|
||||
referer_map = {
|
||||
'weibo': 'https://weibo.com/',
|
||||
'sina': 'https://weibo.com/',
|
||||
'pipix': 'https://www.pipix.com/',
|
||||
'365yg': 'https://www.pipix.com/',
|
||||
'ixigua': 'https://www.ixigua.com/',
|
||||
'kuaishou': 'https://www.kuaishou.com/',
|
||||
'kwimgs': 'https://www.kuaishou.com/',
|
||||
'kwaicdn': 'https://www.kuaishou.com/',
|
||||
'yximgs': 'https://www.kuaishou.com/',
|
||||
'douyin': 'https://www.douyin.com/',
|
||||
'bytedance': 'https://www.douyin.com/',
|
||||
'zjcdn': 'https://www.douyin.com/',
|
||||
'douyinpic': 'https://www.douyin.com/',
|
||||
'bilibili': 'https://www.bilibili.com/',
|
||||
'hdslb': 'https://www.bilibili.com/',
|
||||
}
|
||||
|
||||
referer = 'https://www.google.com/'
|
||||
for key, ref in referer_map.items():
|
||||
if key in domain:
|
||||
referer = ref
|
||||
break
|
||||
|
||||
try:
|
||||
headers = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Referer': referer
|
||||
}
|
||||
|
||||
# 转发 Range 请求头,支持视频进度条拖动
|
||||
range_header = request.headers.get('Range')
|
||||
if range_header:
|
||||
headers['Range'] = range_header
|
||||
|
||||
resp = requests.get(url, headers=headers, stream=True, timeout=30)
|
||||
|
||||
# 获取文件类型
|
||||
content_type = resp.headers.get('Content-Type', 'application/octet-stream')
|
||||
|
||||
# 设置下载文件名
|
||||
if 'video' in content_type:
|
||||
filename = f'video_{int(__import__("time").time())}.mp4'
|
||||
elif 'image' in content_type:
|
||||
filename = f'image_{int(__import__("time").time())}.jpg'
|
||||
else:
|
||||
filename = f'file_{int(__import__("time").time())}'
|
||||
|
||||
def generate():
|
||||
for chunk in resp.iter_content(chunk_size=8192):
|
||||
yield chunk
|
||||
|
||||
# 构建响应头
|
||||
response_headers = {
|
||||
'Content-Type': content_type,
|
||||
'Accept-Ranges': 'bytes'
|
||||
}
|
||||
|
||||
# 转发相关响应头
|
||||
if resp.headers.get('Content-Length'):
|
||||
response_headers['Content-Length'] = resp.headers.get('Content-Length')
|
||||
if resp.headers.get('Content-Range'):
|
||||
response_headers['Content-Range'] = resp.headers.get('Content-Range')
|
||||
|
||||
# 根据是否是 Range 请求返回不同状态码
|
||||
status_code = resp.status_code
|
||||
|
||||
return Response(
|
||||
generate(),
|
||||
status=status_code,
|
||||
headers=response_headers
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return f'Download failed: {str(e)}', 500
|
||||
|
||||
@@ -40,6 +40,9 @@ def parse_video():
|
||||
except ValueError as e:
|
||||
return jsonify({'success': False, 'message': str(e)}), 400
|
||||
|
||||
# 展开短链接
|
||||
video_url = ParserFactory.expand_short_url(video_url)
|
||||
|
||||
# 生成任务ID
|
||||
task_id = str(uuid.uuid4())
|
||||
|
||||
@@ -116,6 +119,10 @@ def _process_task(task_id, video_url, platform, user_id, ip_address):
|
||||
# 执行解析
|
||||
result = parser.parse(video_url)
|
||||
|
||||
# 验证解析结果,video_url 不能为空
|
||||
if not result.get('video_url'):
|
||||
raise Exception('未能获取到视频链接')
|
||||
|
||||
# 计算响应时间
|
||||
response_time = int((time.time() - start_time) * 1000)
|
||||
|
||||
|
||||
@@ -136,9 +136,130 @@ h1, h2, h3, h4, h5, h6 {
|
||||
box-shadow: 0 0 0 3px var(--primary-100);
|
||||
}
|
||||
|
||||
/* Select / Dropdown */
|
||||
/* Custom Select / Dropdown */
|
||||
.ui-select-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ui-select-trigger {
|
||||
width: 100%;
|
||||
padding: 0.625rem 2.5rem 0.625rem 1rem;
|
||||
border: 1px solid var(--secondary-200);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: white;
|
||||
color: var(--text-main);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
transition: var(--transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ui-select-trigger::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 5px solid var(--secondary-500);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.ui-select-wrapper.open .ui-select-trigger::after {
|
||||
transform: translateY(-50%) rotate(180deg);
|
||||
}
|
||||
|
||||
.ui-select-trigger:hover {
|
||||
border-color: var(--secondary-300);
|
||||
}
|
||||
|
||||
.ui-select-wrapper.open .ui-select-trigger,
|
||||
.ui-select-trigger:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-500);
|
||||
box-shadow: 0 0 0 3px var(--primary-100);
|
||||
}
|
||||
|
||||
.ui-select-trigger .placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ui-select-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: white;
|
||||
border: 1px solid var(--secondary-200);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 1000;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(-8px);
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.ui-select-wrapper.open .ui-select-dropdown {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.ui-select-option {
|
||||
padding: 0.625rem 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ui-select-option:hover {
|
||||
background: var(--secondary-50);
|
||||
}
|
||||
|
||||
.ui-select-option.selected {
|
||||
background: var(--primary-50);
|
||||
color: var(--primary-700);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ui-select-option.selected::before {
|
||||
content: '✓';
|
||||
font-size: 0.75rem;
|
||||
color: var(--primary-600);
|
||||
}
|
||||
|
||||
.ui-select-option:first-child {
|
||||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||||
}
|
||||
|
||||
.ui-select-option:last-child {
|
||||
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
||||
}
|
||||
|
||||
/* 隐藏原生 select */
|
||||
.ui-select-wrapper select.ui-select-native {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 兼容旧的 select.ui-input 样式 */
|
||||
select.ui-input,
|
||||
.ui-select {
|
||||
select.ui-select {
|
||||
width: 100%;
|
||||
padding: 0.625rem 2.5rem 0.625rem 1rem;
|
||||
border: 1px solid var(--secondary-200);
|
||||
@@ -158,14 +279,14 @@ select.ui-input,
|
||||
}
|
||||
|
||||
select.ui-input:focus,
|
||||
.ui-select:focus {
|
||||
select.ui-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-500);
|
||||
box-shadow: 0 0 0 3px var(--primary-100);
|
||||
}
|
||||
|
||||
select.ui-input:hover,
|
||||
.ui-select:hover {
|
||||
select.ui-select:hover {
|
||||
border-color: var(--secondary-300);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,107 @@
|
||||
// 现代化UI组件库
|
||||
const UI = {
|
||||
// 初始化所有自定义下拉框
|
||||
initSelects() {
|
||||
document.querySelectorAll('select.ui-input, select.ui-select').forEach(select => {
|
||||
if (select.dataset.uiInit) return; // 已初始化
|
||||
this.createCustomSelect(select);
|
||||
});
|
||||
},
|
||||
|
||||
// 创建自定义下拉框
|
||||
createCustomSelect(select) {
|
||||
select.dataset.uiInit = 'true';
|
||||
|
||||
// 创建包装器
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = 'ui-select-wrapper';
|
||||
select.parentNode.insertBefore(wrapper, select);
|
||||
|
||||
// 隐藏原生 select
|
||||
select.classList.add('ui-select-native');
|
||||
wrapper.appendChild(select);
|
||||
|
||||
// 创建触发器
|
||||
const trigger = document.createElement('div');
|
||||
trigger.className = 'ui-select-trigger';
|
||||
trigger.tabIndex = 0;
|
||||
wrapper.appendChild(trigger);
|
||||
|
||||
// 创建下拉菜单
|
||||
const dropdown = document.createElement('div');
|
||||
dropdown.className = 'ui-select-dropdown';
|
||||
wrapper.appendChild(dropdown);
|
||||
|
||||
// 更新选项
|
||||
const updateOptions = () => {
|
||||
dropdown.innerHTML = '';
|
||||
const selectedValue = select.value;
|
||||
let selectedText = '';
|
||||
|
||||
Array.from(select.options).forEach(option => {
|
||||
const optionEl = document.createElement('div');
|
||||
optionEl.className = 'ui-select-option';
|
||||
optionEl.dataset.value = option.value;
|
||||
optionEl.textContent = option.textContent;
|
||||
|
||||
if (option.value === selectedValue) {
|
||||
optionEl.classList.add('selected');
|
||||
selectedText = option.textContent;
|
||||
}
|
||||
|
||||
optionEl.addEventListener('click', () => {
|
||||
select.value = option.value;
|
||||
select.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
wrapper.classList.remove('open');
|
||||
updateTrigger();
|
||||
});
|
||||
|
||||
dropdown.appendChild(optionEl);
|
||||
});
|
||||
|
||||
return selectedText;
|
||||
};
|
||||
|
||||
// 更新触发器显示
|
||||
const updateTrigger = () => {
|
||||
const selectedText = updateOptions();
|
||||
if (selectedText) {
|
||||
trigger.innerHTML = `<span>${selectedText}</span>`;
|
||||
} else {
|
||||
trigger.innerHTML = `<span class="placeholder">${select.options[0]?.textContent || '请选择'}</span>`;
|
||||
}
|
||||
};
|
||||
|
||||
// 初始化
|
||||
updateTrigger();
|
||||
|
||||
// 点击触发器
|
||||
trigger.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
// 关闭其他下拉框
|
||||
document.querySelectorAll('.ui-select-wrapper.open').forEach(w => {
|
||||
if (w !== wrapper) w.classList.remove('open');
|
||||
});
|
||||
wrapper.classList.toggle('open');
|
||||
});
|
||||
|
||||
// 键盘支持
|
||||
trigger.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
wrapper.classList.toggle('open');
|
||||
} else if (e.key === 'Escape') {
|
||||
wrapper.classList.remove('open');
|
||||
}
|
||||
});
|
||||
|
||||
// 监听原生 select 变化
|
||||
select.addEventListener('change', updateTrigger);
|
||||
|
||||
// 监听选项变化(用于动态更新)
|
||||
const observer = new MutationObserver(updateTrigger);
|
||||
observer.observe(select, { childList: true, subtree: true });
|
||||
},
|
||||
// 显示通知消息
|
||||
notify(message, type = 'info', duration = 3000) {
|
||||
const container = document.getElementById('notification-container') || this.createNotificationContainer();
|
||||
@@ -118,3 +220,20 @@ const UI = {
|
||||
return icons[type] || icons.info;
|
||||
}
|
||||
};
|
||||
|
||||
// 点击外部关闭下拉框
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.ui-select-wrapper')) {
|
||||
document.querySelectorAll('.ui-select-wrapper.open').forEach(w => {
|
||||
w.classList.remove('open');
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 页面加载完成后自动初始化下拉框
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
UI.initSelects();
|
||||
});
|
||||
|
||||
// 提供手动刷新方法(用于动态加载的内容)
|
||||
UI.refreshSelects = UI.initSelects;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<a href="/admin/dashboard" class="nav-item">仪表板</a>
|
||||
<a href="/admin/users" class="nav-item">用户管理</a>
|
||||
<a href="/admin/apis" class="nav-item active">接口管理</a>
|
||||
<a href="/admin/redeem-codes" class="nav-item">兑换码</a>
|
||||
<a href="/admin/config" class="nav-item">系统配置</a>
|
||||
<a href="/admin/logs" class="nav-item">日志审计</a>
|
||||
</nav>
|
||||
@@ -63,6 +64,9 @@
|
||||
<option value="douyin">抖音</option>
|
||||
<option value="tiktok">TikTok</option>
|
||||
<option value="bilibili">哔哩哔哩</option>
|
||||
<option value="kuaishou">快手</option>
|
||||
<option value="pipixia">皮皮虾</option>
|
||||
<option value="weibo">微博</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<a href="/admin/dashboard" class="nav-item">仪表板</a>
|
||||
<a href="/admin/users" class="nav-item">用户管理</a>
|
||||
<a href="/admin/apis" class="nav-item">接口管理</a>
|
||||
<a href="/admin/redeem-codes" class="nav-item">兑换码</a>
|
||||
<a href="/admin/config" class="nav-item active">系统配置</a>
|
||||
<a href="/admin/logs" class="nav-item">日志审计</a>
|
||||
</nav>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<a href="/admin/dashboard" class="nav-item active">仪表板</a>
|
||||
<a href="/admin/users" class="nav-item">用户管理</a>
|
||||
<a href="/admin/apis" class="nav-item">接口管理</a>
|
||||
<a href="/admin/redeem-codes" class="nav-item">兑换码</a>
|
||||
<a href="/admin/config" class="nav-item">系统配置</a>
|
||||
<a href="/admin/logs" class="nav-item">日志审计</a>
|
||||
</nav>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<a href="/admin/dashboard" class="nav-item">仪表板</a>
|
||||
<a href="/admin/users" class="nav-item">用户管理</a>
|
||||
<a href="/admin/apis" class="nav-item">接口管理</a>
|
||||
<a href="/admin/redeem-codes" class="nav-item">兑换码</a>
|
||||
<a href="/admin/config" class="nav-item active">系统配置</a>
|
||||
<a href="/admin/logs" class="nav-item">日志审计</a>
|
||||
</nav>
|
||||
@@ -75,6 +76,9 @@
|
||||
<option value="douyin">抖音 (douyin)</option>
|
||||
<option value="tiktok">TikTok (tiktok)</option>
|
||||
<option value="bilibili">哔哩哔哩 (bilibili)</option>
|
||||
<option value="kuaishou">快手 (kuaishou)</option>
|
||||
<option value="pipixia">皮皮虾 (pipixia)</option>
|
||||
<option value="weibo">微博 (weibo)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<a href="/admin/dashboard" class="nav-item">仪表板</a>
|
||||
<a href="/admin/users" class="nav-item">用户管理</a>
|
||||
<a href="/admin/apis" class="nav-item">接口管理</a>
|
||||
<a href="/admin/redeem-codes" class="nav-item">兑换码</a>
|
||||
<a href="/admin/config" class="nav-item">系统配置</a>
|
||||
<a href="/admin/logs" class="nav-item active">日志审计</a>
|
||||
</nav>
|
||||
@@ -39,6 +40,9 @@
|
||||
<option value="douyin">抖音</option>
|
||||
<option value="tiktok">TikTok</option>
|
||||
<option value="bilibili">哔哩哔哩</option>
|
||||
<option value="kuaishou">快手</option>
|
||||
<option value="pipixia">皮皮虾</option>
|
||||
<option value="weibo">微博</option>
|
||||
</select>
|
||||
<select id="statusFilter" class="ui-input" style="width: auto; margin-bottom: 0;"
|
||||
onchange="loadLogs(1)">
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<a href="/admin/dashboard" class="nav-item">仪表板</a>
|
||||
<a href="/admin/users" class="nav-item">用户管理</a>
|
||||
<a href="/admin/apis" class="nav-item">接口管理</a>
|
||||
<a href="/admin/redeem-codes" class="nav-item">兑换码</a>
|
||||
<a href="/admin/config" class="nav-item">系统配置</a>
|
||||
<a href="/admin/logs" class="nav-item">日志审计</a>
|
||||
</nav>
|
||||
|
||||
521
templates/admin_redeem_codes.html
Normal file
521
templates/admin_redeem_codes.html
Normal file
@@ -0,0 +1,521 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>兑换码管理 - 管理后台</title>
|
||||
<link rel="stylesheet" href="/static/css/ui-components.css?v=3">
|
||||
<link rel="stylesheet" href="/static/css/admin.css">
|
||||
</head>
|
||||
|
||||
<body class="admin-layout">
|
||||
<header class="admin-header">
|
||||
<div class="header-container">
|
||||
<a href="/admin/dashboard" class="brand">
|
||||
<span style="font-size: 1.5rem;">⚡</span> JieXi Admin
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href="/admin/dashboard" class="nav-item">仪表板</a>
|
||||
<a href="/admin/users" class="nav-item">用户管理</a>
|
||||
<a href="/admin/apis" class="nav-item">接口管理</a>
|
||||
<a href="/admin/redeem-codes" class="nav-item active">兑换码</a>
|
||||
<a href="/admin/config" class="nav-item">系统配置</a>
|
||||
<a href="/admin/logs" class="nav-item">日志审计</a>
|
||||
</nav>
|
||||
<div class="user-actions">
|
||||
<a href="/admin/profile" class="ui-btn ui-btn-secondary ui-btn-sm">账号设置</a>
|
||||
<button class="ui-btn ui-btn-secondary ui-btn-sm" onclick="logout()">退出登录</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="main-container">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">兑换码管理</h1>
|
||||
<button class="ui-btn ui-btn-primary" onclick="showGenerateModal()">批量生成</button>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<div class="stats-grid" style="margin-bottom: 1.5rem;">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">总数量</div>
|
||||
<div class="stat-value" id="totalCount">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">未使用</div>
|
||||
<div class="stat-value" id="unusedCount" style="color: var(--success);">-</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label">已使用</div>
|
||||
<div class="stat-value" id="usedCount">-</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选 -->
|
||||
<div class="filter-bar" style="display: flex; gap: 1rem; margin-bottom: 1rem;">
|
||||
<select id="filterStatus" class="ui-select" onchange="loadCodes()">
|
||||
<option value="">全部状态</option>
|
||||
<option value="unused">未使用</option>
|
||||
<option value="used">已使用</option>
|
||||
<option value="expired">已过期</option>
|
||||
</select>
|
||||
<select id="filterBatch" class="ui-select" onchange="loadCodes()">
|
||||
<option value="">全部批次</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 兑换码列表 -->
|
||||
<div class="ui-card">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>兑换码</th>
|
||||
<th>目标套餐</th>
|
||||
<th>有效期</th>
|
||||
<th>状态</th>
|
||||
<th>使用者</th>
|
||||
<th>使用时间</th>
|
||||
<th>过期时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="codesBody">
|
||||
<tr><td colspan="8" style="text-align: center; color: var(--text-muted);">加载中...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="pagination" id="pagination" style="margin-top: 1rem;"></div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 生成兑换码弹窗 -->
|
||||
<div class="modal" id="generateModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>批量生成兑换码</h3>
|
||||
<button class="modal-close" onclick="closeModal('generateModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>目标套餐 <span class="required">*</span></label>
|
||||
<select id="targetGroup" class="ui-input">
|
||||
<option value="">请选择</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>套餐有效期(天)</label>
|
||||
<input type="number" id="durationDays" class="ui-input" value="30" min="1">
|
||||
<small>用户兑换后,套餐的有效天数</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>生成数量</label>
|
||||
<input type="number" id="generateCount" class="ui-input" value="10" min="1" max="1000">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>兑换码前缀(可选)</label>
|
||||
<input type="text" id="codePrefix" class="ui-input" placeholder="如: VIP">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>兑换码有效期(天,可选)</label>
|
||||
<input type="number" id="expiresDays" class="ui-input" placeholder="留空表示永不过期">
|
||||
<small>兑换码本身的过期时间,过期后无法使用</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>备注(可选)</label>
|
||||
<input type="text" id="remark" class="ui-input" placeholder="如: 活动赠送">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="ui-btn ui-btn-secondary" onclick="closeModal('generateModal')">取消</button>
|
||||
<button class="ui-btn ui-btn-primary" onclick="generateCodes()">生成</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 生成结果弹窗 -->
|
||||
<div class="modal" id="resultModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>生成成功</h3>
|
||||
<button class="modal-close" onclick="closeModal('resultModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>批次ID: <strong id="resultBatchId"></strong></p>
|
||||
<p>生成数量: <strong id="resultCount"></strong></p>
|
||||
<div class="form-group">
|
||||
<label>兑换码列表</label>
|
||||
<textarea id="resultCodes" class="ui-input" rows="10" readonly style="font-family: monospace;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="ui-btn ui-btn-secondary" onclick="closeModal('resultModal')">关闭</button>
|
||||
<button class="ui-btn ui-btn-primary" onclick="copyResultCodes()">复制全部</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ui-select {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: white;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-badge.unused {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.status-badge.used {
|
||||
background: #e0e7ff;
|
||||
color: #3730a3;
|
||||
}
|
||||
|
||||
.status-badge.expired {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.code-text {
|
||||
font-family: monospace;
|
||||
font-size: 0.875rem;
|
||||
background: var(--secondary-100);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--secondary-700);
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background: white;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.page-btn.active {
|
||||
background: var(--primary-600);
|
||||
color: white;
|
||||
border-color: var(--primary-600);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="/static/js/ui-components.js"></script>
|
||||
<script>
|
||||
let currentPage = 1;
|
||||
let userGroups = [];
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await fetch('/admin/logout', { method: 'POST' });
|
||||
window.location.href = '/admin/login';
|
||||
} catch (e) {
|
||||
UI.notify('退出失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserGroups() {
|
||||
try {
|
||||
const response = await fetch('/admin/api/user-groups');
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
userGroups = result.data;
|
||||
const select = document.getElementById('targetGroup');
|
||||
select.innerHTML = '<option value="">请选择</option>' +
|
||||
userGroups.map(g => `<option value="${g.id}">${g.name}</option>`).join('');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载用户组失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBatches() {
|
||||
try {
|
||||
const response = await fetch('/admin/api/redeem-codes/batches');
|
||||
const result = await response.json();
|
||||
if (result.success) {
|
||||
const select = document.getElementById('filterBatch');
|
||||
select.innerHTML = '<option value="">全部批次</option>' +
|
||||
result.data.map(b => `<option value="${b.batch_id}">${b.batch_id.slice(-12)} (${b.unused}/${b.total})</option>`).join('');
|
||||
|
||||
let total = 0, used = 0, unused = 0;
|
||||
result.data.forEach(b => {
|
||||
total += b.total;
|
||||
used += b.used;
|
||||
unused += b.unused;
|
||||
});
|
||||
document.getElementById('totalCount').textContent = total;
|
||||
document.getElementById('usedCount').textContent = used;
|
||||
document.getElementById('unusedCount').textContent = unused;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载批次失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCodes() {
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
const batchId = document.getElementById('filterBatch').value;
|
||||
|
||||
let url = `/admin/api/redeem-codes?page=${currentPage}`;
|
||||
if (status) url += `&status=${status}`;
|
||||
if (batchId) url += `&batch_id=${batchId}`;
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
renderCodes(result.data);
|
||||
renderPagination(result.pagination);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('加载兑换码失败', e);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCodes(codes) {
|
||||
const tbody = document.getElementById('codesBody');
|
||||
|
||||
if (codes.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="8" style="text-align: center; color: var(--text-muted);">暂无数据</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = codes.map(code => {
|
||||
let statusClass = 'unused';
|
||||
let statusText = '未使用';
|
||||
if (code.is_used) {
|
||||
statusClass = 'used';
|
||||
statusText = '已使用';
|
||||
} else if (code.is_expired) {
|
||||
statusClass = 'expired';
|
||||
statusText = '已过期';
|
||||
}
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td><span class="code-text">${code.code}</span></td>
|
||||
<td>${code.target_group}</td>
|
||||
<td>${code.duration_days}天</td>
|
||||
<td><span class="status-badge ${statusClass}">${statusText}</span></td>
|
||||
<td>${code.used_by || '-'}</td>
|
||||
<td>${code.used_at || '-'}</td>
|
||||
<td>${code.expires_at || '永久'}</td>
|
||||
<td>
|
||||
${!code.is_used ? `<button class="ui-btn ui-btn-sm ui-btn-danger" onclick="deleteCode(${code.id})">删除</button>` : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderPagination(pagination) {
|
||||
const container = document.getElementById('pagination');
|
||||
if (pagination.pages <= 1) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '';
|
||||
for (let i = 1; i <= pagination.pages; i++) {
|
||||
html += `<button class="page-btn ${i === pagination.page ? 'active' : ''}" onclick="goToPage(${i})">${i}</button>`;
|
||||
}
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function goToPage(page) {
|
||||
currentPage = page;
|
||||
loadCodes();
|
||||
}
|
||||
|
||||
function showGenerateModal() {
|
||||
document.getElementById('generateModal').classList.add('show');
|
||||
}
|
||||
|
||||
function closeModal(id) {
|
||||
document.getElementById(id).classList.remove('show');
|
||||
}
|
||||
|
||||
async function generateCodes() {
|
||||
const targetGroupId = document.getElementById('targetGroup').value;
|
||||
const durationDays = document.getElementById('durationDays').value;
|
||||
const count = document.getElementById('generateCount').value;
|
||||
const prefix = document.getElementById('codePrefix').value;
|
||||
const expiresDays = document.getElementById('expiresDays').value;
|
||||
const remark = document.getElementById('remark').value;
|
||||
|
||||
if (!targetGroupId) {
|
||||
UI.notify('请选择目标套餐', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/admin/api/redeem-codes/generate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
target_group_id: parseInt(targetGroupId),
|
||||
duration_days: parseInt(durationDays),
|
||||
count: parseInt(count),
|
||||
prefix: prefix,
|
||||
expires_days: expiresDays ? parseInt(expiresDays) : null,
|
||||
remark: remark
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
closeModal('generateModal');
|
||||
|
||||
document.getElementById('resultBatchId').textContent = result.data.batch_id;
|
||||
document.getElementById('resultCount').textContent = result.data.codes.length;
|
||||
document.getElementById('resultCodes').value = result.data.codes.join('\n');
|
||||
document.getElementById('resultModal').classList.add('show');
|
||||
|
||||
loadBatches();
|
||||
loadCodes();
|
||||
} else {
|
||||
UI.notify(result.message, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
UI.notify('生成失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function copyResultCodes() {
|
||||
const textarea = document.getElementById('resultCodes');
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
UI.notify('已复制到剪贴板', 'success');
|
||||
}
|
||||
|
||||
async function deleteCode(id) {
|
||||
if (!confirm('确定要删除这个兑换码吗?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/api/redeem-codes/${id}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
UI.notify('删除成功', 'success');
|
||||
loadBatches();
|
||||
loadCodes();
|
||||
} else {
|
||||
UI.notify(result.message, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
UI.notify('删除失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化
|
||||
loadUserGroups();
|
||||
loadBatches();
|
||||
loadCodes();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -19,6 +19,7 @@
|
||||
<a href="/admin/dashboard" class="nav-item">仪表板</a>
|
||||
<a href="/admin/users" class="nav-item">用户管理</a>
|
||||
<a href="/admin/apis" class="nav-item">接口管理</a>
|
||||
<a href="/admin/redeem-codes" class="nav-item">兑换码</a>
|
||||
<a href="/admin/config" class="nav-item active">系统配置</a>
|
||||
<a href="/admin/logs" class="nav-item">日志审计</a>
|
||||
</nav>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
<a href="/admin/dashboard" class="nav-item">仪表板</a>
|
||||
<a href="/admin/users" class="nav-item active">用户管理</a>
|
||||
<a href="/admin/apis" class="nav-item">接口管理</a>
|
||||
<a href="/admin/redeem-codes" class="nav-item">兑换码</a>
|
||||
<a href="/admin/config" class="nav-item">系统配置</a>
|
||||
<a href="/admin/logs" class="nav-item">日志审计</a>
|
||||
</nav>
|
||||
@@ -105,7 +106,12 @@
|
||||
<input type="hidden" id="editUserId">
|
||||
<div class="form-group">
|
||||
<label>用户分组</label>
|
||||
<select id="editGroupId" class="ui-input"></select>
|
||||
<select id="editGroupId" class="ui-input" onchange="onGroupChange()"></select>
|
||||
</div>
|
||||
<div class="form-group" id="expiryGroup" style="display: none;">
|
||||
<label>套餐到期时间</label>
|
||||
<input type="datetime-local" id="editExpiresAt" class="ui-input">
|
||||
<small style="color: var(--text-muted); font-size: 0.75rem;">游客和普通用户无需设置到期时间</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>账号状态</label>
|
||||
@@ -234,7 +240,10 @@
|
||||
<td>#${u.id}</td>
|
||||
<td><span class="font-medium">${u.username}</span></td>
|
||||
<td class="text-muted">${u.email}</td>
|
||||
<td><span class="badge badge-info">${u.group_name}</span></td>
|
||||
<td>
|
||||
<span class="badge badge-info">${u.group_name}</span>
|
||||
${u.group_id > 2 && u.expires_at ? `<br><small class="text-muted">${u.is_expired ? '已过期' : '至 ' + u.expires_at}</small>` : ''}
|
||||
</td>
|
||||
<td>${u.total_parse_count}</td>
|
||||
<td>
|
||||
<span class="badge ${u.is_active ? 'badge-success' : 'badge-error'}">
|
||||
@@ -243,8 +252,8 @@
|
||||
</td>
|
||||
<td class="text-muted text-sm">${new Date(u.created_at).toLocaleString('zh-CN')}</td>
|
||||
<td>
|
||||
<button class="ui-btn ui-btn-secondary ui-btn-sm"
|
||||
onclick="editUser(${u.id}, ${u.group_id}, ${u.is_active})">
|
||||
<button class="ui-btn ui-btn-secondary ui-btn-sm"
|
||||
onclick="editUser(${u.id}, ${u.group_id}, ${u.is_active}, ${u.expires_at ? `'${u.expires_at}'` : null})">
|
||||
编辑
|
||||
</button>
|
||||
</td>
|
||||
@@ -268,13 +277,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
function editUser(id, groupId, isActive) {
|
||||
function editUser(id, groupId, isActive, expiresAt) {
|
||||
document.getElementById('editUserId').value = id;
|
||||
document.getElementById('editGroupId').value = groupId;
|
||||
document.getElementById('editIsActive').value = isActive;
|
||||
|
||||
// 设置到期时间
|
||||
if (expiresAt) {
|
||||
document.getElementById('editExpiresAt').value = expiresAt.slice(0, 16);
|
||||
} else {
|
||||
document.getElementById('editExpiresAt').value = '';
|
||||
}
|
||||
|
||||
// 根据用户组显示/隐藏到期时间
|
||||
onGroupChange();
|
||||
document.getElementById('editModal').classList.add('show');
|
||||
}
|
||||
|
||||
function onGroupChange() {
|
||||
const groupId = parseInt(document.getElementById('editGroupId').value);
|
||||
const expiryGroup = document.getElementById('expiryGroup');
|
||||
// 游客(1)和普通用户(2)不显示到期时间
|
||||
if (groupId > 2) {
|
||||
expiryGroup.style.display = 'block';
|
||||
} else {
|
||||
expiryGroup.style.display = 'none';
|
||||
document.getElementById('editExpiresAt').value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('editModal').classList.remove('show');
|
||||
}
|
||||
@@ -337,11 +368,19 @@
|
||||
document.getElementById('editForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const userId = document.getElementById('editUserId').value;
|
||||
const groupId = parseInt(document.getElementById('editGroupId').value);
|
||||
const expiresAt = document.getElementById('editExpiresAt').value;
|
||||
|
||||
const data = {
|
||||
group_id: parseInt(document.getElementById('editGroupId').value),
|
||||
group_id: groupId,
|
||||
is_active: document.getElementById('editIsActive').value === 'true'
|
||||
};
|
||||
|
||||
// VIP/SVIP 需要设置到期时间
|
||||
if (groupId > 2) {
|
||||
data.expires_at = expiresAt || null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/admin/api/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
@@ -354,6 +393,7 @@
|
||||
UI.notify('更新成功', 'success');
|
||||
closeModal();
|
||||
loadUsers(currentPage);
|
||||
loadUserStats();
|
||||
} else {
|
||||
UI.notify(result.message || '更新失败', 'error');
|
||||
}
|
||||
|
||||
557
templates/api_docs.html
Normal file
557
templates/api_docs.html
Normal file
@@ -0,0 +1,557 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>API 文档 - 短视频解析平台</title>
|
||||
<link rel="stylesheet" href="/static/css/ui-components.css?v=3">
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #f0f9ff 0%, #e0e7ff 100%);
|
||||
min-height: 100vh;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--secondary-900);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.doc-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.doc-card h2 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--secondary-900);
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.doc-card h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--secondary-800);
|
||||
margin: 1.25rem 0 0.75rem;
|
||||
}
|
||||
|
||||
.doc-card p {
|
||||
color: var(--secondary-600);
|
||||
line-height: 1.7;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.endpoint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.method {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.method.post {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.method.get {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.url {
|
||||
font-family: monospace;
|
||||
font-size: 0.95rem;
|
||||
color: var(--secondary-800);
|
||||
background: var(--secondary-50);
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: #1e293b;
|
||||
color: #e2e8f0;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1rem 1.25rem;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.code-block .copy-btn {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
background: rgba(255,255,255,0.1);
|
||||
border: none;
|
||||
color: #94a3b8;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.code-block .copy-btn:hover {
|
||||
background: rgba(255,255,255,0.2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.code-block .comment {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.code-block .string {
|
||||
color: #a5d6ff;
|
||||
}
|
||||
|
||||
.code-block .key {
|
||||
color: #7dd3fc;
|
||||
}
|
||||
|
||||
.code-block .value {
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.param-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.param-table th,
|
||||
.param-table td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.param-table th {
|
||||
background: var(--secondary-50);
|
||||
font-weight: 600;
|
||||
color: var(--secondary-700);
|
||||
}
|
||||
|
||||
.param-table code {
|
||||
background: var(--secondary-100);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #dc2626;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.platform-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.platform-item {
|
||||
background: var(--secondary-50);
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.platform-name {
|
||||
font-weight: 600;
|
||||
color: var(--secondary-900);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.platform-patterns {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.tip-box {
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #1e40af;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.warning-box {
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #92400e;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.error-codes {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.error-code {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--secondary-100);
|
||||
}
|
||||
|
||||
.error-code:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.error-code code {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.endpoint {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.url {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">API 文档</h1>
|
||||
<div class="header-actions">
|
||||
<a href="/user/apikey" class="ui-btn ui-btn-primary">管理 API Key</a>
|
||||
<a href="/" class="ui-btn ui-btn-secondary">返回首页</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快速开始 -->
|
||||
<div class="doc-card">
|
||||
<h2>快速开始</h2>
|
||||
<p>短视频解析 API 支持抖音、TikTok、哔哩哔哩等平台的视频解析,返回无水印视频链接、封面、标题、作者等信息。</p>
|
||||
|
||||
<h3>1. 获取 API Key</h3>
|
||||
<p>登录后访问 <a href="/user/apikey">API Key 管理</a> 页面创建您的 API Key。</p>
|
||||
|
||||
<h3>2. 发起请求</h3>
|
||||
<div class="code-block">
|
||||
<button class="copy-btn" onclick="copyCode(this)">复制</button>
|
||||
<pre>https://your-domain.com/api/v1/parse?key=sk_xxx&url=视频链接</pre>
|
||||
</div>
|
||||
|
||||
<h3>3. 获取结果</h3>
|
||||
<div class="code-block">
|
||||
<button class="copy-btn" onclick="copyCode(this)">复制</button>
|
||||
<pre>{
|
||||
<span class="key">"code"</span>: <span class="value">200</span>,
|
||||
<span class="key">"msg"</span>: <span class="string">"解析成功"</span>,
|
||||
<span class="key">"data"</span>: {
|
||||
<span class="key">"cover"</span>: <span class="string">"封面URL"</span>,
|
||||
<span class="key">"title"</span>: <span class="string">"视频标题"</span>,
|
||||
<span class="key">"description"</span>: <span class="string">"视频简介"</span>,
|
||||
<span class="key">"author"</span>: <span class="string">"作者名"</span>,
|
||||
<span class="key">"video_url"</span>: <span class="string">"无水印视频链接"</span>
|
||||
}
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 认证方式 -->
|
||||
<div class="doc-card">
|
||||
<h2>认证方式</h2>
|
||||
<p>通过 URL 参数 <code>key</code> 传递 API Key:</p>
|
||||
|
||||
<div class="code-block">
|
||||
<button class="copy-btn" onclick="copyCode(this)">复制</button>
|
||||
<pre>/api/v1/parse?key=您的API_Key&url=视频链接</pre>
|
||||
</div>
|
||||
|
||||
<div class="warning-box">
|
||||
请妥善保管您的 API Key,不要在客户端代码中暴露。建议通过后端服务调用 API。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 解析接口 -->
|
||||
<div class="doc-card">
|
||||
<h2>解析接口</h2>
|
||||
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
<span class="url">/api/v1/parse</span>
|
||||
</div>
|
||||
|
||||
<h3>请求参数</h3>
|
||||
<table class="param-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>参数</th>
|
||||
<th>类型</th>
|
||||
<th>必填</th>
|
||||
<th>说明</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>key</code></td>
|
||||
<td>string</td>
|
||||
<td><span class="required">是</span></td>
|
||||
<td>您的 API Key</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>url</code></td>
|
||||
<td>string</td>
|
||||
<td><span class="required">是</span></td>
|
||||
<td>视频链接(支持短链接和完整链接)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>成功响应</h3>
|
||||
<div class="code-block">
|
||||
<button class="copy-btn" onclick="copyCode(this)">复制</button>
|
||||
<pre>{
|
||||
<span class="key">"code"</span>: <span class="value">200</span>,
|
||||
<span class="key">"msg"</span>: <span class="string">"解析成功"</span>,
|
||||
<span class="key">"data"</span>: {
|
||||
<span class="key">"cover"</span>: <span class="string">"封面图片URL"</span>,
|
||||
<span class="key">"title"</span>: <span class="string">"视频标题"</span>,
|
||||
<span class="key">"description"</span>: <span class="string">"视频简介"</span>,
|
||||
<span class="key">"author"</span>: <span class="string">"作者名称"</span>,
|
||||
<span class="key">"video_url"</span>: <span class="string">"无水印视频URL"</span>
|
||||
}
|
||||
}</pre>
|
||||
</div>
|
||||
|
||||
<h3>错误响应</h3>
|
||||
<div class="code-block">
|
||||
<button class="copy-btn" onclick="copyCode(this)">复制</button>
|
||||
<pre>{
|
||||
<span class="key">"code"</span>: <span class="value">401</span>,
|
||||
<span class="key">"msg"</span>: <span class="string">"错误描述"</span>
|
||||
}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 支持平台 -->
|
||||
<div class="doc-card">
|
||||
<h2>支持平台</h2>
|
||||
<div class="endpoint">
|
||||
<span class="method get">GET</span>
|
||||
<span class="url">/api/v1/platforms</span>
|
||||
</div>
|
||||
<p>此接口无需认证,可获取当前支持的平台列表。</p>
|
||||
|
||||
<div class="platform-list">
|
||||
<div class="platform-item">
|
||||
<div class="platform-name">抖音</div>
|
||||
<div class="platform-patterns">douyin.com, v.douyin.com</div>
|
||||
</div>
|
||||
<div class="platform-item">
|
||||
<div class="platform-name">TikTok</div>
|
||||
<div class="platform-patterns">tiktok.com</div>
|
||||
</div>
|
||||
<div class="platform-item">
|
||||
<div class="platform-name">哔哩哔哩</div>
|
||||
<div class="platform-patterns">bilibili.com, b23.tv</div>
|
||||
</div>
|
||||
<div class="platform-item">
|
||||
<div class="platform-name">快手</div>
|
||||
<div class="platform-patterns">kuaishou.com</div>
|
||||
</div>
|
||||
<div class="platform-item">
|
||||
<div class="platform-name">皮皮虾</div>
|
||||
<div class="platform-patterns">pipix.com, h5.pipix.com</div>
|
||||
</div>
|
||||
<div class="platform-item">
|
||||
<div class="platform-name">微博</div>
|
||||
<div class="platform-patterns">weibo.com, weibo.cn</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误码 -->
|
||||
<div class="doc-card">
|
||||
<h2>错误码说明</h2>
|
||||
<div class="error-codes">
|
||||
<div class="error-code">
|
||||
<code>UNAUTHORIZED</code>
|
||||
<span>API Key 无效、已禁用或超出限额</span>
|
||||
</div>
|
||||
<div class="error-code">
|
||||
<code>INVALID_REQUEST</code>
|
||||
<span>请求格式错误,需要 JSON 格式</span>
|
||||
</div>
|
||||
<div class="error-code">
|
||||
<code>MISSING_URL</code>
|
||||
<span>缺少 url 参数</span>
|
||||
</div>
|
||||
<div class="error-code">
|
||||
<code>UNSUPPORTED_PLATFORM</code>
|
||||
<span>不支持的视频平台</span>
|
||||
</div>
|
||||
<div class="error-code">
|
||||
<code>NO_AVAILABLE_API</code>
|
||||
<span>该平台暂无可用解析接口</span>
|
||||
</div>
|
||||
<div class="error-code">
|
||||
<code>PARSE_FAILED</code>
|
||||
<span>解析失败,请检查链接或稍后重试</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 代码示例 -->
|
||||
<div class="doc-card">
|
||||
<h2>代码示例</h2>
|
||||
|
||||
<h3>Python</h3>
|
||||
<div class="code-block">
|
||||
<button class="copy-btn" onclick="copyCode(this)">复制</button>
|
||||
<pre>import requests
|
||||
|
||||
api_key = "sk_your_api_key_here"
|
||||
video_url = "https://v.douyin.com/xxxxx"
|
||||
|
||||
response = requests.get(
|
||||
"https://your-domain.com/api/v1/parse",
|
||||
params={"key": api_key, "url": video_url}
|
||||
)
|
||||
result = response.json()
|
||||
|
||||
if result["code"] == 200:
|
||||
print("视频标题:", result["data"]["title"])
|
||||
print("作者:", result["data"]["author"])
|
||||
print("视频链接:", result["data"]["video_url"])
|
||||
else:
|
||||
print("错误:", result["msg"])</pre>
|
||||
</div>
|
||||
|
||||
<h3>JavaScript</h3>
|
||||
<div class="code-block">
|
||||
<button class="copy-btn" onclick="copyCode(this)">复制</button>
|
||||
<pre>const apiKey = 'sk_your_api_key_here';
|
||||
const videoUrl = 'https://v.douyin.com/xxxxx';
|
||||
|
||||
fetch(`https://your-domain.com/api/v1/parse?key=${apiKey}&url=${encodeURIComponent(videoUrl)}`)
|
||||
.then(res => res.json())
|
||||
.then(result => {
|
||||
if (result.code === 200) {
|
||||
console.log('视频标题:', result.data.title);
|
||||
console.log('作者:', result.data.author);
|
||||
console.log('视频链接:', result.data.video_url);
|
||||
} else {
|
||||
console.error('错误:', result.msg);
|
||||
}
|
||||
});</pre>
|
||||
</div>
|
||||
|
||||
<h3>cURL</h3>
|
||||
<div class="code-block">
|
||||
<button class="copy-btn" onclick="copyCode(this)">复制</button>
|
||||
<pre>curl "https://your-domain.com/api/v1/parse?key=sk_xxx&url=https://v.douyin.com/xxxxx"</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 限制说明 -->
|
||||
<div class="doc-card">
|
||||
<h2>使用限制</h2>
|
||||
<table class="param-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户等级</th>
|
||||
<th>每日限额</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>普通用户</td>
|
||||
<td>100 次/天/Key</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>VIP 用户</td>
|
||||
<td>500 次/天/Key</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>SVIP 用户</td>
|
||||
<td>2000 次/天/Key</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="tip-box">
|
||||
每个用户最多可创建 5 个 API Key,每日限额按单个 Key 计算。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/ui-components.js"></script>
|
||||
<script>
|
||||
function copyCode(btn) {
|
||||
const codeBlock = btn.parentElement;
|
||||
const code = codeBlock.querySelector('pre').textContent;
|
||||
|
||||
navigator.clipboard.writeText(code).then(() => {
|
||||
const originalText = btn.textContent;
|
||||
btn.textContent = '已复制';
|
||||
setTimeout(() => {
|
||||
btn.textContent = originalText;
|
||||
}, 2000);
|
||||
}).catch(() => {
|
||||
UI.notify('复制失败', 'error');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
465
templates/apikey/manage.html
Normal file
465
templates/apikey/manage.html
Normal file
@@ -0,0 +1,465 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>API Key 管理 - 短视频解析平台</title>
|
||||
<link rel="stylesheet" href="/static/css/ui-components.css?v=3">
|
||||
<style>
|
||||
body {
|
||||
background: linear-gradient(135deg, #f0f9ff 0%, #e0e7ff 100%);
|
||||
min-height: 100vh;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--secondary-900);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.info-card h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--secondary-900);
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.key-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.key-item {
|
||||
background: var(--secondary-50);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1.25rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.key-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.key-name {
|
||||
font-weight: 600;
|
||||
color: var(--secondary-900);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.key-status {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.key-status.active {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.key-status.inactive {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.key-value {
|
||||
font-family: monospace;
|
||||
background: white;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
color: var(--secondary-700);
|
||||
margin-bottom: 0.75rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.key-meta {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.key-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.key-actions button {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.create-form {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.create-form input {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.create-form input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-500);
|
||||
box-shadow: 0 0 0 3px var(--primary-100);
|
||||
}
|
||||
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.modal-overlay.show {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 2rem;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--secondary-900);
|
||||
}
|
||||
|
||||
.modal-key {
|
||||
background: var(--secondary-50);
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
word-break: break-all;
|
||||
margin-bottom: 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-warning {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tip-box {
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.create-form {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.key-meta {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.key-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">API Key 管理</h1>
|
||||
<div class="header-actions">
|
||||
<a href="/api-docs" class="ui-btn ui-btn-secondary">API 文档</a>
|
||||
<a href="/auth/profile" class="ui-btn ui-btn-secondary">个人中心</a>
|
||||
<a href="/" class="ui-btn ui-btn-secondary">返回首页</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h3>创建新的 API Key</h3>
|
||||
<div class="tip-box">
|
||||
API Key 用于调用解析接口,请妥善保管。每个用户最多创建 5 个 Key。
|
||||
</div>
|
||||
<div class="create-form">
|
||||
<input type="text" id="keyName" placeholder="输入 Key 名称(如:我的应用)" maxlength="100">
|
||||
<button class="ui-btn ui-btn-primary" onclick="createKey()">创建 Key</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h3>我的 API Keys</h3>
|
||||
<div class="key-list" id="keyList">
|
||||
<div class="empty-state">加载中...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新建成功弹窗 -->
|
||||
<div class="modal-overlay" id="keyModal">
|
||||
<div class="modal-content">
|
||||
<h3 class="modal-title">API Key 创建成功</h3>
|
||||
<div class="modal-key" id="newKeyValue"></div>
|
||||
<div class="modal-warning">
|
||||
请立即复制并保存此 Key,关闭后将无法再次查看完整内容!
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="ui-btn ui-btn-secondary" onclick="copyNewKey()">复制 Key</button>
|
||||
<button class="ui-btn ui-btn-primary" onclick="closeModal()">我已保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/ui-components.js"></script>
|
||||
<script>
|
||||
let newApiKey = '';
|
||||
|
||||
async function loadKeys() {
|
||||
try {
|
||||
const response = await fetch('/user/apikey/list');
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
if (response.status === 401) {
|
||||
window.location.href = '/auth/login';
|
||||
return;
|
||||
}
|
||||
throw new Error(result.message || '加载失败');
|
||||
}
|
||||
|
||||
renderKeys(result.data);
|
||||
} catch (error) {
|
||||
UI.notify('加载失败: ' + error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function renderKeys(keys) {
|
||||
const container = document.getElementById('keyList');
|
||||
|
||||
if (keys.length === 0) {
|
||||
container.innerHTML = '<div class="empty-state">暂无 API Key,请创建一个</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = keys.map(key => `
|
||||
<div class="key-item">
|
||||
<div class="key-header">
|
||||
<span class="key-name">${escapeHtml(key.name)}</span>
|
||||
<span class="key-status ${key.is_active ? 'active' : 'inactive'}">
|
||||
${key.is_active ? '启用' : '禁用'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="key-value">
|
||||
<span>${key.api_key}</span>
|
||||
</div>
|
||||
<div class="key-meta">
|
||||
<span>每日限额: ${key.daily_limit} 次</span>
|
||||
<span>今日已用: ${key.today_calls} 次</span>
|
||||
<span>总调用: ${key.total_calls} 次</span>
|
||||
<span>创建时间: ${key.created_at}</span>
|
||||
</div>
|
||||
<div class="key-actions">
|
||||
<button class="ui-btn ui-btn-secondary" onclick="toggleKey(${key.id}, ${key.is_active})">
|
||||
${key.is_active ? '禁用' : '启用'}
|
||||
</button>
|
||||
<button class="ui-btn ui-btn-danger" onclick="deleteKey(${key.id}, '${escapeHtml(key.name)}')">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
async function createKey() {
|
||||
const nameInput = document.getElementById('keyName');
|
||||
const name = nameInput.value.trim();
|
||||
|
||||
if (!name) {
|
||||
UI.notify('请输入 Key 名称', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/user/apikey/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || '创建失败');
|
||||
}
|
||||
|
||||
// 显示新 Key
|
||||
newApiKey = result.data.api_key;
|
||||
document.getElementById('newKeyValue').textContent = newApiKey;
|
||||
document.getElementById('keyModal').classList.add('show');
|
||||
|
||||
nameInput.value = '';
|
||||
loadKeys();
|
||||
|
||||
} catch (error) {
|
||||
UI.notify(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleKey(keyId, currentStatus) {
|
||||
try {
|
||||
const response = await fetch(`/user/apikey/toggle/${keyId}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || '操作失败');
|
||||
}
|
||||
|
||||
UI.notify(result.message, 'success');
|
||||
loadKeys();
|
||||
|
||||
} catch (error) {
|
||||
UI.notify(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteKey(keyId, keyName) {
|
||||
if (!confirm(`确定要删除 "${keyName}" 吗?此操作不可恢复。`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/user/apikey/delete/${keyId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || '删除失败');
|
||||
}
|
||||
|
||||
UI.notify('删除成功', 'success');
|
||||
loadKeys();
|
||||
|
||||
} catch (error) {
|
||||
UI.notify(error.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function copyNewKey() {
|
||||
navigator.clipboard.writeText(newApiKey).then(() => {
|
||||
UI.notify('已复制到剪贴板', 'success');
|
||||
}).catch(() => {
|
||||
UI.notify('复制失败,请手动复制', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
document.getElementById('keyModal').classList.remove('show');
|
||||
newApiKey = '';
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
// 点击遮罩关闭
|
||||
document.getElementById('keyModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
loadKeys();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -201,6 +201,73 @@
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 输入框按钮组 */
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.input-group .url-input {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.input-btn {
|
||||
padding: 0 1.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border-radius: 12px;
|
||||
border: 2px solid var(--primary-200);
|
||||
background: white;
|
||||
color: var(--primary-600);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.input-btn:hover {
|
||||
background: var(--primary-50);
|
||||
border-color: var(--primary-400);
|
||||
}
|
||||
|
||||
.input-btn:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
/* 进度条 */
|
||||
.progress-container {
|
||||
display: none;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.progress-container.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--secondary-600);
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8px;
|
||||
background: var(--secondary-100);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #4f46e5 0%, #7c3aed 100%);
|
||||
border-radius: 4px;
|
||||
width: 0%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -217,22 +284,36 @@
|
||||
|
||||
<div class="main-card">
|
||||
<div class="input-wrapper">
|
||||
<input type="text" class="url-input" id="videoUrl" placeholder="在此粘贴 抖音/TikTok/B站 视频链接...">
|
||||
<div class="input-group">
|
||||
<input type="text" class="url-input" id="videoUrl" placeholder="在此粘贴 抖音/TikTok/B站/快手/皮皮虾/微博 视频链接...">
|
||||
<button class="input-btn" onclick="pasteUrl()">粘贴</button>
|
||||
<button class="input-btn" onclick="clearAll()">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="action-btn" onclick="parseVideo()" id="parseBtn">
|
||||
<span class="btn-text">立即解析</span>
|
||||
<div class="loading-spinner"></div>
|
||||
</button>
|
||||
|
||||
<div class="progress-container" id="progressContainer">
|
||||
<div class="progress-label">
|
||||
<span id="progressText">正在解析...</span>
|
||||
<span id="progressPercent">0%</span>
|
||||
</div>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" id="progressFill"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="result" class="result-card">
|
||||
<video id="videoPlayer" class="video-preview" controls></video>
|
||||
<div class="result-content">
|
||||
<h3 class="video-title" id="title"></h3>
|
||||
<p class="video-meta" id="description"></p>
|
||||
<div class="download-actions">
|
||||
<a id="videoLink" class="ui-btn ui-btn-primary" href="" download target="_blank">
|
||||
<button class="ui-btn ui-btn-primary" onclick="downloadVideo()">
|
||||
下载视频
|
||||
</a>
|
||||
</button>
|
||||
<button class="ui-btn ui-btn-primary" onclick="downloadCover()">
|
||||
下载封面
|
||||
</button>
|
||||
@@ -240,12 +321,14 @@
|
||||
复制链接
|
||||
</button>
|
||||
</div>
|
||||
<input type="hidden" id="videoLink" value="">
|
||||
<a id="coverLink" href="" style="display: none;"></a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="nav-links" id="navLinks">
|
||||
<a href="/api-docs" class="nav-link">API 文档</a>
|
||||
<a href="/auth/login" class="nav-link">登录账号</a>
|
||||
<a href="/auth/register" class="nav-link">注册新用户</a>
|
||||
</div>
|
||||
@@ -258,13 +341,103 @@
|
||||
|
||||
<script src="/static/js/ui-components.js"></script>
|
||||
<script>
|
||||
// 粘贴按钮
|
||||
async function pasteUrl() {
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
document.getElementById('videoUrl').value = text;
|
||||
UI.notify('已粘贴', 'success');
|
||||
} catch (e) {
|
||||
UI.notify('无法访问剪贴板,请手动粘贴', 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
// 清空按钮
|
||||
function clearAll() {
|
||||
document.getElementById('videoUrl').value = '';
|
||||
document.getElementById('result').classList.remove('show');
|
||||
document.getElementById('progressContainer').classList.remove('show');
|
||||
document.getElementById('videoPlayer').src = '';
|
||||
document.getElementById('videoPlayer').poster = '';
|
||||
UI.notify('已清空', 'info');
|
||||
}
|
||||
|
||||
// 进度条控制
|
||||
let progressInterval = null;
|
||||
|
||||
function startProgress() {
|
||||
const container = document.getElementById('progressContainer');
|
||||
const fill = document.getElementById('progressFill');
|
||||
const text = document.getElementById('progressText');
|
||||
const percent = document.getElementById('progressPercent');
|
||||
|
||||
container.classList.add('show');
|
||||
let progress = 0;
|
||||
const steps = [
|
||||
{ p: 15, t: '正在连接服务器...' },
|
||||
{ p: 35, t: '正在解析链接...' },
|
||||
{ p: 55, t: '正在获取视频信息...' },
|
||||
{ p: 75, t: '正在提取无水印地址...' },
|
||||
{ p: 90, t: '即将完成...' }
|
||||
];
|
||||
let stepIndex = 0;
|
||||
|
||||
progressInterval = setInterval(() => {
|
||||
if (stepIndex < steps.length && progress >= steps[stepIndex].p - 10) {
|
||||
text.textContent = steps[stepIndex].t;
|
||||
stepIndex++;
|
||||
}
|
||||
if (progress < 90) {
|
||||
progress += Math.random() * 8 + 2;
|
||||
if (progress > 90) progress = 90;
|
||||
fill.style.width = progress + '%';
|
||||
percent.textContent = Math.round(progress) + '%';
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function completeProgress() {
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval);
|
||||
progressInterval = null;
|
||||
}
|
||||
const fill = document.getElementById('progressFill');
|
||||
const text = document.getElementById('progressText');
|
||||
const percent = document.getElementById('progressPercent');
|
||||
|
||||
fill.style.width = '100%';
|
||||
percent.textContent = '100%';
|
||||
text.textContent = '解析完成!';
|
||||
|
||||
setTimeout(() => {
|
||||
document.getElementById('progressContainer').classList.remove('show');
|
||||
fill.style.width = '0%';
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function resetProgress() {
|
||||
if (progressInterval) {
|
||||
clearInterval(progressInterval);
|
||||
progressInterval = null;
|
||||
}
|
||||
const container = document.getElementById('progressContainer');
|
||||
const fill = document.getElementById('progressFill');
|
||||
container.classList.remove('show');
|
||||
fill.style.width = '0%';
|
||||
}
|
||||
|
||||
function extractVideoUrl(text) {
|
||||
const patterns = [
|
||||
/https?:\/\/v\.douyin\.com\/[A-Za-z0-9_-]+/,
|
||||
/https?:\/\/www\.douyin\.com\/video\/\d+/,
|
||||
/https?:\/\/(?:www\.)?bilibili\.com\/video\/[A-Za-z0-9?&=]+/,
|
||||
/https?:\/\/b23\.tv\/[A-Za-z0-9]+/,
|
||||
/https?:\/\/(?:www\.)?tiktok\.com\/@[^\/]+\/video\/\d+/
|
||||
/https?:\/\/(?:www\.)?tiktok\.com\/@[^\/]+\/video\/\d+/,
|
||||
/https?:\/\/(?:www\.)?kuaishou\.com\/[A-Za-z0-9\/_-]+/,
|
||||
/https?:\/\/h5\.pipix\.com\/s\/[A-Za-z0-9_-]+\/?/,
|
||||
/https?:\/\/(?:www\.)?pipix\.com\/[A-Za-z0-9\/_-]+/,
|
||||
/https?:\/\/(?:video\.)?weibo\.com\/show\?fid=[0-9:]+/,
|
||||
/https?:\/\/(?:www\.)?weibo\.com\/[A-Za-z0-9\/]+/
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
@@ -295,6 +468,7 @@
|
||||
resultCard.classList.remove('show');
|
||||
btn.disabled = true;
|
||||
btn.classList.add('loading');
|
||||
startProgress();
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/parse', {
|
||||
@@ -318,6 +492,7 @@
|
||||
UI.notify(error.message, 'error');
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
resetProgress();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -331,6 +506,7 @@
|
||||
UI.notify('解析超时,请稍后重试', 'error');
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
resetProgress();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -350,6 +526,7 @@
|
||||
UI.notify(error.message, 'error');
|
||||
btn.disabled = false;
|
||||
btn.classList.remove('loading');
|
||||
resetProgress();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -360,14 +537,31 @@
|
||||
const btn = document.getElementById('parseBtn');
|
||||
const resultCard = document.getElementById('result');
|
||||
|
||||
completeProgress();
|
||||
|
||||
document.getElementById('title').textContent = data.title || '无标题';
|
||||
document.getElementById('description').textContent = data.description || '';
|
||||
|
||||
const player = document.getElementById('videoPlayer');
|
||||
player.src = data.video_url;
|
||||
player.poster = data.cover;
|
||||
// 先停止并清空当前视频
|
||||
player.pause();
|
||||
player.removeAttribute('src');
|
||||
player.load();
|
||||
|
||||
document.getElementById('videoLink').href = data.video_url;
|
||||
// 使用代理播放视频,绕过防盗链
|
||||
const proxyVideoUrl = '/proxy/download?url=' + encodeURIComponent(data.video_url);
|
||||
player.src = proxyVideoUrl;
|
||||
|
||||
// 封面也使用代理
|
||||
if (data.cover) {
|
||||
const proxyCoverUrl = '/proxy/download?url=' + encodeURIComponent(data.cover);
|
||||
player.poster = proxyCoverUrl;
|
||||
}
|
||||
|
||||
// 强制重新加载视频
|
||||
player.load();
|
||||
|
||||
document.getElementById('videoLink').value = data.video_url;
|
||||
document.getElementById('coverLink').href = data.cover;
|
||||
|
||||
resultCard.classList.add('show');
|
||||
@@ -377,6 +571,18 @@
|
||||
UI.notify('解析成功', 'success');
|
||||
}
|
||||
|
||||
function downloadVideo() {
|
||||
const videoUrl = document.getElementById('videoLink').value;
|
||||
if (!videoUrl) {
|
||||
UI.notify('视频链接无效', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用代理下载
|
||||
const proxyUrl = '/proxy/download?url=' + encodeURIComponent(videoUrl);
|
||||
window.open(proxyUrl, '_blank');
|
||||
}
|
||||
|
||||
async function downloadCover() {
|
||||
const coverUrl = document.getElementById('coverLink').href;
|
||||
if (!coverUrl) {
|
||||
@@ -434,6 +640,7 @@
|
||||
const data = await response.json();
|
||||
if (data.success) {
|
||||
document.getElementById('navLinks').innerHTML = `
|
||||
<a href="/api-docs" class="nav-link">API 文档</a>
|
||||
<a href="/auth/profile" class="nav-link">个人中心</a>
|
||||
<a href="#" class="nav-link" onclick="logout(); return false;">退出登录</a>
|
||||
`;
|
||||
|
||||
@@ -251,6 +251,132 @@
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* API Key 管理样式 */
|
||||
.api-key-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--secondary-100);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.api-key-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.api-key-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.api-key-name {
|
||||
font-weight: 500;
|
||||
color: var(--secondary-800);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.api-key-meta {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.api-key-status {
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.api-key-status.active {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.api-key-status.inactive {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.api-key-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* 弹窗样式 */
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--secondary-700);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
|
||||
@@ -299,6 +425,10 @@
|
||||
<span class="info-label">用户组</span>
|
||||
<span class="info-value" id="groupName">-</span>
|
||||
</div>
|
||||
<div class="info-row" id="expiryRow" style="display: none;">
|
||||
<span class="info-label">套餐到期</span>
|
||||
<span class="info-value" id="expiryTime">-</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">注册时间</span>
|
||||
<span class="info-value" id="createdAt">-</span>
|
||||
@@ -312,6 +442,74 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 兑换码 -->
|
||||
<div class="info-card">
|
||||
<h3>兑换码</h3>
|
||||
<p style="color: var(--text-muted); font-size: 0.875rem; margin-bottom: 1rem;">
|
||||
输入兑换码升级您的账户套餐
|
||||
</p>
|
||||
<div style="display: flex; gap: 0.75rem;">
|
||||
<input type="text" id="redeemCode" class="ui-input" placeholder="请输入兑换码" style="flex: 1; text-transform: uppercase;">
|
||||
<button class="ui-btn ui-btn-primary" onclick="redeemCode()">兑换</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Key 管理 -->
|
||||
<div class="info-card">
|
||||
<h3 style="display: flex; justify-content: space-between; align-items: center;">
|
||||
API Key 管理
|
||||
<button class="ui-btn ui-btn-primary ui-btn-sm" onclick="showCreateKeyModal()">创建 Key</button>
|
||||
</h3>
|
||||
<p style="color: var(--text-muted); font-size: 0.875rem; margin-bottom: 1rem;">
|
||||
通过 API Key 可以在您的应用中调用视频解析接口(最多5个)
|
||||
</p>
|
||||
<div id="apiKeyList">
|
||||
<p style="color: var(--text-muted); text-align: center;">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建 API Key 弹窗 -->
|
||||
<div class="modal" id="createKeyModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>创建 API Key</h3>
|
||||
<button class="modal-close" onclick="closeModal('createKeyModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Key 名称</label>
|
||||
<input type="text" id="keyName" class="ui-input" placeholder="如:我的应用">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="ui-btn ui-btn-secondary" onclick="closeModal('createKeyModal')">取消</button>
|
||||
<button class="ui-btn ui-btn-primary" onclick="createApiKey()">创建</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 显示新 Key 弹窗 -->
|
||||
<div class="modal" id="newKeyModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>API Key 创建成功</h3>
|
||||
<button class="modal-close" onclick="closeModal('newKeyModal')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p style="color: #dc2626; font-size: 0.875rem; margin-bottom: 1rem;">
|
||||
请立即复制保存,此 Key 只显示一次!
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label>API Key</label>
|
||||
<input type="text" id="newKeyValue" class="ui-input" readonly style="font-family: monospace;">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="ui-btn ui-btn-primary" onclick="copyNewKey()">复制 Key</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 解析记录 -->
|
||||
<div class="logs-card">
|
||||
<div class="logs-header">
|
||||
@@ -368,6 +566,15 @@
|
||||
const groupBadge = getGroupBadge(data.group.name);
|
||||
document.getElementById('groupName').innerHTML = groupBadge;
|
||||
|
||||
// 显示套餐到期时间(在账户信息卡片中)
|
||||
if (data.group.expires_at) {
|
||||
document.getElementById('expiryRow').style.display = 'flex';
|
||||
const expiryText = data.group.is_expired
|
||||
? `<span style="color: #dc2626;">${data.group.expires_at}(已过期)</span>`
|
||||
: data.group.expires_at;
|
||||
document.getElementById('expiryTime').innerHTML = expiryText;
|
||||
}
|
||||
|
||||
// 使用进度
|
||||
const usagePercent = data.usage.daily_limit > 0
|
||||
? Math.round((data.usage.today_used / data.usage.daily_limit) * 100)
|
||||
@@ -440,7 +647,167 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function redeemCode() {
|
||||
const code = document.getElementById('redeemCode').value.trim();
|
||||
if (!code) {
|
||||
UI.notify('请输入兑换码', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/auth/api/redeem', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: code })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
UI.notify(result.message, 'success');
|
||||
document.getElementById('redeemCode').value = '';
|
||||
loadProfile();
|
||||
} else {
|
||||
UI.notify(result.message, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
UI.notify('兑换失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('redeemCode').addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter') redeemCode();
|
||||
});
|
||||
|
||||
// ==================== API Key 管理 ====================
|
||||
async function loadApiKeys() {
|
||||
try {
|
||||
const response = await fetch('/user/apikey/list');
|
||||
const result = await response.json();
|
||||
|
||||
const container = document.getElementById('apiKeyList');
|
||||
|
||||
if (!result.success) {
|
||||
container.innerHTML = '<p style="color: var(--text-muted); text-align: center;">加载失败</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.data.length === 0) {
|
||||
container.innerHTML = '<p style="color: var(--text-muted); text-align: center;">暂无 API Key</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = result.data.map(key => `
|
||||
<div class="api-key-item">
|
||||
<div class="api-key-info">
|
||||
<div class="api-key-name">${key.name || '未命名'}</div>
|
||||
<div class="api-key-meta">
|
||||
<span>Key: ${key.api_key}</span>
|
||||
<span>调用: ${key.total_calls || 0}次</span>
|
||||
<span class="api-key-status ${key.is_active ? 'active' : 'inactive'}">${key.is_active ? '启用' : '禁用'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="api-key-actions">
|
||||
<button class="ui-btn ui-btn-sm ${key.is_active ? 'ui-btn-secondary' : 'ui-btn-primary'}" onclick="toggleApiKey(${key.id})">
|
||||
${key.is_active ? '禁用' : '启用'}
|
||||
</button>
|
||||
<button class="ui-btn ui-btn-sm ui-btn-danger" onclick="deleteApiKey(${key.id})">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
document.getElementById('apiKeyList').innerHTML = '<p style="color: var(--text-muted); text-align: center;">加载失败</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function showCreateKeyModal() {
|
||||
document.getElementById('keyName').value = '';
|
||||
document.getElementById('createKeyModal').classList.add('show');
|
||||
}
|
||||
|
||||
function closeModal(modalId) {
|
||||
document.getElementById(modalId).classList.remove('show');
|
||||
}
|
||||
|
||||
async function createApiKey() {
|
||||
const name = document.getElementById('keyName').value.trim();
|
||||
|
||||
if (!name) {
|
||||
UI.notify('请输入 Key 名称', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/user/apikey/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: name })
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
closeModal('createKeyModal');
|
||||
document.getElementById('newKeyValue').value = result.data.api_key;
|
||||
document.getElementById('newKeyModal').classList.add('show');
|
||||
loadApiKeys();
|
||||
} else {
|
||||
UI.notify(result.message, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
UI.notify('创建失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function copyNewKey() {
|
||||
const input = document.getElementById('newKeyValue');
|
||||
input.select();
|
||||
document.execCommand('copy');
|
||||
UI.notify('已复制到剪贴板', 'success');
|
||||
}
|
||||
|
||||
async function toggleApiKey(keyId) {
|
||||
try {
|
||||
const response = await fetch(`/user/apikey/toggle/${keyId}`, {
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
UI.notify(result.message, 'success');
|
||||
loadApiKeys();
|
||||
} else {
|
||||
UI.notify(result.message, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
UI.notify('操作失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteApiKey(keyId) {
|
||||
if (!confirm('确定要删除这个 API Key 吗?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/user/apikey/delete/${keyId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (result.success) {
|
||||
UI.notify('删除成功', 'success');
|
||||
loadApiKeys();
|
||||
} else {
|
||||
UI.notify(result.message, 'error');
|
||||
}
|
||||
} catch (error) {
|
||||
UI.notify('删除失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
loadProfile();
|
||||
loadApiKeys();
|
||||
</script>
|
||||
</body>
|
||||
|
||||
|
||||
104
utils/api_auth.py
Normal file
104
utils/api_auth.py
Normal file
@@ -0,0 +1,104 @@
|
||||
from functools import wraps
|
||||
from flask import request, jsonify
|
||||
from models import db, UserApiKey, ApiKeyDailyStat
|
||||
from datetime import datetime, date
|
||||
|
||||
|
||||
def get_api_key_from_request():
|
||||
"""从请求中获取 API Key"""
|
||||
# 优先从 Header 获取
|
||||
api_key = request.headers.get('X-API-Key')
|
||||
if api_key:
|
||||
return api_key
|
||||
|
||||
# 其次从 Authorization Bearer 获取
|
||||
auth_header = request.headers.get('Authorization')
|
||||
if auth_header and auth_header.startswith('Bearer '):
|
||||
return auth_header[7:]
|
||||
|
||||
# 最后从查询参数获取
|
||||
return request.args.get('api_key')
|
||||
|
||||
|
||||
def validate_api_key(api_key):
|
||||
"""
|
||||
验证 API Key
|
||||
返回: (is_valid, key_obj_or_error_msg)
|
||||
"""
|
||||
if not api_key:
|
||||
return False, 'API Key 不能为空'
|
||||
|
||||
key_obj = UserApiKey.query.filter_by(api_key=api_key).first()
|
||||
|
||||
if not key_obj:
|
||||
return False, 'API Key 无效'
|
||||
|
||||
if not key_obj.is_active:
|
||||
return False, 'API Key 已被禁用'
|
||||
|
||||
if not key_obj.user.is_active:
|
||||
return False, '用户账号已被禁用'
|
||||
|
||||
# 检查每日限额
|
||||
today_stat = ApiKeyDailyStat.query.filter_by(
|
||||
api_key_id=key_obj.id,
|
||||
date=date.today()
|
||||
).first()
|
||||
|
||||
if today_stat and today_stat.call_count >= key_obj.daily_limit:
|
||||
return False, f'已达到每日调用限额({key_obj.daily_limit}次)'
|
||||
|
||||
return True, key_obj
|
||||
|
||||
|
||||
def record_api_call(key_obj, ip_address, success=True):
|
||||
"""记录 API 调用"""
|
||||
# 更新 Key 统计
|
||||
key_obj.total_calls += 1
|
||||
key_obj.last_used_at = datetime.utcnow()
|
||||
key_obj.last_used_ip = ip_address
|
||||
|
||||
# 更新每日统计
|
||||
today_stat = ApiKeyDailyStat.query.filter_by(
|
||||
api_key_id=key_obj.id,
|
||||
date=date.today()
|
||||
).first()
|
||||
|
||||
if not today_stat:
|
||||
today_stat = ApiKeyDailyStat(
|
||||
api_key_id=key_obj.id,
|
||||
date=date.today()
|
||||
)
|
||||
db.session.add(today_stat)
|
||||
|
||||
today_stat.call_count += 1
|
||||
if success:
|
||||
today_stat.success_count += 1
|
||||
else:
|
||||
today_stat.fail_count += 1
|
||||
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def api_key_required(f):
|
||||
"""API Key 鉴权装饰器"""
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
api_key = get_api_key_from_request()
|
||||
|
||||
is_valid, result = validate_api_key(api_key)
|
||||
|
||||
if not is_valid:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'error': {
|
||||
'code': 'UNAUTHORIZED',
|
||||
'message': result
|
||||
}
|
||||
}), 401
|
||||
|
||||
# 将 key 对象传递给视图函数
|
||||
request.api_key_obj = result
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated_function
|
||||
99
开发记录/2025-11-30-会话记录.md
Normal file
99
开发记录/2025-11-30-会话记录.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# 开发会话记录 - 2025-11-30
|
||||
|
||||
## 本次会话完成的功能
|
||||
|
||||
### 1. API Key 内联管理(个人中心)
|
||||
- 将 API Key 管理功能直接嵌入到个人中心页面
|
||||
- 支持创建、查看、启用/禁用、删除 API Key
|
||||
- 新创建的 Key 只显示一次,提示用户立即复制
|
||||
- 文件修改:`templates/profile.html`
|
||||
|
||||
### 2. 用户套餐到期时间管理
|
||||
- 管理后台编辑用户时可设置套餐到期时间
|
||||
- VIP/SVIP 用户显示到期时间输入框
|
||||
- 游客和普通用户不显示(永久有效)
|
||||
- 用户列表显示套餐到期状态
|
||||
- 文件修改:
|
||||
- `templates/admin_users.html`
|
||||
- `routes/admin.py`(get_users、update_user)
|
||||
|
||||
### 3. 自定义下拉框组件
|
||||
- 创建美观的自定义下拉框替代浏览器原生 select
|
||||
- 自动初始化所有 `.ui-input` 和 `.ui-select` 下拉框
|
||||
- 支持键盘操作(Enter/Space 打开,Escape 关闭)
|
||||
- 点击外部自动关闭
|
||||
- 动态选项更新支持
|
||||
- 文件修改:
|
||||
- `static/css/ui-components.css`
|
||||
- `static/js/ui-components.js`
|
||||
|
||||
### 4. 平台选项更新
|
||||
- 管理后台所有平台下拉框添加新平台选项
|
||||
- 新增:快手(kuaishou)、皮皮虾(pipixia)、微博(weibo)
|
||||
- 文件修改:
|
||||
- `templates/admin_apis.html`
|
||||
- `templates/admin_logs.html`
|
||||
- `templates/admin_health_checks.html`
|
||||
|
||||
---
|
||||
|
||||
## 之前会话完成的功能(摘要)
|
||||
|
||||
### 兑换码系统
|
||||
- 管理员批量生成兑换码
|
||||
- 用户在个人中心兑换升级套餐
|
||||
- 支持套餐时间叠加
|
||||
- 文件:`models/__init__.py`、`routes/admin.py`、`routes/auth.py`、`templates/admin_redeem_codes.html`
|
||||
|
||||
### 视频代理下载
|
||||
- 解决防盗链问题(Referer)
|
||||
- 支持多平台域名白名单
|
||||
- 文件:`routes/main.py`
|
||||
|
||||
### B站短链接支持
|
||||
- 自动展开 b23.tv 短链接
|
||||
- 文件:`parsers/factory.py`
|
||||
|
||||
### 前端优化
|
||||
- 粘贴按钮、清空按钮
|
||||
- 解析进度条
|
||||
- 视频播放器缓存修复
|
||||
- 文件:`templates/index.html`
|
||||
|
||||
---
|
||||
|
||||
## 项目当前支持的平台
|
||||
1. 抖音 (douyin)
|
||||
2. TikTok (tiktok)
|
||||
3. 哔哩哔哩 (bilibili)
|
||||
4. 快手 (kuaishou)
|
||||
5. 皮皮虾 (pipixia)
|
||||
6. 微博 (weibo)
|
||||
|
||||
---
|
||||
|
||||
## 关键文件清单
|
||||
|
||||
### 后端路由
|
||||
- `routes/auth.py` - 用户认证、个人中心、兑换码
|
||||
- `routes/admin.py` - 管理后台 API
|
||||
- `routes/parser.py` - 视频解析 API
|
||||
- `routes/api_v1.py` - 外部 API(API Key 认证)
|
||||
- `routes/apikey.py` - API Key 管理
|
||||
- `routes/main.py` - 主页、代理下载
|
||||
|
||||
### 前端模板
|
||||
- `templates/index.html` - 首页
|
||||
- `templates/profile.html` - 个人中心
|
||||
- `templates/admin_*.html` - 管理后台页面
|
||||
|
||||
### 静态资源
|
||||
- `static/css/ui-components.css` - UI 组件样式
|
||||
- `static/js/ui-components.js` - UI 组件脚本
|
||||
|
||||
### 数据模型
|
||||
- `models/__init__.py` - 所有数据库模型
|
||||
|
||||
### 解析器
|
||||
- `parsers/factory.py` - 解析器工厂
|
||||
- `parsers/base.py` - 基础解析器类
|
||||
71
解析接口/微博解析.postman_collection.json
Normal file
71
解析接口/微博解析.postman_collection.json
Normal file
@@ -0,0 +1,71 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "38e90715-747a-4f04-89b2-194e902cdbdf",
|
||||
"name": "微博解析",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||
"_exporter_id": "49242368",
|
||||
"_collection_link": "https://lixin0229-2646365.postman.co/workspace/shihao's-Workspace~249f47cc-12a0-4152-8c64-d21cf5552a6c/collection/49242368-38e90715-747a-4f04-89b2-194e902cdbdf?action=share&source=collection_link&creator=49242368"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "优创微博API",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "http://apis.uctb.cn/api/videojx?url=https://video.weibo.com/show?fid=1034:5233299304415254",
|
||||
"protocol": "http",
|
||||
"host": [
|
||||
"apis",
|
||||
"uctb",
|
||||
"cn"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"videojx"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "url",
|
||||
"value": "https://video.weibo.com/show?fid=1034:5233299304415254"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "妖狐微博API",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "https://api.yaohud.cn/api/v6/video/weibo?key=SM227DLC0ZgJ6DXJhAx&url=https://video.weibo.com/show?fid=1034:5233299304415254",
|
||||
"protocol": "https",
|
||||
"host": [
|
||||
"api",
|
||||
"yaohud",
|
||||
"cn"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"v6",
|
||||
"video",
|
||||
"weibo"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "key",
|
||||
"value": "SM227DLC0ZgJ6DXJhAx"
|
||||
},
|
||||
{
|
||||
"key": "url",
|
||||
"value": "https://video.weibo.com/show?fid=1034:5233299304415254"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
97
解析接口/快手解析.postman_collection.json
Normal file
97
解析接口/快手解析.postman_collection.json
Normal file
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "f31a94d4-4545-4d7f-a5d8-1f9058a5e2f8",
|
||||
"name": "快手解析",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||
"_exporter_id": "49242368",
|
||||
"_collection_link": "https://lixin0229-2646365.postman.co/workspace/shihao's-Workspace~249f47cc-12a0-4152-8c64-d21cf5552a6c/collection/49242368-f31a94d4-4545-4d7f-a5d8-1f9058a5e2f8?action=share&source=collection_link&creator=49242368"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "BugPk快手",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "https://api.bugpk.com/api/kuaishou?url=https://www.kuaishou.com/f/X-1ZPlLXEI6SP1mx",
|
||||
"protocol": "https",
|
||||
"host": [
|
||||
"api",
|
||||
"bugpk",
|
||||
"com"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"kuaishou"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "url",
|
||||
"value": "https://www.kuaishou.com/f/X-1ZPlLXEI6SP1mx"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "Nice猫快手API",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "https://api.nsmao.net/api/kuaishou/query?key=UhjPIwpNdljUSdRUGCTxsuDEMm&url=https://www.kuaishou.com/f/X-1ZPlLXEI6SP1mx",
|
||||
"protocol": "https",
|
||||
"host": [
|
||||
"api",
|
||||
"nsmao",
|
||||
"net"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"kuaishou",
|
||||
"query"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "key",
|
||||
"value": "UhjPIwpNdljUSdRUGCTxsuDEMm"
|
||||
},
|
||||
{
|
||||
"key": "url",
|
||||
"value": "https://www.kuaishou.com/f/X-1ZPlLXEI6SP1mx"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "优创快手API",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "http://apis.uctb.cn/api/videojx?url=https://www.kuaishou.com/f/X-1ZPlLXEI6SP1mx",
|
||||
"protocol": "http",
|
||||
"host": [
|
||||
"apis",
|
||||
"uctb",
|
||||
"cn"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"videojx"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "url",
|
||||
"value": "https://www.kuaishou.com/f/X-1ZPlLXEI6SP1mx"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
65
解析接口/皮皮虾解析.postman_collection.json
Normal file
65
解析接口/皮皮虾解析.postman_collection.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"info": {
|
||||
"_postman_id": "31f6f547-0cb0-4610-b2b6-130ec8e75811",
|
||||
"name": "皮皮虾解析",
|
||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
|
||||
"_exporter_id": "49242368",
|
||||
"_collection_link": "https://lixin0229-2646365.postman.co/workspace/shihao's-Workspace~249f47cc-12a0-4152-8c64-d21cf5552a6c/collection/49242368-31f6f547-0cb0-4610-b2b6-130ec8e75811?action=share&source=collection_link&creator=49242368"
|
||||
},
|
||||
"item": [
|
||||
{
|
||||
"name": "BugPK皮皮虾API",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "https://api.bugpk.com/api/pipixia?url=https://h5.pipix.com/s/kLUS2a4iJ_M/",
|
||||
"protocol": "https",
|
||||
"host": [
|
||||
"api",
|
||||
"bugpk",
|
||||
"com"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"pipixia"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "url",
|
||||
"value": "https://h5.pipix.com/s/kLUS2a4iJ_M/"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
},
|
||||
{
|
||||
"name": "优创皮皮虾API",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"header": [],
|
||||
"url": {
|
||||
"raw": "http://apis.uctb.cn/api/videojx?url=https://h5.pipix.com/s/wrDsplDprWs/",
|
||||
"protocol": "http",
|
||||
"host": [
|
||||
"apis",
|
||||
"uctb",
|
||||
"cn"
|
||||
],
|
||||
"path": [
|
||||
"api",
|
||||
"videojx"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "url",
|
||||
"value": "https://h5.pipix.com/s/wrDsplDprWs/"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"response": []
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user