Merge commit '91761b6754b2ca55b5f1a5317c1fc603a76794aa'

This commit is contained in:
xbh 2025-11-13 22:29:51 +08:00
commit ba85036a42
35 changed files with 8204 additions and 1398 deletions

2
.gitignore vendored
View File

@ -67,4 +67,6 @@ yarn-error.log*
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Figma 设计文件目录(无需纳入版本控制)
.figma/ .figma/

View File

@ -66,6 +66,8 @@ def setup_logging(quiet_mode=False):
class DouyinAutoScheduler: class DouyinAutoScheduler:
def __init__(self): def __init__(self):
self.is_running = False self.is_running = False
# 创建logger实例
self.logger = logging.getLogger(__name__)
def _normalize_play_vv(self, play_vv): def _normalize_play_vv(self, play_vv):
"""标准化播放量数据类型,将字符串转换为数字""" """标准化播放量数据类型,将字符串转换为数字"""
@ -78,29 +80,68 @@ class DouyinAutoScheduler:
return 0 return 0
return play_vv return play_vv
def _deduplicate_videos_by_mix_name(self, videos, include_rank=False): def check_browser_login_status(self):
"""按短剧名称去重,保留播放量最高的记录""" """检查浏览器登录状态,如果没有登录则提示用户登录"""
unique_data = {} try:
for video in videos: import os
mix_name = video.get("mix_name", "") script_dir = os.path.dirname(os.path.abspath(__file__))
if mix_name: profile_dir = os.path.join(script_dir, 'config', 'chrome_profile_timer', 'douyin_persistent')
# 标准化播放量数据类型
play_vv = self._normalize_play_vv(video.get("play_vv", 0))
if mix_name not in unique_data or play_vv > unique_data[mix_name].get("play_vv", 0):
if include_rank: # 检查配置文件是否为空(可能未登录)
# 用于昨天数据的格式 import glob
unique_data[mix_name] = { profile_files = glob.glob(os.path.join(profile_dir, "*"))
"play_vv": play_vv, if len(profile_files) < 5: # 如果文件太少,可能未登录
"video_id": str(video.get("_id", "")), print("⚠️ 检测到定时器浏览器可能未登录")
"rank": 0 # 稍后计算排名 print(" 请在浏览器中完成抖音登录,并导航到【我的】→【收藏】→【合集】页面")
} print(" 完成后按回车键继续...")
input()
else: else:
# 用于今天数据的格式,直接更新原视频对象 print("✅ 定时器浏览器已配置,继续执行...")
video["play_vv"] = play_vv
unique_data[mix_name] = video
return unique_data except Exception as e:
logging.warning(f"检查浏览器登录状态时出错: {e}")
print("⚠️ 检查浏览器状态失败,请确保浏览器已正确配置")
print(" 完成后按回车键继续...")
input()
def _cleanup_chrome_processes(self):
"""清理可能占用配置文件的Chrome进程"""
try:
import psutil
import os
# 获取当前配置文件路径
script_dir = os.path.dirname(os.path.abspath(__file__))
profile_dir = os.path.join(script_dir, 'config', 'chrome_profile_timer', 'douyin_persistent')
# 查找使用该配置文件的Chrome进程
killed_processes = []
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
try:
if proc.info['name'] and 'chrome' in proc.info['name'].lower():
cmdline = proc.info['cmdline']
if cmdline and any(profile_dir in arg for arg in cmdline):
proc.terminate()
killed_processes.append(proc.info['pid'])
logging.info(f'终止占用配置文件的Chrome进程: PID {proc.info["pid"]}')
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
continue
# 等待进程终止
if killed_processes:
import time
time.sleep(2)
return len(killed_processes) > 0
except ImportError:
# 如果没有psutil跳过清理以避免影响其他脚本实例
logging.warning('psutil 不可用,跳过进程清理(避免全局终止 Chrome')
return False
except Exception as e:
logging.warning(f'清理Chrome进程时出错: {e}')
return False
def run_douyin_scraper(self): def run_douyin_scraper(self):
"""执行抖音播放量抓取任务""" """执行抖音播放量抓取任务"""
@ -114,14 +155,14 @@ class DouyinAutoScheduler:
scraper = DouyinPlayVVScraper( scraper = DouyinPlayVVScraper(
start_url="https://www.douyin.com/user/self?showTab=favorite_collection&showSubTab=compilation", start_url="https://www.douyin.com/user/self?showTab=favorite_collection&showSubTab=compilation",
auto_continue=True, auto_continue=True,
duration_s=60 duration_s=60 # 增加到60秒给更多时间收集数据
) )
print("📁 开始执行抓取任务...") print("开始执行抓取任务...")
logging.info("📁 开始执行抓取任务...") logging.info("📁 开始执行抓取任务...")
scraper.run() scraper.run()
print("抖音播放量抓取任务执行成功") print("抖音播放量抓取任务执行成功")
logging.info("✅ 抖音播放量抓取任务执行成功") logging.info("✅ 抖音播放量抓取任务执行成功")
# 数据抓取完成后,自动生成当日榜单 # 数据抓取完成后,自动生成当日榜单
@ -180,12 +221,24 @@ class DouyinAutoScheduler:
today_videos_raw = list(douyin_collection.find({"batch_time": latest_batch_time}).sort("play_vv", -1)) today_videos_raw = list(douyin_collection.find({"batch_time": latest_batch_time}).sort("play_vv", -1))
logging.info(f"📊 最新批次数据数量: {len(today_videos_raw)}") logging.info(f"📊 最新批次数据数量: {len(today_videos_raw)}")
# 按短剧名称去重,每个短剧只保留播放量最高的一条 # 按短剧ID去重每个短剧只保留播放量最高的一条
# 🚫 过滤掉空的或无效的mix_id和播放量为0的记录
unique_videos = {} unique_videos = {}
for video in today_videos_raw: for video in today_videos_raw:
mix_name = video.get("mix_name", "") mix_id = video.get("mix_id", "").strip()
if mix_name and (mix_name not in unique_videos or video.get("play_vv", 0) > unique_videos[mix_name].get("play_vv", 0)): mix_name = video.get("mix_name", "").strip()
unique_videos[mix_name] = video play_vv = video.get("play_vv", 0)
# 过滤掉空的或无效的mix_id
if not mix_id or mix_id == "" or mix_id.lower() == "null":
continue
# 注意播放量为0的数据也会被保留可能是新发布的短剧
if play_vv <= 0:
logging.warning(f"⚠️ 发现播放量为0的数据: mix_name={mix_name}, play_vv={play_vv},仍会保留")
if mix_id not in unique_videos or play_vv > unique_videos[mix_id].get("play_vv", 0):
unique_videos[mix_id] = video
today_videos = list(unique_videos.values()) today_videos = list(unique_videos.values())
@ -213,16 +266,28 @@ class DouyinAutoScheduler:
"batch_time": yesterday_batch_time "batch_time": yesterday_batch_time
}).sort("play_vv", -1)) }).sort("play_vv", -1))
# 按短剧名称去重,每个短剧只保留播放量最高的一条 # 按短剧ID去重每个短剧只保留播放量最高的一条
# 🚫 过滤掉空的或无效的mix_id
unique_yesterday_videos = {} unique_yesterday_videos = {}
for video in yesterday_videos_raw: for video in yesterday_videos_raw:
mix_name = video.get("mix_name", "") mix_id = video.get("mix_id", "").strip()
if mix_name and (mix_name not in unique_yesterday_videos or video.get("play_vv", 0) > unique_yesterday_videos[mix_name].get("play_vv", 0)): mix_name = video.get("mix_name", "").strip()
unique_yesterday_videos[mix_name] = video play_vv = video.get("play_vv", 0)
# 将昨天的数据转换为字典,以短剧名称为键 # 过滤掉空的或无效的mix_id
for mix_name, video in unique_yesterday_videos.items(): if not mix_id or mix_id == "" or mix_id.lower() == "null":
yesterday_data[mix_name] = { continue
# 注意播放量为0的数据也会被保留可能是新发布的短剧
if play_vv <= 0:
logging.warning(f"⚠️ 昨天数据中发现播放量为0: mix_name={mix_name}, play_vv={play_vv},仍会保留")
if mix_id not in unique_yesterday_videos or play_vv > unique_yesterday_videos[mix_id].get("play_vv", 0):
unique_yesterday_videos[mix_id] = video
# 将昨天的数据转换为字典以短剧ID为键
for mix_id, video in unique_yesterday_videos.items():
yesterday_data[mix_id] = {
"rank": 0, # 原始数据没有排名设为0 "rank": 0, # 原始数据没有排名设为0
"play_vv": video.get("play_vv", 0), "play_vv": video.get("play_vv", 0),
"video_id": str(video.get("_id", "")) "video_id": str(video.get("_id", ""))
@ -244,10 +309,10 @@ class DouyinAutoScheduler:
play_vv_change_rate = 0 play_vv_change_rate = 0
is_new = True is_new = True
mix_name = video.get("mix_name", "") mix_id = video.get("mix_id", "")
if mix_name in yesterday_data: if mix_id in yesterday_data:
is_new = False is_new = False
yesterday_play_vv = yesterday_data[mix_name]["play_vv"] yesterday_play_vv = yesterday_data[mix_id]["play_vv"]
# 计算播放量变化 # 计算播放量变化
play_vv_change = current_play_vv - yesterday_play_vv play_vv_change = current_play_vv - yesterday_play_vv
@ -260,7 +325,7 @@ class DouyinAutoScheduler:
"play_vv_change": play_vv_change, "play_vv_change": play_vv_change,
"play_vv_change_rate": play_vv_change_rate, "play_vv_change_rate": play_vv_change_rate,
"is_new": is_new, "is_new": is_new,
"yesterday_data": yesterday_data.get(mix_name, {}) "yesterday_data": yesterday_data.get(mix_id, {})
} }
videos_with_growth.append(video_with_growth) videos_with_growth.append(video_with_growth)
@ -277,29 +342,92 @@ class DouyinAutoScheduler:
"data": [] "data": []
} }
# 获取Rankings_management集合用于补充详细信息
rankings_management_collection = db['Rankings_management']
# 生成排序后的榜单数据 # 生成排序后的榜单数据
for i, item in enumerate(videos_with_growth, 1): rank = 1 # 使用独立的排名计数器
for item in videos_with_growth:
video = item["video"] video = item["video"]
video_id = str(video.get("_id", "")) video_id = str(video.get("_id", ""))
current_play_vv = video.get("play_vv", 0) current_play_vv = video.get("play_vv", 0)
mix_name = video.get("mix_name", "") mix_name = video.get("mix_name", "").strip()
# 🚫 跳过无效数据确保mix_name不为空
# 注意播放量为0的数据也会被保留可能是新发布的短剧
if not mix_name or mix_name == "" or mix_name.lower() == "null":
self.logger.warning(f"跳过空的mix_name记录video_id: {video_id}")
continue
if current_play_vv <= 0:
self.logger.warning(f"⚠️ 榜单中发现播放量为0的记录: mix_name={mix_name}, play_vv={current_play_vv},仍会保留")
# 计算排名变化(基于昨天的排名) # 计算排名变化(基于昨天的排名)
rank_change = 0 rank_change = 0
if not item["is_new"] and item["yesterday_data"]: if not item["is_new"] and item["yesterday_data"]:
yesterday_rank = item["yesterday_data"].get("rank", 0) yesterday_rank = item["yesterday_data"].get("rank", 0)
rank_change = yesterday_rank - i rank_change = yesterday_rank - rank # 使用当前排名计数器
# 🔍 从Rankings_management获取详细信息按mix_id查询因为管理数据库每个短剧只有一条记录
mix_id = video.get("mix_id", "").strip()
management_data = None
if mix_id:
# 直接按mix_id查询不需要按日期查询
management_data = rankings_management_collection.find_one({"mix_id": mix_id})
if management_data:
logging.info(f"📋 从 Rankings_management 获取数据: {mix_name} (mix_id: {mix_id})")
else:
logging.warning(f"⚠️ 未找到管理数据: {mix_name} (mix_id: {mix_id})")
else:
logging.warning(f"⚠️ mix_id 为空: {mix_name}")
ranking_item = { ranking_item = {
"rank": i, # 🎯 核心榜单字段
"rank": rank, # 使用排名计数器
"title": mix_name, "title": mix_name,
"mix_name": mix_name,
"play_vv": current_play_vv, "play_vv": current_play_vv,
"author": video.get("author", ""), "series_author": video.get("series_author", ""),
"video_id": video_id, "video_id": video_id,
"video_url": video.get("video_url", ""), "video_url": video.get("video_url", ""),
"cover_image_url": video.get("cover_image_url", ""), "cover_image_url": video.get("cover_image_url", ""),
"playcount_str": video.get("playcount", ""), "playcount_str": video.get("playcount", ""),
# 时间轴对比数据
# 📋 从Rankings_management获取的详细字段
"batch_id": management_data.get("batch_id", "") if management_data else "",
"batch_time": management_data.get("batch_time") if management_data else None,
"item_sequence": management_data.get("item_sequence", 0) if management_data else 0,
"mix_id": video.get("mix_id", ""), # 直接从原始数据获取mix_id
"playcount": management_data.get("playcount", "") if management_data else "",
"request_id": management_data.get("request_id", "") if management_data else "",
"cover_image_url_original": management_data.get("cover_image_url_original", "") if management_data else "",
"cover_upload_success": management_data.get("cover_upload_success", True) if management_data else True,
"cover_backup_urls": management_data.get("cover_backup_urls", []) if management_data else [],
"desc": management_data.get("desc", "") if management_data else "",
"updated_to_episode": management_data.get("updated_to_episode", 0) if management_data else 0,
"episode_video_ids": management_data.get("episode_video_ids", []) if management_data else [],
"episode_details": management_data.get("episode_details", []) if management_data else [],
"data_status": management_data.get("data_status", "") if management_data else "",
"realtime_saved": management_data.get("realtime_saved", True) if management_data else True,
"created_at": management_data.get("created_at") if management_data else None,
"last_updated": management_data.get("last_updated") if management_data else None,
# 🎬 评论总结字段直接从管理数据库获取按mix_id查询
"comments_summary": management_data.get("comments_summary", "") if management_data else "",
# 🔑 分类字段直接从管理数据库获取按mix_id查询每个短剧只有一条记录
"Manufacturing_Field": management_data.get("Manufacturing_Field", "") if management_data else "",
"Copyright_field": management_data.get("Copyright_field", "") if management_data else "",
"classification_type": management_data.get("classification_type", "") if management_data else "",
"release_date": management_data.get("release_date", "") if management_data else "",
"Novel_IDs": management_data.get("Novel_IDs", []) if management_data else [],
"Anime_IDs": management_data.get("Anime_IDs", []) if management_data else [],
"Drama_IDs": management_data.get("Drama_IDs", []) if management_data else [],
# 🔒 锁定状态:直接从管理数据库获取
"field_lock_status": management_data.get("field_lock_status", {}) if management_data else {},
# 📊 时间轴对比数据(重要:包含播放量差值)
"timeline_data": { "timeline_data": {
"is_new": item["is_new"], "is_new": item["is_new"],
"rank_change": rank_change, "rank_change": rank_change,
@ -311,6 +439,7 @@ class DouyinAutoScheduler:
} }
comprehensive_ranking["data"].append(ranking_item) comprehensive_ranking["data"].append(ranking_item)
rank += 1 # 递增排名计数器
# 为每次计算添加唯一的时间戳,确保数据唯一性 # 为每次计算添加唯一的时间戳,确保数据唯一性
current_timestamp = datetime.now() current_timestamp = datetime.now()
@ -330,6 +459,29 @@ class DouyinAutoScheduler:
logging.info(f"📝 创建了新的今日榜单数据(第{existing_count + 1}次计算,包含最新差值)") logging.info(f"📝 创建了新的今日榜单数据(第{existing_count + 1}次计算,包含最新差值)")
logging.info(f"🔖 计算ID: {comprehensive_ranking['calculation_id']}") logging.info(f"🔖 计算ID: {comprehensive_ranking['calculation_id']}")
# 📊 检查数据完整性统计从Rankings_management成功获取详细信息的项目数量
total_items = len(comprehensive_ranking["data"])
items_with_management_data = 0
items_with_manufacturing = 0
items_with_copyright = 0
for item in comprehensive_ranking["data"]:
# 检查是否从Rankings_management获取到了数据
if item.get("batch_id") or item.get("desc") or item.get("Manufacturing_Field") or item.get("Copyright_field"):
items_with_management_data += 1
if item.get("Manufacturing_Field"):
items_with_manufacturing += 1
if item.get("Copyright_field"):
items_with_copyright += 1
print(f"数据完整性统计:")
print(f" 总项目数: {total_items}")
print(f" 从Rankings_management获取到详细信息: {items_with_management_data}")
print(f" 包含Manufacturing_Field: {items_with_manufacturing}")
print(f" 包含Copyright_field: {items_with_copyright}")
logging.info(f"📊 数据完整性: 总{total_items}项,获取详细信息{items_with_management_data}Manufacturing_Field: {items_with_manufacturing}Copyright_field: {items_with_copyright}")
# 统计信息 # 统计信息
new_count = sum(1 for item in comprehensive_ranking["data"] if item["timeline_data"]["is_new"]) new_count = sum(1 for item in comprehensive_ranking["data"] if item["timeline_data"]["is_new"])
print(f"✅ 时间轴对比榜单生成成功") print(f"✅ 时间轴对比榜单生成成功")
@ -358,13 +510,165 @@ class DouyinAutoScheduler:
import traceback import traceback
logging.error(f"详细错误信息: {traceback.format_exc()}") logging.error(f"详细错误信息: {traceback.format_exc()}")
def check_and_sync_missing_fields(self):
"""实时检查并同步当天缺失字段"""
try:
from database import db
# 只检查当天的数据
today = date.today()
today_str = today.strftime('%Y-%m-%d')
# 首先检查 Rankings_management 是否有当天的数据
rankings_management_collection = db['Rankings_management']
management_count = rankings_management_collection.count_documents({})
if management_count == 0:
# Rankings_management 没有数据,说明还没有抓取,直接返回
return
rankings_collection = db['Ranking_storage']
key_fields = ['Manufacturing_Field', 'Copyright_field', 'desc', 'series_author']
# 检查今天是否有缺失字段的数据
missing_conditions = []
for field in key_fields:
missing_conditions.extend([
{field: {"$exists": False}},
{field: None},
{field: ""}
])
today_missing_count = rankings_collection.count_documents({
"date": today_str,
"$or": missing_conditions
})
# 如果今天没有缺失数据,静默返回
if today_missing_count == 0:
return
logging.info(f"🔍 检测到今天有 {today_missing_count} 条缺失字段Rankings_management有 {management_count} 条数据,开始实时同步...")
# 只处理当天的数据
dates_to_check = [today_str]
total_missing = 0
total_synced = 0
for check_date in dates_to_check:
# 查询该日期缺失字段的数据
rankings_collection = db['Ranking_storage']
# 检查多个关键字段(包括新增的分类字段)
key_fields = ['Manufacturing_Field', 'Copyright_field', 'desc', 'series_author', 'Novel_IDs', 'Anime_IDs', 'Drama_IDs']
missing_conditions = []
for field in key_fields:
missing_conditions.extend([
{field: {"$exists": False}},
{field: None},
{field: ""}
])
missing_query = {
"date": check_date,
"$or": missing_conditions
}
missing_count = rankings_collection.count_documents(missing_query)
# 详细统计每个字段的缺失情况
field_stats = {}
total_items = rankings_collection.count_documents({"date": check_date})
for field in key_fields:
missing_field_count = rankings_collection.count_documents({
"date": check_date,
"$or": [
{field: {"$exists": False}},
{field: None},
{field: ""}
]
})
field_stats[field] = {
"missing": missing_field_count,
"completion_rate": ((total_items - missing_field_count) / total_items * 100) if total_items > 0 else 0
}
if missing_count > 0:
logging.info(f"📅 今日({check_date}): 发现 {missing_count} 条记录缺失字段(总计 {total_items} 条)")
# 输出详细的字段统计
for field, stats in field_stats.items():
if stats["missing"] > 0:
logging.info(f" - {field}: 缺失 {stats['missing']} 条 ({stats['completion_rate']:.1f}% 完整)")
total_missing += missing_count
# 尝试同步
try:
from routers.rank_api_routes import sync_ranking_storage_fields
# 使用改进的重试机制
sync_result = sync_ranking_storage_fields(
target_date=check_date,
force_update=False,
max_retries=2, # 定期检查时重试2次
retry_delay=15 # 15秒重试间隔
)
if sync_result.get("success", False):
stats = sync_result.get("stats", {})
synced = stats.get("updated_items", 0)
retry_count = stats.get("retry_count", 0)
pending_final = stats.get("pending_items_final", 0)
total_synced += synced
if synced > 0:
logging.info(f"✅ 今日({check_date}): 成功同步 {synced} 条记录")
if retry_count > 0:
logging.info(f"🔄 今日({check_date}): 使用了 {retry_count} 次重试")
if pending_final > 0:
logging.warning(f"⚠️ 今日({check_date}): {pending_final} 条记录在 Rankings_management 中仍未找到")
else:
logging.warning(f"⚠️ 今日({check_date}): 同步失败 - {sync_result.get('message', '')}")
except Exception as sync_error:
logging.error(f"💥 今日({check_date}): 同步过程出错 - {sync_error}")
else:
if total_items > 0:
logging.info(f"📅 {check_date}: 所有字段完整(总计 {total_items} 条记录)")
# 显示完整性统计
for field, stats in field_stats.items():
logging.info(f" - {field}: {stats['completion_rate']:.1f}% 完整")
else:
logging.info(f"📅 {check_date}: 无数据")
if total_missing > 0:
logging.info(f"🔍 当天同步完成:发现 {total_missing} 条缺失记录,成功同步 {total_synced}")
print(f"🔍 当天字段同步:发现 {total_missing} 条缺失,同步 {total_synced}")
else:
# 当天没有缺失数据时,不输出日志(静默模式)
pass
except Exception as e:
logging.error(f"💥 检查缺失字段时发生异常: {e}")
import traceback
logging.error(f"详细错误信息: {traceback.format_exc()}")
def setup_schedule(self): def setup_schedule(self):
"""设置定时任务""" """设置定时任务"""
# 每小时的整点执行抖音播放量抓取 # 每小时的整点执行抖音播放量抓取
schedule.every().hour.at(":00").do(self.run_douyin_scraper) schedule.every().hour.at(":00").do(self.run_douyin_scraper)
# 每1分钟检查一次缺失字段并尝试同步实时同步
schedule.every(1).minutes.do(self.check_and_sync_missing_fields)
logging.info(f"⏰ 定时器已设置:每小时整点执行抖音播放量抓取") logging.info(f"⏰ 定时器已设置:每小时整点执行抖音播放量抓取")
logging.info(f"⏰ 定时器已设置每1分钟检查缺失字段并同步实时模式")
def show_next_run(self): def show_next_run(self):
"""显示下次执行时间""" """显示下次执行时间"""

View File

@ -1,9 +1,29 @@
from flask import Flask, jsonify from flask import Flask, jsonify, send_from_directory
from flask_cors import CORS from flask_cors import CORS
import logging import logging
import os import os
app = Flask(__name__) app = Flask(__name__)
# 配置静态文件目录为dist
# 说明:这里指向后端目录中的 dist前端构建产物应复制或输出到此
dist_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'dist'))
app.static_folder = dist_dir
# 为 SPA 提供静态文件与回退到 index.html 的路由
@app.route('/')
def serve_index():
# 返回构建后的前端入口文件
return send_from_directory(app.static_folder, 'index.html')
@app.route('/<path:path>')
def serve_static_or_fallback(path):
# 如果请求的文件存在则直接返回,否则回退到 index.html用于前端路由
file_path = os.path.join(app.static_folder, path)
if os.path.isfile(file_path):
return send_from_directory(app.static_folder, path)
return send_from_directory(app.static_folder, 'index.html')
CORS(app) # 允许跨域访问 CORS(app) # 允许跨域访问
# 配置日志 # 配置日志
@ -22,13 +42,11 @@ logging.basicConfig(
# 导入并注册蓝图 # 导入并注册蓝图
from routers.rank_api_routes import rank_bp from routers.rank_api_routes import rank_bp
from routers.article_routes import article_bp
app.register_blueprint(rank_bp) app.register_blueprint(rank_bp)
app.register_blueprint(article_bp)
if __name__ == '__main__': if __name__ == '__main__':
print("启动主程序服务...") print("启动主程序服务...")
print("服务地址: http://localhost:5001") print("服务地址: http://localhost:8443")
app.run(host='0.0.0.0', port=5001, debug=True) app.run(host='0.0.0.0', port=8443, debug=True)

View File

@ -52,6 +52,18 @@ API_CONFIG = {
'OSS_HOST': TOS_CONFIG['self_domain'] 'OSS_HOST': TOS_CONFIG['self_domain']
} }
# DeepSeek API 配置(用于评论总结功能)
DEEPSEEK_CONFIG = {
'api_key': 'sk-7b47e34bdcb549e6b00115a99b9b5c4c', # DeepSeek API密钥
'api_base': 'https://api.deepseek.com/v1', # API基础URL
'model': 'deepseek-chat', # 使用的模型
'max_retries': 3, # 最大重试次数
'retry_delays': [2, 5, 10], # 重试延迟(秒)
'batch_size': 800, # 每批评论数量
'max_tokens': 15000, # 每批最大token数
'summary_max_length': 200 # 最终总结最大字数
}
def apply_timer_environment(): def apply_timer_environment():
"""应用定时器环境变量配置""" """应用定时器环境变量配置"""
for key, value in TIMER_ENV_CONFIG.items(): for key, value in TIMER_ENV_CONFIG.items():

View File

@ -19,9 +19,13 @@
{ {
"video_id": "7471924777410645283", "video_id": "7471924777410645283",
"episode_num": 0 "episode_num": 0
},
{
"video_id": "7472791705268325641",
"episode_num": 0
} }
], ],
"total_count": 5, "total_count": 6,
"last_update": "2025-10-22T09:55:36.943794", "last_update": "2025-11-06T17:43:54.929209",
"mix_name": "《青蛇传》" "mix_name": "《青蛇传》"
} }

View File

@ -143,9 +143,21 @@
{ {
"video_id": "7558378239337467174", "video_id": "7558378239337467174",
"episode_num": 0 "episode_num": 0
},
{
"video_id": "7567050545257516331",
"episode_num": 0
},
{
"video_id": "7568152326477942022",
"episode_num": 0
},
{
"video_id": "7569217928420183332",
"episode_num": 0
} }
], ],
"total_count": 36, "total_count": 39,
"last_update": "2025-10-22T09:55:32.073567", "last_update": "2025-11-06T11:06:44.598400",
"mix_name": "末世系列" "mix_name": "末世系列"
} }

View File

@ -47,9 +47,17 @@
{ {
"video_id": "7548447317729234239", "video_id": "7548447317729234239",
"episode_num": 0 "episode_num": 0
},
{
"video_id": "7568747381357808923",
"episode_num": 0
},
{
"video_id": "7568800392985791784",
"episode_num": 0
} }
], ],
"total_count": 12, "total_count": 14,
"last_update": "2025-10-22T09:55:50.726907", "last_update": "2025-11-06T17:48:06.014161",
"mix_name": "青云修仙传" "mix_name": "青云修仙传"
} }

View File

@ -107,9 +107,17 @@
{ {
"video_id": "7560551213957500195", "video_id": "7560551213957500195",
"episode_num": 0 "episode_num": 0
},
{
"video_id": "7562056353343966464",
"episode_num": 0
},
{
"video_id": "7567981488823318927",
"episode_num": 0
} }
], ],
"total_count": 27, "total_count": 29,
"last_update": "2025-10-22T09:56:16.947762", "last_update": "2025-11-06T17:15:32.747557",
"mix_name": "绝境逆袭" "mix_name": "绝境逆袭"
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,268 +0,0 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
文章API服务器
提供文章列表获取和文章详情获取的接口
"""
from flask import Blueprint, request, jsonify
from datetime import datetime, timedelta
import logging
from database import db
from bson import ObjectId
# 创建蓝图
article_bp = Blueprint('article', __name__, url_prefix='/api/article')
# 获取数据库集合
articles_collection = db['articles']
def format_time(time_obj):
"""格式化时间"""
if not time_obj:
return ""
if isinstance(time_obj, datetime):
return time_obj.strftime("%Y-%m-%d %H:%M:%S")
else:
return str(time_obj)
def format_article_item(doc):
"""格式化文章数据项"""
return {
"_id": str(doc.get("_id", "")),
"title": doc.get("title", ""),
"author_id": doc.get("author_id", ""),
"cover_image": doc.get("cover_image", ""),
"status": doc.get("status", ""),
"summary": doc.get("summary", ""),
"created_at": format_time(doc.get("created_at")),
"likes": doc.get("likes", []),
"likes_count": len(doc.get("likes", []))
}
def get_article_list(page=1, limit=20, sort_by="created_at", status=None):
"""获取文章列表(分页)"""
try:
# 计算跳过的数量
skip = (page - 1) * limit
# 构建查询条件
query_condition = {}
if status:
query_condition["status"] = status
# 设置排序字段
sort_field = sort_by if sort_by in ["created_at", "title"] else "created_at"
sort_order = -1 # 降序
# 查询数据
cursor = articles_collection.find(query_condition).sort(sort_field, sort_order).skip(skip).limit(limit)
docs = list(cursor)
# 获取总数
total = articles_collection.count_documents(query_condition)
# 格式化数据
article_list = []
for doc in docs:
item = format_article_item(doc)
article_list.append(item)
return {
"success": True,
"data": article_list,
"pagination": {
"page": page,
"limit": limit,
"total": total,
"pages": (total + limit - 1) // limit,
"has_next": page * limit < total,
"has_prev": page > 1
},
"sort_by": sort_by,
"status_filter": status,
"update_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
except Exception as e:
logging.error(f"获取文章列表失败: {e}")
return {"success": False, "message": f"获取数据失败: {str(e)}"}
def search_articles(keyword, page=1, limit=10):
"""搜索文章"""
try:
if not keyword:
return {"success": False, "message": "请提供搜索关键词"}
# 计算跳过的数量
skip = (page - 1) * limit
# 构建搜索条件(模糊匹配标题和内容)
search_condition = {
"$or": [
{"title": {"$regex": keyword, "$options": "i"}},
{"content": {"$regex": keyword, "$options": "i"}},
{"summary": {"$regex": keyword, "$options": "i"}}
]
}
# 查询数据
cursor = articles_collection.find(search_condition).sort("created_at", -1).skip(skip).limit(limit)
docs = list(cursor)
# 获取搜索结果总数
total = articles_collection.count_documents(search_condition)
# 格式化数据
search_results = []
for doc in docs:
item = format_article_item(doc)
search_results.append(item)
return {
"success": True,
"data": search_results,
"keyword": keyword,
"pagination": {
"page": page,
"limit": limit,
"total": total,
"pages": (total + limit - 1) // limit,
"has_next": page * limit < total,
"has_prev": page > 1
},
"update_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
except Exception as e:
logging.error(f"搜索文章失败: {e}")
return {"success": False, "message": f"搜索失败: {str(e)}"}
def get_article_detail(article_id):
"""获取文章详情"""
try:
# 尝试通过ObjectId查找
try:
doc = articles_collection.find_one({"_id": ObjectId(article_id)})
except:
# 如果ObjectId无效尝试其他字段
doc = articles_collection.find_one({
"$or": [
{"title": article_id},
{"author_id": article_id}
]
})
if not doc:
return {"success": False, "message": "未找到文章信息"}
# 格式化详细信息
detail = format_article_item(doc)
return {
"success": True,
"data": detail,
"update_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
except Exception as e:
logging.error(f"获取文章详情失败: {e}")
return {"success": False, "message": f"获取详情失败: {str(e)}"}
def get_statistics():
"""获取统计信息"""
try:
# 基本统计
total_articles = articles_collection.count_documents({})
if total_articles == 0:
return {"success": False, "message": "暂无数据"}
# 按状态统计
status_stats = []
for status in ["draft", "published", "archived"]:
count = articles_collection.count_documents({"status": status})
status_stats.append({"status": status, "count": count})
# 获取最新更新时间
latest_doc = articles_collection.find().sort("created_at", -1).limit(1)
latest_time = ""
if latest_doc:
latest_list = list(latest_doc)
if latest_list:
latest_time = format_time(latest_list[0].get("created_at"))
return {
"success": True,
"data": {
"total_articles": total_articles,
"status_stats": status_stats,
"latest_update": latest_time
},
"update_time": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
except Exception as e:
logging.error(f"获取统计信息失败: {e}")
return {"success": False, "message": f"获取统计失败: {str(e)}"}
# 路由定义
@article_bp.route('/list')
def get_articles():
"""获取文章列表"""
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 20))
sort_by = request.args.get('sort', 'created_at')
status = request.args.get('status')
result = get_article_list(page, limit, sort_by, status)
return jsonify(result)
@article_bp.route('/search')
def search():
"""搜索文章"""
keyword = request.args.get('q', '')
page = int(request.args.get('page', 1))
limit = int(request.args.get('limit', 10))
result = search_articles(keyword, page, limit)
return jsonify(result)
@article_bp.route('/detail')
def get_detail():
"""获取文章详情"""
article_id = request.args.get('id', '')
result = get_article_detail(article_id)
return jsonify(result)
@article_bp.route('/stats')
def get_stats():
"""获取统计信息"""
result = get_statistics()
return jsonify(result)
@article_bp.route('/health')
def health_check():
"""健康检查"""
try:
# 检查数据库连接
total_records = articles_collection.count_documents({})
return jsonify({
"success": True,
"message": "服务正常",
"data": {
"database": "连接正常",
"total_records": total_records,
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
})
except Exception as e:
return jsonify({
"success": False,
"message": f"服务异常: {str(e)}",
"data": {
"database": "连接失败",
"timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
}
})

File diff suppressed because it is too large Load Diff

View File

@ -11,7 +11,8 @@
"axios": "^1.12.2", "axios": "^1.12.2",
"bootstrap": "^5.3.0-alpha1", "bootstrap": "^5.3.0-alpha1",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"vue": "^3.5.22" "vue": "^3.5.22",
"vue-router": "^4.6.3"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",
@ -1333,6 +1334,11 @@
"@vue/shared": "3.5.22" "@vue/shared": "3.5.22"
} }
}, },
"node_modules/@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
},
"node_modules/@vue/devtools-core": { "node_modules/@vue/devtools-core": {
"version": "8.0.3", "version": "8.0.3",
"resolved": "https://registry.npmmirror.com/@vue/devtools-core/-/devtools-core-8.0.3.tgz", "resolved": "https://registry.npmmirror.com/@vue/devtools-core/-/devtools-core-8.0.3.tgz",
@ -2643,6 +2649,20 @@
} }
} }
}, },
"node_modules/vue-router": {
"version": "4.6.3",
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.3.tgz",
"integrity": "sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg==",
"dependencies": {
"@vue/devtools-api": "^6.6.4"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"vue": "^3.5.0"
}
},
"node_modules/wsl-utils": { "node_modules/wsl-utils": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmmirror.com/wsl-utils/-/wsl-utils-0.1.0.tgz", "resolved": "https://registry.npmmirror.com/wsl-utils/-/wsl-utils-0.1.0.tgz",
@ -3459,6 +3479,11 @@
"@vue/shared": "3.5.22" "@vue/shared": "3.5.22"
} }
}, },
"@vue/devtools-api": {
"version": "6.6.4",
"resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
},
"@vue/devtools-core": { "@vue/devtools-core": {
"version": "8.0.3", "version": "8.0.3",
"resolved": "https://registry.npmmirror.com/@vue/devtools-core/-/devtools-core-8.0.3.tgz", "resolved": "https://registry.npmmirror.com/@vue/devtools-core/-/devtools-core-8.0.3.tgz",
@ -4276,6 +4301,14 @@
"@vue/shared": "3.5.22" "@vue/shared": "3.5.22"
} }
}, },
"vue-router": {
"version": "4.6.3",
"resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.3.tgz",
"integrity": "sha512-ARBedLm9YlbvQomnmq91Os7ck6efydTSpRP3nuOKCvgJOHNrhRoJDSKtee8kcL1Vf7nz6U+PMBL+hTvR3bTVQg==",
"requires": {
"@vue/devtools-api": "^6.6.4"
}
},
"wsl-utils": { "wsl-utils": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmmirror.com/wsl-utils/-/wsl-utils-0.1.0.tgz", "resolved": "https://registry.npmmirror.com/wsl-utils/-/wsl-utils-0.1.0.tgz",

View File

@ -15,7 +15,8 @@
"axios": "^1.12.2", "axios": "^1.12.2",
"bootstrap": "^5.3.0-alpha1", "bootstrap": "^5.3.0-alpha1",
"bootstrap-icons": "^1.13.1", "bootstrap-icons": "^1.13.1",
"vue": "^3.5.22" "vue": "^3.5.22",
"vue-router": "^4.6.3"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^6.0.1", "@vitejs/plugin-vue": "^6.0.1",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@ -1,5 +0,0 @@
<svg width="60" height="80" xmlns="http://www.w3.org/2000/svg">
<rect width="60" height="80" fill="#f0f0f0" stroke="#ddd" stroke-width="1"/>
<text x="30" y="35" text-anchor="middle" font-family="Arial" font-size="8" fill="#999">暂无</text>
<text x="30" y="50" text-anchor="middle" font-family="Arial" font-size="8" fill="#999">图片</text>
</svg>

Before

Width:  |  Height:  |  Size: 355 B

1043
frontend/src/AdminPanel.vue Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,11 @@
<script setup> <script setup>
import { ref, onMounted, computed } from 'vue' import { ref, onMounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import axios from 'axios' import axios from 'axios'
const router = useRouter()
const route = useRoute()
// //
const currentTab = ref('ranking') // 'ranking' 'news' const currentTab = ref('ranking') // 'ranking' 'news'
const rankingData = ref([]) const rankingData = ref([])
@ -13,6 +17,10 @@ const updateTime = ref('') // 添加更新时间字段
const showDatePicker = ref(false) // const showDatePicker = ref(false) //
const dateOptions = ref([]) // const dateOptions = ref([]) //
const selectedCategory = ref('all') // const selectedCategory = ref('all') //
const showCommentsSummary = ref(false) //
const currentCommentsSummary = ref('') //
const currentDramaName = ref('') //
const currentDramaMixId = ref('') // ID
// //
const initDate = () => { const initDate = () => {
@ -59,14 +67,23 @@ const generateDateOptions = () => {
const fetchRankingData = async () => { const fetchRankingData = async () => {
loading.value = true loading.value = true
try { try {
const response = await axios.get('http://localhost:5001/api/rank/videos', { // API
params: { const params = {
page: currentPage.value, page: currentPage.value,
limit: 20, limit: 100,
sort: 'growth', sort: 'growth',
start_date: selectedDate.value, start_date: selectedDate.value,
end_date: selectedDate.value end_date: selectedDate.value
} }
//
if (selectedCategory.value !== 'all') {
params.classification_type = selectedCategory.value
}
// const response = await axios.get('http://159.75.150.210:8443/api/rank/videos', { //
const response = await axios.get('http://localhost:8443/api/rank/videos', { //
params: params
}) })
if (response.data.success) { if (response.data.success) {
@ -74,6 +91,7 @@ const fetchRankingData = async () => {
totalPages.value = response.data.pagination.pages totalPages.value = response.data.pagination.pages
// //
updateTime.value = response.data.update_time || '' updateTime.value = response.data.update_time || ''
console.log(`获取${selectedCategory.value === 'all' ? '全部' : selectedCategory.value}分类数据成功,共${response.data.data.length}`)
} else { } else {
console.error('获取数据失败:', response.data.message) console.error('获取数据失败:', response.data.message)
rankingData.value = [] rankingData.value = []
@ -212,7 +230,9 @@ const selectDate = (dateValue) => {
// //
const switchCategory = (category) => { const switchCategory = (category) => {
selectedCategory.value = category selectedCategory.value = category
// currentPage.value = 1 //
fetchRankingData() //
console.log(`切换到分类: ${category}`)
} }
// //
@ -237,6 +257,38 @@ const getRankBadgeClass = (rank) => {
return 'rank-normal' return 'rank-normal'
} }
//
// const goToAdmin = () => {
// router.push('/admin')
// }
// 使 mix_id-
const fetchCommentsSummary = async (item, event) => {
//
if (event) {
event.stopPropagation()
}
//
const dramaId = item.mix_id || item._id
router.push(`/drama/${dramaId}#comments`)
}
//
const closeCommentsSummary = () => {
showCommentsSummary.value = false
currentCommentsSummary.value = ''
currentDramaName.value = ''
currentDramaMixId.value = ''
}
//
const goToDramaDetail = (item) => {
// 使 mix_id
const dramaId = item.mix_id || item._id
router.push(`/drama/${dramaId}`)
}
// //
onMounted(() => { onMounted(() => {
initDate() initDate()
@ -246,36 +298,21 @@ onMounted(() => {
<template> <template>
<div class="app"> <div class="app">
<!-- 主容器 --> <!-- 路由视图 -->
<div class="main-container"> <router-view v-if="route.path !== '/'" />
<!-- 顶部标题区域 -->
<div class="header-section">
<div class="title-wrapper">
<div class="title-icon-left"></div>
<h1 class="main-title">AI棒榜</h1>
<div class="title-icon-right"></div>
</div>
</div>
<!-- 横幅区域 --> <!-- 主容器 - 仅在首页显示 -->
<div class="banner-section"> <div v-if="route.path === '/'" class="main-container">
<div class="banner-content"> <!-- 顶部横幅区域按设计稿 -->
<p class="banner-subtitle">微短剧爆火</p> <div class="top-banner">
<p class="banner-title">中国"血统"的ReelShort征服美国</p> <div class="banner-inner">
<img src="./images/mhru18yf-f5p3yze.svg" class="banner-main-title" />
<p class="banner-subtitle">基于抖音端原生播放增量排序</p>
</div> </div>
</div> </div>
<!-- 装饰分隔线 -->
<div class="divider-dots"></div>
<!-- 日期显示区域 -->
<div class="date-section">
<p class="date-title">{{ formatDateTitle(selectedDate) }}</p>
<div class="date-dropdown-icon" @click="toggleDatePicker"></div>
</div>
<!-- 分类标签区域 --> <!-- 分类标签区域 -->
<div class="category-section"> <!-- <div class="category-section">
<div <div
class="category-tab" class="category-tab"
:class="{ active: selectedCategory === 'all' }" :class="{ active: selectedCategory === 'all' }"
@ -304,10 +341,16 @@ onMounted(() => {
> >
<span>短剧</span> <span>短剧</span>
</div> </div>
</div> </div> -->
<!-- 排行榜内容区域 --> <!-- 排行榜内容区域 -->
<div class="ranking-content"> <div class="ranking-content">
<!-- 日期显示区域 -->
<div class="date-section">
<p class="date-title">{{ formatDateTitle(selectedDate) }}</p>
<div class="date-dropdown-icon" @click="toggleDatePicker"></div>
</div>
<!-- 加载状态 --> <!-- 加载状态 -->
<div v-if="loading" class="loading"> <div v-if="loading" class="loading">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
@ -320,6 +363,7 @@ onMounted(() => {
v-for="(item, index) in rankingData" v-for="(item, index) in rankingData"
:key="item._id || index" :key="item._id || index"
class="ranking-item" class="ranking-item"
@click="goToDramaDetail(item)"
> >
<!-- 排名标识 --> <!-- 排名标识 -->
<div class="rank-badge" :class="getRankBadgeClass(index + 1)"> <div class="rank-badge" :class="getRankBadgeClass(index + 1)">
@ -344,39 +388,46 @@ onMounted(() => {
<!-- 详细信息 --> <!-- 详细信息 -->
<div class="drama-details"> <div class="drama-details">
<div class="detail-icons"> <div class="detail-icons">
<div class="detail-icon"></div> <img src="./images/剧场名icon.svg" alt="剧场名" class="detail-icon" />
<div class="detail-icon"></div> <img src="./images/承制icon.svg" alt="承制" class="detail-icon" />
<div class="detail-icon"></div> <img src="./images/版权icon.svg" alt="版权" class="detail-icon" />
</div> </div>
<div class="detail-text"> <div class="detail-text">
<p>剧场名爱微剧场</p> <p>剧场名{{ item.series_author || '' }}</p>
<p>承制妙想制片厂</p> <p>承制{{ item.Manufacturing_Field || '' }}</p>
<p>版权可梦</p> <p>版权{{ item.Copyright_field || '' }}</p>
</div> </div>
</div> </div>
<!-- 数据统计 --> <!-- 数据统计 -->
<div class="stats-row"> <div class="stats-row">
<div class="stat-item"> <div class="stat-item">
<div class="stat-icon play-icon"></div> <img src="./images/播放icon.svg" alt="播放" class="stat-icon" />
<span class="stat-value">{{ formatPlayCount(item.play_vv) || '9999W' }}</span> <span class="stat-value">{{ formatPlayCount(item.play_vv) || '9999W' }}</span>
</div> </div>
<div class="stat-item"> <div class="stat-item">
<div class="stat-icon like-icon"></div> <img src="./images/点赞icon.svg" alt="点赞" class="stat-icon" />
<span class="stat-value">374W</span> <span class="stat-value">{{ item.total_likes_formatted || '0' }}</span>
</div> </div>
</div> </div>
</div> </div>
<!-- 增长数据 --> <!-- 增长数据 -->
<div class="growth-section"> <div class="growth-section">
<div class="growth-icon"></div> <div>
<img src="./images/热度icon.svg" alt="热度" class="growth-icon" />
<span class="growth-value">{{ formatGrowth(item) || '300W' }}</span> <span class="growth-value">{{ formatGrowth(item) || '300W' }}</span>
</div> </div>
<!-- 用户评论总结 --> <!-- 评论总结按钮 - 只有当有评论总结时才显示 -->
<div class="comment-summary"> <button
<p>用户评论总结</p> v-if="item.comments_summary"
class="comments-summary-btn"
@click="fetchCommentsSummary(item, $event)"
title="查看用户评论AI总结"
>
用户评论总结
</button>
</div> </div>
</div> </div>
@ -408,7 +459,10 @@ onMounted(() => {
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
@ -422,40 +476,50 @@ onMounted(() => {
/* 主容器 */ /* 主容器 */
.main-container { .main-container {
max-width: 375px; max-width: 428px;
margin: 0 auto; margin: 0 auto;
background: #ebedf2; background: #ebedf2;
min-height: 100vh; min-height: 100vh;
position: relative; position: relative;
} }
/* 顶部标题区域 */ /* 顶部横幅(按设计稿) */
.header-section { .top-banner {
padding: 20px 0; position: relative;
text-align: center; height: 180px;
/* 背景图改为项目内资源路径 */
background-image: url('./images/top_bg.png');
background-position: center;
background-size: cover;
background-repeat: no-repeat;
overflow: hidden;
} }
.title-wrapper { .top-banner::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(to bottom, rgba(0,0,0,0.0) 0%, rgba(0,0,0,0.05) 60%, rgba(0,0,0,0.1) 100%);
}
.banner-inner {
position: relative;
z-index: 1;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 10px; height: 100%;
} }
.banner-main-title {
.title-icon-left, width: 136px;
.title-icon-right {
width: 20px;
height: 20px;
background: #333;
border-radius: 50%;
} }
.banner-subtitle {
.main-title { margin-top: 12px;
font-size: 24px; letter-spacing: 1px;
font-weight: bold; color: #ffffff;
color: #333; font-family: ABeeZee, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", SimHei, Arial, Helvetica, sans-serif;
margin: 0; font-size: 16px;
font-family: Alatsi, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', SimHei, Arial, Helvetica, sans-serif;
} }
/* 横幅区域 */ /* 横幅区域 */
@ -473,20 +537,6 @@ onMounted(() => {
z-index: 2; z-index: 2;
} }
.banner-subtitle {
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
margin: 0 0 8px 0;
}
.banner-title {
font-size: 18px;
font-weight: bold;
color: white;
margin: 0;
line-height: 1.4;
}
/* 装饰分隔线 */ /* 装饰分隔线 */
.divider-dots { .divider-dots {
display: flex; display: flex;
@ -510,14 +560,13 @@ onMounted(() => {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin: 20px 0;
gap: 8px; gap: 8px;
} }
.date-title { .date-title {
font-size: 16px; font-size: 14px;
color: #333; color: #555;
margin: 0; margin: 8px 0;
font-weight: 500; font-weight: 500;
} }
@ -559,7 +608,17 @@ onMounted(() => {
/* 排行榜内容区域 */ /* 排行榜内容区域 */
.ranking-content { .ranking-content {
padding: 0 16px; /* 作为白色圆角卡片容器 */
background: #ffffff;
/* 仅顶部圆角 */
border-radius: 24px 24px 0 0;
/* 移除两侧内边距与外边距 */
padding: 0;
margin: -24px 0 16px;
/* 提高层级,确保顶部圆角可见 */
position: relative;
z-index: 2;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.06);
} }
/* 加载状态 */ /* 加载状态 */
@ -588,28 +647,33 @@ onMounted(() => {
.ranking-list { .ranking-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; padding: 0 16px;
} }
.ranking-item { .ranking-item {
background: white; min-height: 120px;
border-radius: 12px; padding: 12px 0 ;
padding: 16px;
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 12px; gap: 12px;
position: relative; position: relative;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); border-bottom: 1px solid #E1E3E5;
cursor: pointer;
transition: background-color 0.2s ease;
}
.ranking-item:hover {
background-color: #f8f9fa;
} }
/* 排名徽章 */ /* 排名徽章 */
.rank-badge { .rank-badge {
position: absolute; position: absolute;
top: -8px; top: 11px;
left: -8px; left: -1px;
width: 32px; width: 24px;
height: 32px; height: 28px;
border-radius: 50%; border-radius: 8px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -642,8 +706,8 @@ onMounted(() => {
/* 海报容器 */ /* 海报容器 */
.poster-container { .poster-container {
width: 60px; width: 84px;
height: 80px; height: 112px;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
flex-shrink: 0; flex-shrink: 0;
@ -685,10 +749,10 @@ onMounted(() => {
} }
.detail-icon { .detail-icon {
width: 4px; margin: 1px 0;
height: 4px; width: 14px;
background: #9ca3af; height: 14px;
border-radius: 50%; flex-shrink: 0;
} }
.detail-text { .detail-text {
@ -721,14 +785,6 @@ onMounted(() => {
border-radius: 2px; border-radius: 2px;
} }
.play-icon {
background: #4a90e2;
}
.like-icon {
background: #ef4444;
}
.stat-value { .stat-value {
font-size: 12px; font-size: 12px;
color: #374151; color: #374151;
@ -739,14 +795,17 @@ onMounted(() => {
.growth-section { .growth-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: flex-start;
gap: 4px; gap: 2px;
padding: 8px;
background: #ef4444;
border-radius: 8px;
color: white;
flex-shrink: 0; flex-shrink: 0;
min-width: 60px; min-width: 60px;
justify-content: flex-end;
}
.growth-section > div:first-child {
display: flex;
align-items: center;
gap: 4px;
} }
.growth-icon { .growth-icon {
@ -757,17 +816,11 @@ onMounted(() => {
} }
.growth-value { .growth-value {
color: #ef4444;
font-weight: bold; font-weight: bold;
font-size: 12px; font-size: 12px;
} }
/* 用户评论总结 */
.comment-summary {
position: absolute;
bottom: 8px;
right: 16px;
}
.comment-summary p { .comment-summary p {
font-size: 12px; font-size: 12px;
color: #6b7280; color: #6b7280;
@ -883,6 +936,27 @@ onMounted(() => {
color: rgba(255, 255, 255, 0.8); color: rgba(255, 255, 255, 0.8);
} }
/* 评论总结按钮样式 */
.comments-summary-btn {
background: none;
border: none;
color: #333;
font-size: 10px;
cursor: pointer;
padding: 0;
margin-top: 78px;
display: block;
text-align: left;
transition: color 0.2s ease;
white-space: nowrap;
}
.comments-summary-btn:hover {
color: #666;
text-decoration: underline;
}
/* 响应式设计 */ /* 响应式设计 */
@media (max-width: 480px) { @media (max-width: 480px) {
.main-container { .main-container {
@ -895,7 +969,8 @@ onMounted(() => {
} }
.ranking-content { .ranking-content {
padding: 0 12px; margin-top: -12px;
padding: 12px 0;
} }
.ranking-item { .ranking-item {

View File

@ -0,0 +1,682 @@
<template>
<div class="applications-page">
<div class="header">
<div class="header-top">
<button class="back-btn" @click="goBack"></button>
<h1>申请管理</h1>
</div>
<div class="filters">
<button
:class="['filter-btn', { active: currentFilter === 'all' }]"
@click="changeFilter('all')"
>
全部 ({{ stats.total }})
</button>
<button
:class="['filter-btn', { active: currentFilter === 'pending' }]"
@click="changeFilter('pending')"
>
待审核 ({{ stats.pending }})
</button>
<button
:class="['filter-btn', { active: currentFilter === 'approved' }]"
@click="changeFilter('approved')"
>
已通过 ({{ stats.approved }})
</button>
<button
:class="['filter-btn', { active: currentFilter === 'rejected' }]"
@click="changeFilter('rejected')"
>
已拒绝 ({{ stats.rejected }})
</button>
</div>
</div>
<div class="table-container">
<table class="applications-table">
<thead>
<tr>
<th>短剧名称</th>
<th>申请类型</th>
<th>公司名称</th>
<th>提交时间</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody>
<tr v-if="loading">
<td colspan="6" class="loading-cell">加载中...</td>
</tr>
<tr v-else-if="applications.length === 0">
<td colspan="6" class="empty-cell">暂无申请</td>
</tr>
<tr v-else v-for="app in applications" :key="app.application_id">
<td>{{ app.drama_name }}</td>
<td>
<span :class="['type-badge', app.field_type]">
{{ app.field_type_label }}
</span>
</td>
<td>{{ app.company_name }}</td>
<td>{{ app.submit_time }}</td>
<td>
<span :class="['status-badge', app.status]">
{{ app.status_label }}
</span>
</td>
<td>
<button class="action-btn view" @click="viewDetail(app)">查看详情</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 详情弹窗 -->
<div v-if="showDetailModal" class="modal-overlay" @click="closeDetail">
<div class="modal-content" @click.stop>
<div class="modal-header">
<h2>申请详情</h2>
<button class="close-btn" @click="closeDetail">×</button>
</div>
<div class="modal-body" v-if="currentApplication">
<div class="detail-section">
<h3>基本信息</h3>
<div class="detail-row">
<span class="label">短剧名称</span>
<span class="value">{{ currentApplication.drama_name }}</span>
</div>
<div class="detail-row">
<span class="label">申请类型</span>
<span class="value">{{ currentApplication.field_type_label }}</span>
</div>
<div class="detail-row">
<span class="label">公司名称</span>
<span class="value">{{ currentApplication.company_name }}</span>
</div>
<div class="detail-row">
<span class="label">补充说明</span>
<span class="value">{{ currentApplication.description || '无' }}</span>
</div>
<div class="detail-row">
<span class="label">提交时间</span>
<span class="value">{{ currentApplication.submit_time }}</span>
</div>
<div class="detail-row">
<span class="label">状态</span>
<span :class="['status-badge', currentApplication.status]">
{{ currentApplication.status_label }}
</span>
</div>
</div>
<div class="detail-section">
<h3>证明材料</h3>
<div class="files-grid">
<a
v-for="(url, index) in currentApplication.tos_file_urls"
:key="index"
:href="url"
target="_blank"
class="file-link"
>
<div class="file-preview">
<img v-if="isImage(url)" :src="url" alt="证明材料" />
<div v-else class="file-icon">
<span>{{ getFileExtension(url) }}</span>
</div>
</div>
<span class="file-name">文件 {{ index + 1 }}</span>
</a>
</div>
</div>
<div v-if="currentApplication.status === 'rejected'" class="detail-section">
<h3>拒绝理由</h3>
<p class="reject-reason">{{ currentApplication.reject_reason }}</p>
</div>
</div>
<div class="modal-footer" v-if="currentApplication && currentApplication.status === 'pending'">
<button class="action-btn reject" @click="showRejectInput = true" v-if="!showRejectInput">
拒绝
</button>
<div v-if="showRejectInput" class="reject-input-group">
<input
v-model="rejectReason"
type="text"
placeholder="请输入拒绝理由"
class="reject-input"
/>
<button class="action-btn reject" @click="handleReject">确认拒绝</button>
<button class="action-btn cancel" @click="showRejectInput = false">取消</button>
</div>
<button class="action-btn approve" @click="handleApprove">通过</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { useRouter } from 'vue-router'
import axios from 'axios'
const router = useRouter()
const API_BASE_URL = 'http://localhost:8443/api'
//
const applications = ref([])
const loading = ref(false)
const currentFilter = ref('all')
const showDetailModal = ref(false)
const currentApplication = ref(null)
const showRejectInput = ref(false)
const rejectReason = ref('')
//
const stats = ref({
total: 0,
pending: 0,
approved: 0,
rejected: 0
})
//
const fetchApplications = async (status = 'all') => {
loading.value = true
try {
const response = await axios.get(`${API_BASE_URL}/rank/claim/applications`, {
params: { status, page: 1, limit: 100 }
})
if (response.data.success) {
applications.value = response.data.data
}
} catch (error) {
console.error('获取申请列表失败:', error)
alert('获取申请列表失败')
} finally {
loading.value = false
}
}
//
const fetchStats = async () => {
try {
const [allRes, pendingRes, approvedRes, rejectedRes] = await Promise.all([
axios.get(`${API_BASE_URL}/rank/claim/applications`, { params: { status: 'all', limit: 1 } }),
axios.get(`${API_BASE_URL}/rank/claim/applications`, { params: { status: 'pending', limit: 1 } }),
axios.get(`${API_BASE_URL}/rank/claim/applications`, { params: { status: 'approved', limit: 1 } }),
axios.get(`${API_BASE_URL}/rank/claim/applications`, { params: { status: 'rejected', limit: 1 } })
])
stats.value = {
total: allRes.data.pagination?.total || 0,
pending: pendingRes.data.pagination?.total || 0,
approved: approvedRes.data.pagination?.total || 0,
rejected: rejectedRes.data.pagination?.total || 0
}
} catch (error) {
console.error('获取统计数据失败:', error)
}
}
//
const changeFilter = (filter) => {
currentFilter.value = filter
fetchApplications(filter)
}
//
const viewDetail = async (app) => {
try {
const response = await axios.get(`${API_BASE_URL}/rank/claim/application/${app.application_id}`)
if (response.data.success) {
currentApplication.value = response.data.data
showDetailModal.value = true
showRejectInput.value = false
rejectReason.value = ''
}
} catch (error) {
console.error('获取申请详情失败:', error)
alert('获取申请详情失败')
}
}
//
const closeDetail = () => {
showDetailModal.value = false
currentApplication.value = null
showRejectInput.value = false
rejectReason.value = ''
}
//
const handleApprove = async () => {
if (!confirm('确认通过该申请吗?')) return
try {
const response = await axios.post(`${API_BASE_URL}/rank/claim/review`, {
application_id: currentApplication.value.application_id,
action: 'approve',
reviewer: 'admin'
})
if (response.data.success) {
alert('申请已通过')
closeDetail()
fetchApplications(currentFilter.value)
fetchStats()
} else {
alert('操作失败:' + response.data.message)
}
} catch (error) {
console.error('审核失败:', error)
alert('审核失败,请稍后重试')
}
}
//
const handleReject = async () => {
if (!rejectReason.value.trim()) {
alert('请输入拒绝理由')
return
}
try {
const response = await axios.post(`${API_BASE_URL}/rank/claim/review`, {
application_id: currentApplication.value.application_id,
action: 'reject',
reject_reason: rejectReason.value,
reviewer: 'admin'
})
if (response.data.success) {
alert('申请已拒绝')
closeDetail()
fetchApplications(currentFilter.value)
fetchStats()
} else {
alert('操作失败:' + response.data.message)
}
} catch (error) {
console.error('审核失败:', error)
alert('审核失败,请稍后重试')
}
}
//
const isImage = (url) => {
return /\.(jpg|jpeg|png|gif)$/i.test(url)
}
//
const getFileExtension = (url) => {
const match = url.match(/\.([^.]+)$/)
return match ? match[1].toUpperCase() : 'FILE'
}
//
const goBack = () => {
router.push('/admin')
}
//
onMounted(() => {
fetchApplications('all')
fetchStats()
})
</script>
<style scoped>
.applications-page {
padding: 12px;
max-width: 428px;
margin: 0 auto;
background: #ebedf2;
min-height: 100vh;
}
.header {
margin-bottom: 12px;
}
.header-top {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.back-btn {
background: white;
border: none;
width: 32px;
height: 32px;
border-radius: 8px;
font-size: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #374151;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.back-btn:hover {
background: #f3f4f6;
}
.header h1 {
font-size: 18px;
margin: 0;
color: #1f2937;
font-weight: 600;
}
.filters {
display: flex;
gap: 10px;
}
.filter-btn {
padding: 6px 12px;
border: 1px solid #e5e7eb;
background: white;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
font-size: 12px;
}
.filter-btn:hover {
background: #f3f4f6;
}
.filter-btn.active {
background: #3b82f6;
color: white;
border-color: #3b82f6;
}
.table-container {
background: white;
border-radius: 8px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.applications-table {
width: 100%;
border-collapse: collapse;
}
.applications-table th {
background: #f9fafb;
padding: 8px 10px;
text-align: left;
font-weight: 600;
color: #374151;
border-bottom: 1px solid #e5e7eb;
font-size: 12px;
}
.applications-table td {
padding: 8px 10px;
border-bottom: 1px solid #f3f4f6;
font-size: 12px;
}
.loading-cell,
.empty-cell {
text-align: center;
color: #9ca3af;
padding: 40px !important;
}
.type-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
}
.type-badge.copyright {
background: #dbeafe;
color: #1e40af;
}
.type-badge.manufacturing {
background: #fce7f3;
color: #be123c;
}
.status-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
}
.status-badge.pending {
background: #fef3c7;
color: #92400e;
}
.status-badge.approved {
background: #d1fae5;
color: #065f46;
}
.status-badge.rejected {
background: #fee2e2;
color: #991b1b;
}
.action-btn {
padding: 5px 10px;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.action-btn.view {
background: #3b82f6;
color: white;
}
.action-btn.view:hover {
background: #2563eb;
}
.action-btn.approve {
background: #10b981;
color: white;
margin-left: 8px;
}
.action-btn.approve:hover {
background: #059669;
}
.action-btn.reject {
background: #ef4444;
color: white;
}
.action-btn.reject:hover {
background: #dc2626;
}
.action-btn.cancel {
background: #6b7280;
color: white;
margin-left: 8px;
}
/* 弹窗样式 */
.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;
}
.modal-content {
background: white;
border-radius: 12px;
width: 90%;
max-width: 400px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
font-size: 16px;
margin: 0;
font-weight: 600;
}
.close-btn {
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #6b7280;
line-height: 1;
}
.modal-body {
padding: 16px;
}
.detail-section {
margin-bottom: 16px;
}
.detail-section h3 {
font-size: 14px;
margin-bottom: 10px;
color: #374151;
font-weight: 600;
}
.detail-row {
display: flex;
margin-bottom: 10px;
}
.detail-row .label {
width: 80px;
color: #6b7280;
font-size: 12px;
flex-shrink: 0;
}
.detail-row .value {
flex: 1;
color: #1f2937;
font-size: 12px;
word-break: break-all;
}
.files-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.file-link {
text-decoration: none;
color: inherit;
}
.file-preview {
width: 100%;
height: 90px;
border-radius: 6px;
overflow: hidden;
background: #f3f4f6;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 4px;
}
.file-preview img {
width: 100%;
height: 100%;
object-fit: cover;
}
.file-icon {
font-size: 11px;
color: #6b7280;
font-weight: 600;
}
.file-name {
font-size: 11px;
color: #6b7280;
display: block;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.reject-reason {
padding: 10px;
background: #fef2f2;
border-left: 3px solid #ef4444;
color: #991b1b;
font-size: 12px;
}
.modal-footer {
padding: 12px 16px;
border-top: 1px solid #e5e7eb;
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
.reject-input-group {
display: flex;
gap: 8px;
flex: 1;
margin-right: 8px;
}
.reject-input {
flex: 1;
padding: 6px 10px;
border: 1px solid #e5e7eb;
border-radius: 4px;
font-size: 12px;
}
</style>

759
frontend/src/ClaimPage.vue Normal file
View File

@ -0,0 +1,759 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import axios from 'axios'
const router = useRouter()
const route = useRoute()
//
const description = ref('')
const images = ref([]) // filesimages
const companyName = ref('')
const showSuccessModal = ref(false)
const loading = ref(false)
//
const dramaId = route.params.id
const fieldType = route.query.type // 'copyright' 'manufacturing'
// APIURL
const API_BASE_URL = 'http://localhost:8443/api'
//
const fieldLabel = computed(() => {
return fieldType === 'copyright' ? '版权方' : '承制方'
})
const pageTitle = computed(() => {
return fieldType === 'copyright' ? '短剧版权方认证' : '短剧承制方认证'
})
const isFormValid = computed(() => {
return images.value.length > 0 &&
description.value.trim().length > 0 &&
companyName.value.trim().length > 0
})
//
const goBack = () => {
router.back()
}
//
const getFileIcon = (file) => {
const ext = file.name.split('.').pop().toLowerCase()
if (['jpg', 'jpeg', 'png', 'gif'].includes(ext)) return 'image'
if (ext === 'pdf') return 'pdf'
if (['doc', 'docx'].includes(ext)) return 'word'
return 'file'
}
//
const validateFileSize = (file) => {
const ext = file.name.split('.').pop().toLowerCase()
const isImage = ['jpg', 'jpeg', 'png', 'gif'].includes(ext)
const maxSize = isImage ? 10 * 1024 * 1024 : 20 * 1024 * 1024 // 10MB for images, 20MB for docs
if (file.size > maxSize) {
const maxSizeMB = maxSize / 1024 / 1024
const fileType = isImage ? '图片' : '文档'
alert(`${fileType}文件大小不能超过${maxSizeMB}MB`)
return false
}
return true
}
//
const handleFileUpload = (event) => {
const files = event.target.files
if (!files || files.length === 0) return
const remainingSlots = 9 - images.value.length
const filesToAdd = Array.from(files).slice(0, remainingSlots)
filesToAdd.forEach(file => {
//
if (!validateFileSize(file)) return
const fileType = getFileIcon(file)
if (fileType === 'image') {
//
const reader = new FileReader()
reader.onload = (e) => {
images.value.push({
id: Date.now() + Math.random(),
url: e.target.result,
file: file,
type: 'image',
name: file.name
})
}
reader.readAsDataURL(file)
} else {
//
images.value.push({
id: Date.now() + Math.random(),
url: null,
file: file,
type: fileType,
name: file.name
})
}
})
// input
event.target.value = ''
}
//
const triggerFileInput = () => {
document.getElementById('fileInput').click()
}
//
const handleRemoveImage = (id) => {
images.value = images.value.filter(img => img.id !== id)
}
//
const handleSubmit = async () => {
if (!isFormValid.value) return
loading.value = true
try {
// FormData
const formData = new FormData()
formData.append('drama_id', dramaId)
formData.append('field_type', fieldType)
formData.append('company_name', companyName.value)
formData.append('description', description.value)
//
images.value.forEach((img, index) => {
if (img.file) {
formData.append(`files`, img.file)
}
})
const response = await axios.post(`${API_BASE_URL}/rank/claim`, formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
if (response.data.success) {
showSuccessModal.value = true
// 3
setTimeout(() => {
router.push(`/drama/${dramaId}`)
}, 3000)
} else {
alert('提交失败:' + response.data.message)
}
} catch (error) {
console.error('提交认证失败:', error)
alert('提交失败,请稍后重试')
} finally {
loading.value = false
}
}
//
const closeSuccessModal = () => {
showSuccessModal.value = false
router.push(`/drama/${dramaId}`)
}
</script>
<template>
<div class="page">
<div class="card">
<!-- 顶部导航栏 -->
<div class="header">
<button class="icon-button" @click="goBack">
<svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" fill="none" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<p class="title-header">{{ pageTitle }}</p>
<div class="header-spacer" />
</div>
<!-- 内容区域 -->
<div class="content">
<!-- 认证信息 -->
<div class="section">
<div class="section-head">
<div class="section-bar" />
<p class="section-title">认证信息</p>
</div>
<div class="form-row">
<p class="form-label">{{ fieldLabel }}</p>
<input
v-model="companyName"
type="text"
placeholder="请输入"
class="form-input"
maxlength="50"
/>
</div>
<!-- 证明材料 -->
<div class="materials">
<div class="row-top">
<p class="row-label">证明材料</p>
<p class="count-label">{{ images.length }}/9</p>
</div>
<div class="grid">
<div
v-for="img in images"
:key="img.id"
class="thumb"
:class="{ 'thumb-file': img.type !== 'image' }"
:style="img.type === 'image' ? { backgroundImage: `url(${img.url})` } : {}"
>
<!-- 图片预览 -->
<template v-if="img.type === 'image'">
<!-- 图片已经通过backgroundImage显示 -->
</template>
<!-- PDF图标 -->
<div v-else-if="img.type === 'pdf'" class="file-icon">
<svg viewBox="0 0 24 24" width="32" height="32" fill="#e53e3e">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<text x="7" y="18" font-size="6" fill="white" font-weight="bold">PDF</text>
</svg>
<p class="file-name">{{ img.name }}</p>
</div>
<!-- Word图标 -->
<div v-else-if="img.type === 'word'" class="file-icon">
<svg viewBox="0 0 24 24" width="32" height="32" fill="#2b5797">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<text x="6" y="18" font-size="6" fill="white" font-weight="bold">DOC</text>
</svg>
<p class="file-name">{{ img.name }}</p>
</div>
<!-- 删除按钮 -->
<button class="thumb-close" @click="handleRemoveImage(img.id)">
<svg viewBox="0 0 24 24" width="12" height="12" stroke="#ffffff" fill="none" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
</div>
<button v-if="images.length < 9" class="add-btn" @click="triggerFileInput">
<svg viewBox="0 0 24 24" width="20" height="20" stroke="#9ca3af" fill="none" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
</div>
<!-- 隐藏的文件输入 -->
<input
id="fileInput"
type="file"
accept="image/jpeg,image/jpg,image/png,image/gif,.pdf,.doc,.docx"
multiple
style="display: none"
@change="handleFileUpload"
/>
<div class="hint">
<svg viewBox="0 0 24 24" width="16" height="16" stroke="#9ca3af" fill="none" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12" y2="8" />
</svg>
<p class="hint-text">
仅受持公司原画证认证版权方个人身份请提交知识经授权证证通上传可认证是
<button class="link-btn">查看示例</button>
</p>
</div>
</div>
</div>
<!-- 补充说明 -->
<div class="section">
<p class="sub-title">补充说明</p>
<div class="textarea-wrap">
<textarea
v-model="description"
placeholder="请输入"
class="textarea"
maxLength="100"
/>
<div class="counter">{{ description.length }}/100</div>
</div>
</div>
<!-- 提交按钮 -->
<button
:class="isFormValid ? 'submit-btn' : 'submit-btn-disabled'"
:disabled="!isFormValid || loading"
@click="handleSubmit"
>
{{ loading ? '提交中...' : '提交认证' }}
</button>
<p class="footer">
认证过程中有任何问题请点击<button class="link-btn">联系客服</button>
</p>
</div>
</div>
<!-- 成功弹窗 -->
<div v-if="showSuccessModal" class="modal-overlay" @click="closeSuccessModal">
<div class="modal-content" @click.stop>
<div class="modal-icon">
<svg viewBox="0 0 24 24" width="48" height="48" stroke="#10b981" fill="none" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
</div>
<h3 class="modal-title">提交成功</h3>
<p class="modal-text">您的认证申请已提交我们会尽快审核</p>
<button class="modal-btn" @click="closeSuccessModal">确定</button>
</div>
</div>
</div>
</template>
<style scoped>
* {
box-sizing: border-box;
}
.page {
display: flex;
flex-direction: column;
align-items: center;
background: #ebedf2;
padding: 14px;
min-height: 100vh;
}
.card {
display: flex;
flex-direction: column;
border-radius: 0;
overflow: hidden;
background: #f3f4f6;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
width: 100%;
max-width: 428px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
background: #ffffff;
padding: 12px 16px;
}
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
color: #374151;
background: none;
border: none;
cursor: pointer;
transition: opacity 0.2s;
}
.icon-button:hover {
opacity: 0.7;
}
.title-header {
color: #1f2937;
font-size: 16px;
font-weight: 600;
margin: 0;
}
.header-spacer {
width: 24px;
height: 24px;
}
.content {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
.section {
background: #ffffff;
border-radius: 12px;
padding: 20px;
}
.section-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
}
.section-bar {
width: 4px;
height: 16px;
border-radius: 999px;
background: #3b82f6;
}
.section-title {
color: #111827;
font-size: 16px;
font-weight: 600;
margin: 0;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 0;
border-bottom: 1px solid #f3f4f6;
}
.row-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.row-label {
color: #4b5563;
font-size: 14px;
margin: 0;
}
.form-row {
display: flex;
flex-direction: column;
gap: 8px;
padding: 12px 0;
border-bottom: 1px solid #f3f4f6;
}
.form-label {
color: #4b5563;
font-size: 14px;
margin: 0;
font-weight: 500;
}
.form-input {
border: 1px solid #e5e7eb;
background: #ffffff;
color: #111827;
font-size: 14px;
padding: 10px 12px;
border-radius: 8px;
outline: none;
width: 100%;
}
.form-input::placeholder {
color: #9ca3af;
}
.form-input:focus {
border-color: #3b82f6;
}
.count-label {
color: #9ca3af;
font-size: 14px;
margin: 0;
}
.materials {
margin-top: 12px;
}
.grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.thumb {
position: relative;
width: 80px;
height: 80px;
border-radius: 8px;
overflow: hidden;
background-size: cover;
background-position: center;
background-color: #e5e7eb;
}
.thumb-file {
display: flex;
align-items: center;
justify-content: center;
background-color: #f3f4f6;
}
.file-icon {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 8px;
}
.file-name {
font-size: 10px;
color: #6b7280;
text-align: center;
word-break: break-all;
line-height: 1.2;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
margin: 0;
}
.thumb-close {
position: absolute;
top: -4px;
right: -4px;
width: 20px;
height: 20px;
border-radius: 999px;
background: #4b5563;
display: flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
transition: background 0.2s;
}
.thumb-close:hover {
background: #374151;
}
.add-btn {
width: 80px;
height: 80px;
border-radius: 8px;
border: 2px dashed #d1d5db;
background: #ffffff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: border-color 0.2s;
}
.add-btn:hover {
border-color: #9ca3af;
}
.hint {
display: flex;
align-items: flex-start;
gap: 8px;
background: #f9fafb;
padding: 12px;
border-radius: 8px;
}
.hint-text {
font-size: 12px;
color: #6b7280;
margin: 0;
line-height: 1.5;
}
.link-btn {
color: #3b82f6;
font-size: 12px;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
text-decoration: underline;
}
.link-btn:hover {
opacity: 0.8;
}
.sub-title {
color: #111827;
font-size: 14px;
font-weight: 600;
margin: 0 0 12px 0;
}
.textarea-wrap {
position: relative;
}
.textarea {
width: 100%;
height: 96px;
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 10px;
resize: none;
font-size: 14px;
color: #111827;
font-family: inherit;
}
.textarea::placeholder {
color: #9ca3af;
}
.textarea:focus {
outline: none;
border-color: #3b82f6;
}
.counter {
position: absolute;
bottom: 8px;
right: 8px;
font-size: 12px;
color: #9ca3af;
}
.submit-btn {
width: 100%;
padding: 12px 0;
border-radius: 10px;
background: #3b82f6;
color: #ffffff;
font-size: 16px;
font-weight: 600;
border: none;
cursor: pointer;
transition: background 0.2s;
}
.submit-btn:hover {
background: #2563eb;
}
.submit-btn-disabled {
width: 100%;
padding: 12px 0;
border-radius: 10px;
background: #e5e7eb;
color: #9ca3af;
font-size: 16px;
font-weight: 600;
border: none;
cursor: not-allowed;
}
.footer {
margin-top: 8px;
margin-bottom: 40px;
text-align: center;
font-size: 12px;
color: #6b7280;
}
/* 成功弹窗 */
.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;
}
.modal-content {
background: white;
border-radius: 16px;
padding: 32px 24px;
max-width: 320px;
width: 90%;
text-align: center;
}
.modal-icon {
display: flex;
align-items: center;
justify-content: center;
width: 64px;
height: 64px;
margin: 0 auto 16px;
background: #d1fae5;
border-radius: 50%;
}
.modal-title {
font-size: 20px;
font-weight: 600;
color: #111827;
margin: 0 0 8px 0;
}
.modal-text {
font-size: 14px;
color: #6b7280;
margin: 0 0 24px 0;
}
.modal-btn {
width: 100%;
padding: 12px 0;
border-radius: 10px;
background: #3b82f6;
color: #ffffff;
font-size: 16px;
font-weight: 600;
border: none;
cursor: pointer;
transition: background 0.2s;
}
.modal-btn:hover {
background: #2563eb;
}
/* 响应式设计 */
@media (max-width: 480px) {
.page {
padding: 0;
}
.card {
max-width: 100%;
border-radius: 0;
}
}
</style>

View File

@ -0,0 +1,592 @@
<script setup>
import { ref, onMounted, nextTick } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import axios from 'axios'
const router = useRouter()
const route = useRoute()
//
const isExpanded = ref(false)
const loading = ref(false)
const dramaData = ref({
title: '',
mix_name: '',
mix_id: '',
classification_type: '', // /
episodes: '',
release_date: '', // 线
cover_image_url: '',
desc: '', // 使desc
Copyright_field: '', //
Manufacturing_Field: '', //
comments_summary: '' //
})
// APIURL
const API_BASE_URL = 'http://localhost:8443/api' //
//
const goBack = () => {
router.push('/')
}
// /
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value
}
// HTML
const formatCommentsSummary = (text, spacing = 1) => {
if (!text) return ''
//
let result = text.replace(/【([^】]+)】/g, '<strong>【$1】</strong>')
// 使margin-top
const spacingPx = spacing * 10 // spacing=1.5 -> 15px
// margindiv
let isFirst = true
result = result.replace(/(<strong>【)/g, (match) => {
if (isFirst) {
isFirst = false
return match
}
return `<div style="margin-top: ${spacingPx}px">${match}`
})
// divdiv
const parts = result.split(/(<div style="margin-top: \d+px">)/)
let finalResult = ''
for (let i = 0; i < parts.length; i++) {
if (parts[i].includes('margin-top')) {
finalResult += parts[i]
if (i + 1 < parts.length) {
finalResult += parts[i + 1] + '</div>'
i++ // part
}
} else {
finalResult += parts[i]
}
}
return finalResult
}
//
const fetchDramaDetail = async (dramaId) => {
loading.value = true
try {
const response = await axios.get(`${API_BASE_URL}/rank/drama/${dramaId}`)
if (response.data.success) {
const data = response.data.data
dramaData.value = {
title: data.mix_name || data.title || '',
mix_name: data.mix_name || '',
mix_id: data.mix_id || '',
classification_type: data.classification_type || '',
episodes: data.updated_to_episode ? `${data.updated_to_episode}` : '',
release_date: data.release_date || '',
cover_image_url: data.cover_image_url || '',
desc: data.desc || '', // 使desc
Copyright_field: data.Copyright_field || '',
Manufacturing_Field: data.Manufacturing_Field || '',
comments_summary: data.comments_summary || '',
series_author: data.series_author || ''
}
// URLhash
if (route.hash) {
await nextTick()
const element = document.querySelector(route.hash)
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}
} else {
console.error('获取短剧详情失败:', response.data.message)
}
} catch (error) {
console.error('API调用失败:', error)
} finally {
loading.value = false
}
}
//
const goToClaim = (type) => {
// type: 'copyright' 'manufacturing'
const dramaId = route.params.id
if (dramaId) {
router.push(`/claim/${dramaId}?type=${type}`)
} else {
console.error('无法获取短剧ID')
}
}
//
onMounted(() => {
// ID
const dramaId = route.params.id
console.log('短剧详情页ID:', dramaId)
if (dramaId) {
fetchDramaDetail(dramaId)
}
})
</script>
<template>
<div class="page">
<div class="card">
<!-- 顶部导航栏 -->
<div class="header">
<button class="icon-button" @click="goBack">
<svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" fill="none" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<p class="title-header">短剧详情</p>
<div class="header-spacer" />
</div>
<!-- 内容区域 -->
<div class="content">
<!-- 加载状态 -->
<div v-if="loading" class="loading">
<div class="loading-spinner"></div>
<p>加载中...</p>
</div>
<!-- 短剧基本信息 -->
<div v-else class="block">
<div class="drama-info">
<div class="cover">
<img
:src="dramaData.cover_image_url || '/placeholder-poster.svg'"
:alt="dramaData.title"
class="cover-img"
/>
</div>
<div class="info">
<div class="title-row">
<p class="title">{{ dramaData.title || '短剧名称' }}</p>
</div>
<p class="small-text">
<span class="field-label">类型/元素</span>
<span class="field-value">{{ dramaData.classification_type || '' }}</span>
</p>
<p class="small-text">
<span class="field-label">集数</span>
<span class="field-value">{{ dramaData.episodes || '' }}</span>
</p>
<p class="small-text">
<span class="field-label">上线日期</span>
<span class="field-value">{{ dramaData.release_date || '' }}</span>
</p>
</div>
</div>
<!-- 短剧介绍 -->
<div class="description">
<p class="description-label">剧情介绍</p>
<p class="description-text" v-if="dramaData.desc">
{{ isExpanded ? dramaData.desc : (dramaData.desc.length > 100 ? dramaData.desc.substring(0, 100) + '...' : dramaData.desc) }}
</p>
<p class="description-text description-empty" v-else></p>
<button v-if="dramaData.desc && dramaData.desc.length > 100" class="toggle-btn" @click="toggleExpanded">
{{ isExpanded ? '收起' : '展开' }}
</button>
</div>
</div>
<!-- 关联方信息 -->
<div v-if="!loading" class="block">
<p class="section-title">关联方</p>
<div class="assoc-group">
<p class="label">版权方</p>
<div class="assoc-row">
<div>
<span v-if="dramaData.Copyright_field" class="chip-blue">{{ dramaData.Copyright_field }}</span>
<span v-else class="chip-empty"></span>
</div>
<p v-if="!dramaData.Copyright_field" class="claim" @click="goToClaim('copyright')">我要认领</p>
</div>
</div>
<div class="assoc-group">
<p class="label">承制方</p>
<div class="assoc-row">
<div>
<span v-if="dramaData.Manufacturing_Field" class="chip-red">{{ dramaData.Manufacturing_Field }}</span>
<span v-else class="chip-empty"></span>
</div>
<p v-if="!dramaData.Manufacturing_Field" class="claim" @click="goToClaim('manufacturing')">我要认领</p>
</div>
</div>
</div>
<!-- 用户评论 -->
<div v-if="!loading" id="comments" class="block">
<p class="section-title-sm">抖音用户整体评论</p>
<div class="comment-row">
<img
src="https://oss.xintiao85.com/story/materials/image/6912ff82dc05328e25f9442c/1762926632_cfc62d9f.png"
alt="用户评论"
class="avatar"
/>
<div class="comment-text" v-if="dramaData.comments_summary" v-html="formatCommentsSummary(dramaData.comments_summary, 1.5)"></div>
<p class="comment-text comment-empty" v-else></p>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
* {
box-sizing: border-box;
}
.page {
display: flex;
flex-direction: column;
align-items: center;
background: #ebedf2;
padding: 14px;
min-height: 100vh;
}
.card {
display: flex;
flex-direction: column;
border-radius: 0;
overflow: hidden;
background: #f3f4f6;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08);
width: 100%;
max-width: 428px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
background: #ffffff;
padding: 12px 16px;
}
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
color: #111827;
background: none;
border: none;
cursor: pointer;
transition: opacity 0.2s;
}
.icon-button:hover {
opacity: 0.7;
}
.icon-button-sm {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 4px;
background: none;
border: none;
cursor: pointer;
transition: opacity 0.2s;
}
.icon-button-sm:hover {
opacity: 0.7;
}
.title-header {
color: #1f2937;
font-size: 16px;
font-weight: 600;
margin: 0;
}
.header-spacer {
width: 24px;
height: 24px;
}
.content {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
}
.block {
background: #ffffff;
border-radius: 12px;
padding: 20px;
}
.drama-info {
display: flex;
gap: 16px;
margin-bottom: 0;
}
.cover {
width: 84px;
height: 112px;
border-radius: 8px;
overflow: hidden;
background: #fce7f3;
flex-shrink: 0;
}
.cover-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.info {
flex: 1;
}
.title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 26px;
}
.title {
color: #111827;
font-size: 18px;
font-weight: 600;
margin: 0;
}
.small-text {
font-size: 12px;
color: #6b7280;
margin: 8px 0;
}
.description {
display: flex;
flex-direction: column;
margin-top: 16px;
}
.description-text {
color: #4b5563;
font-size: 14px;
margin-bottom: 6px;
line-height: 1.6;
}
.description-empty {
color: #9ca3af;
min-height: 20px;
}
.toggle-btn {
color: #3b82f6;
font-size: 12px;
background: transparent;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
transition: opacity 0.2s;
}
.toggle-btn:hover {
opacity: 0.7;
}
.section-title {
color: #111827;
font-size: 18px;
font-weight: 600;
margin: 0 0 16px 0;
padding-bottom: 12px;
border-bottom: 1px solid #e5e7eb;
}
.assoc-group {
margin-bottom: 16px;
}
.assoc-group:last-child {
margin-bottom: 0;
}
.assoc-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.label {
color: #374151;
font-size: 14px;
margin: 0 0 0 0;
}
.claim {
color: #ef4444;
font-size: 14px;
margin: 0;
cursor: pointer;
transition: opacity 0.2s;
}
.claim:hover {
opacity: 0.7;
}
.chip-blue {
display: inline-block;
padding: 6px 14px;
border-radius: 8px;
background: #eff6ff;
color: #2563eb;
font-size: 14px;
}
.chip-red {
display: inline-block;
padding: 6px 14px;
border-radius: 8px;
background: #fef2f2;
color: #dc2626;
font-size: 14px;
}
.chip-green {
display: inline-block;
padding: 6px 14px;
border-radius: 8px;
background: #ecfdf5;
color: #059669;
font-size: 14px;
}
.section-title-sm {
color: #1f2937;
font-size: 14px;
font-weight: 600;
margin: 0 0 12px 0;
padding: 16px 0 12px 0;
border-bottom: 1px solid #e5e7eb;
}
.qr-box {
display: flex;
align-items: center;
justify-content: center;
width: 128px;
height: 128px;
margin: 0 auto;
border: 1px solid #d1d5db;
border-radius: 8px;
overflow: hidden;
}
.qr-img {
width: 100%;
height: 100%;
object-fit: cover;
}
.comment-row {
display: flex;
gap: 12px;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
}
.comment-text {
font-size: 13px;
color: #4b5563;
margin: 0;
line-height: 2;
}
.comment-text strong {
font-weight: 600;
color: #1f2937;
}
.comment-empty {
color: #9ca3af;
font-style: italic;
}
.chip-empty {
display: inline-block;
padding: 4px 10px;
border-radius: 8px;
background: #f3f4f6;
color: #9ca3af;
font-size: 14px;
}
.description-label {
color: #374151;
font-size: 14px;
font-weight: 600;
margin: 0 0 8px 0;
}
/* 加载状态 */
.loading {
text-align: center;
padding: 40px 20px;
color: #666;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #f3f3f3;
border-top: 4px solid #4a90e2;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
/* 响应式设计 */
@media (max-width: 480px) {
.page {
padding: 0;
}
.card {
max-width: 100%;
border-radius: 0;
}
}
</style>

123
frontend/src/RootApp.vue Normal file
View File

@ -0,0 +1,123 @@
<script setup>
import { useRouter, useRoute } from 'vue-router'
const router = useRouter()
const route = useRoute()
const navigateToHome = () => {
router.push('/')
}
const navigateToAdmin = () => {
router.push('/admin')
}
</script>
<template>
<div class="root-app">
<!-- 导航栏 -->
<nav class="navigation">
<div class="nav-container">
<div class="nav-brand">
<h1>AI棒榜系统</h1>
</div>
<div class="nav-links">
<button
class="nav-btn"
:class="{ active: route.path === '/admin' }"
@click="navigateToAdmin"
>
后台管理
</button>
</div>
</div>
</nav>
<!-- 路由视图 -->
<main class="main-content">
<router-view />
</main>
</div>
</template>
<style scoped>
.root-app {
min-height: 100vh;
background: #ebedf2;
}
/* 导航栏样式 */
.navigation {
background: white;
border-bottom: 1px solid #e0e0e0;
position: sticky;
top: 0;
z-index: 100;
}
.nav-container {
max-width: 375px;
margin: 0 auto;
padding: 12px 16px;
display: flex;
justify-content: space-between;
align-items: center;
}
.nav-brand h1 {
font-size: 18px;
font-weight: bold;
color: #333;
margin: 0;
}
.nav-links {
display: flex;
gap: 8px;
}
.nav-btn {
padding: 8px 16px;
border: 1px solid #ddd;
background: white;
color: #666;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.3s ease;
}
.nav-btn:hover {
background: #f8f9fa;
border-color: #4a90e2;
}
.nav-btn.active {
background: #4a90e2;
color: white;
border-color: #4a90e2;
}
/* 主内容区域 */
.main-content {
min-height: calc(100vh - 60px);
}
/* 响应式设计 */
@media (max-width: 480px) {
.nav-container {
max-width: 100%;
padding: 10px 12px;
}
.nav-brand h1 {
font-size: 16px;
}
.nav-btn {
padding: 6px 12px;
font-size: 13px;
}
}
</style>

View File

Before

Width:  |  Height:  |  Size: 800 B

After

Width:  |  Height:  |  Size: 800 B

View File

@ -0,0 +1,6 @@
<svg width="290" height="66" viewBox="0 0 290 66" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.3992 1.73008C16.5867 2.9688 15.9301 4.38667 16.7811 5.57271C17.632 6.75875 19.2851 6.59838 21.0343 5.40299C23.286 3.86412 24.9356 0 24.9356 0C24.9356 0 20.651 0.191221 18.3992 1.73008ZM14.9137 6.68594C16.2193 3.95356 14.1804 0.273984 14.1804 0.273984C14.1804 0.273984 11.5757 2.38533 10.2701 5.11771C9.25569 7.24023 9.33794 9.02983 10.7422 9.66893C12.1465 10.308 13.8628 8.88539 14.9137 6.68594ZM9.77706 13.1231C10.1484 10.1722 7.06222 7.32456 7.06222 7.32456C7.06222 7.32456 5.26811 10.0782 4.89672 13.0291C4.60859 15.3214 5.25066 16.9762 6.77857 17.1592C8.30648 17.3423 9.47846 15.4985 9.77706 13.1231ZM16.526 7.59885C14.4268 8.38524 13.026 9.86311 13.5654 11.2335C14.1043 12.6039 15.7732 12.8406 17.7987 12.0817C20.4063 11.1047 22.4876 8.85733 22.4876 8.85733C22.4876 8.85733 19.1336 6.62191 16.526 7.59885ZM11.6423 13.933C9.84618 15.0714 8.85366 16.7381 9.65476 17.9421C10.4558 19.1461 12.0705 19.0613 13.8038 17.9627C16.0351 16.5484 17.4987 14.0229 17.4987 14.0229C17.4987 14.0229 13.8736 12.5187 11.6423 13.933ZM6.55659 20.2008C6.13935 17.3032 2.48931 15.3597 2.48931 15.3597C2.48931 15.3597 1.51374 18.4219 1.93099 21.3195C2.25501 23.5704 3.2904 24.9848 4.77893 24.7806C6.26696 24.5765 6.89258 22.5333 6.55659 20.2008ZM8.74969 20.6241C7.26315 22.025 6.65946 23.8039 7.6814 24.8366C8.70283 25.8693 10.2357 25.5188 11.6704 24.1669C13.5169 22.4265 14.3927 19.7378 14.3927 19.7378C14.3927 19.7378 10.5961 18.8838 8.74969 20.6241ZM5.25852 27.6435C4.1668 25.0061 0.168807 24.0448 0.168807 24.0448C0.168807 24.0448 -0.0500359 27.1714 1.04218 29.8088C1.89014 31.8575 3.22911 32.9455 4.62243 32.396C6.01574 31.8466 6.13738 29.7664 5.25852 27.6435ZM7.41883 27.9277C6.33608 29.5546 6.17158 31.3538 7.36948 32.1132C8.56788 32.8725 9.93278 32.2242 10.9776 30.6542C12.3226 28.6331 12.537 25.9212 12.537 25.9212C12.537 25.9212 8.76329 25.9066 7.41883 27.9277ZM7.80026 35.1654C7.14473 37.023 7.43436 38.8302 8.80175 39.2897C10.1686 39.7492 11.3471 38.7856 11.9797 36.9929C12.7938 34.6852 12.3247 31.9693 12.3247 31.9693C12.3247 31.9693 8.61432 32.8577 7.80026 35.1654ZM5.85591 35.3503C4.21633 32.9781 0 32.899 0 32.899C0 32.899 0.422231 36.0415 2.06181 38.4137C3.33548 40.2564 4.90477 41.0411 6.1929 40.1932C7.48103 39.3453 7.17594 37.2599 5.85591 35.3503ZM9.67774 42.2104C9.40057 44.1347 10.0681 45.8411 11.5451 46.0436C13.0222 46.2462 14.0132 45.0981 14.2804 43.2412C14.6249 40.8507 13.587 38.2925 13.587 38.2925C13.587 38.2925 10.0222 39.82 9.67774 42.2104ZM7.93324 42.841C5.87094 40.7565 1.75281 41.3053 1.75281 41.3053C1.75281 41.3053 2.78322 44.3267 4.84552 46.4112C6.44771 48.0305 8.12965 48.5674 9.21589 47.5438C10.3016 46.5203 9.59376 44.519 7.93324 42.841ZM13.1147 48.6075C13.2234 50.6383 14.2164 52.2634 15.7054 52.1872C17.195 52.111 17.9402 50.7404 17.835 48.7806C17.6994 46.2578 16.175 43.8286 16.175 43.8286C16.175 43.8286 12.9791 46.0847 13.1147 48.6075ZM11.5673 49.8541C9.19641 48.1728 5.27918 49.4091 5.27918 49.4091C5.27918 49.4091 6.80709 52.1796 9.17846 53.861C11.0204 55.1671 12.7522 55.4042 13.6326 54.2213C14.5129 53.0384 13.4761 51.2076 11.5673 49.8541ZM17.6489 54.5517C18.1878 56.5159 19.4664 57.9142 20.8428 57.5543C22.2191 57.1943 22.6209 55.7116 22.1005 53.8161C21.4305 51.3758 19.4814 49.2935 19.4814 49.2935C19.4814 49.2935 16.9789 52.1114 17.6489 54.5517ZM23.2189 59.297C24.1162 61.0923 25.6311 62.2002 26.9118 61.5905C28.1924 60.9808 28.3076 59.4718 27.4417 57.7393C26.3275 55.509 24.0269 53.8677 24.0269 53.8677C24.0269 53.8677 22.1042 57.0667 23.2189 59.297ZM16.3997 56.0787C13.7437 54.8698 10.1171 56.861 10.1171 56.861C10.1171 56.861 12.149 59.3163 14.8055 60.5252C16.8688 61.4643 18.621 61.3627 19.2646 60.0159C19.9077 58.6691 18.5383 57.0519 16.3997 56.0787ZM22.5159 61.134C19.7258 60.4551 16.6022 63.0888 16.6022 63.0888C16.6022 63.0888 19.0239 65.1045 21.8136 65.7834C23.9805 66.3108 25.6515 65.8796 26.0194 64.4409C26.3868 63.0023 24.7617 61.6804 22.5159 61.134Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M271.601 1.73009C273.413 2.96881 274.069 4.38667 273.219 5.57271C272.367 6.75876 270.715 6.59837 268.965 5.40298C266.714 3.86412 265.064 0 265.064 0C265.064 0 269.349 0.191223 271.601 1.73009ZM275.087 6.68594C273.781 3.95357 275.82 0.273987 275.82 0.273987C275.82 0.273987 278.425 2.38533 279.73 5.11771C280.744 7.24023 280.662 9.02983 279.258 9.66893C277.854 10.308 276.138 8.88539 275.087 6.68594ZM280.224 13.1231C279.852 10.1722 282.938 7.32457 282.938 7.32457C282.938 7.32457 284.732 10.0782 285.104 13.0291C285.392 15.3214 284.75 16.9762 283.222 17.1592C281.693 17.3423 280.521 15.4985 280.224 13.1231ZM273.474 7.59886C275.573 8.38525 276.974 9.86311 276.435 11.2335C275.895 12.6039 274.227 12.8406 272.201 12.0817C269.594 11.1047 267.513 8.85733 267.513 8.85733C267.513 8.85733 270.866 6.62192 273.474 7.59886ZM278.357 13.933C280.154 15.0714 281.146 16.7381 280.346 17.9421C279.544 19.1461 277.93 19.0613 276.196 17.9627C273.965 16.5484 272.501 14.0229 272.501 14.0229C272.501 14.0229 276.127 12.5187 278.357 13.933ZM283.443 20.2008C283.86 17.3032 287.511 15.3597 287.511 15.3597C287.511 15.3597 288.486 18.422 288.069 21.3195C287.745 23.5704 286.71 24.9848 285.222 24.7806C283.733 24.5765 283.107 22.5333 283.443 20.2008ZM281.25 20.6241C282.736 22.025 283.34 23.8039 282.318 24.8366C281.297 25.8693 279.764 25.5188 278.329 24.1669C276.482 22.4265 275.606 19.7378 275.606 19.7378C275.606 19.7378 279.403 18.8838 281.25 20.6241ZM284.742 27.6434C285.834 25.0061 289.832 24.0448 289.832 24.0448C289.832 24.0448 290.051 27.1714 288.958 29.8088C288.11 31.8575 286.771 32.9455 285.378 32.396C283.984 31.8466 283.863 29.7664 284.742 27.6434ZM282.581 27.9277C283.664 29.5546 283.828 31.3538 282.631 32.1132C281.432 32.8725 280.067 32.2242 279.022 30.6542C277.678 28.6331 277.463 25.9212 277.463 25.9212C277.463 25.9212 281.236 25.9066 282.581 27.9277ZM282.199 35.1654C282.855 37.023 282.565 38.8302 281.198 39.2897C279.831 39.7492 278.652 38.7856 278.02 36.993C277.206 34.6852 277.675 31.9693 277.675 31.9693C277.675 31.9693 281.386 32.8577 282.199 35.1654ZM284.145 35.3503C285.783 32.9781 290 32.899 290 32.899C290 32.899 289.578 36.0415 287.938 38.4137C286.664 40.2564 285.096 41.0411 283.807 40.1932C282.519 39.3453 282.824 37.2599 284.145 35.3503ZM280.322 42.2104C280.6 44.1347 279.933 45.8411 278.455 46.0437C276.978 46.2462 275.987 45.0981 275.72 43.2412C275.375 40.8507 276.413 38.2925 276.413 38.2925C276.413 38.2925 279.978 39.82 280.322 42.2104ZM282.066 42.841C284.129 40.7565 288.247 41.3053 288.247 41.3053C288.247 41.3053 287.217 44.3267 285.154 46.4112C283.552 48.0305 281.87 48.5674 280.784 47.5439C279.698 46.5203 280.406 44.519 282.066 42.841ZM276.886 48.6075C276.777 50.6383 275.784 52.2634 274.295 52.1872C272.806 52.111 272.061 50.7404 272.165 48.7806C272.301 46.2578 273.825 43.8286 273.825 43.8286C273.825 43.8286 277.021 46.0847 276.886 48.6075ZM278.433 49.8541C280.804 48.1727 284.721 49.4091 284.721 49.4091C284.721 49.4091 283.192 52.1796 280.821 53.861C278.979 55.1671 277.248 55.4042 276.367 54.2213C275.487 53.0384 276.523 51.2076 278.433 49.8541ZM272.351 54.5517C271.812 56.5159 270.533 57.9142 269.157 57.5543C267.78 57.1943 267.379 55.7116 267.899 53.8161C268.569 51.3758 270.519 49.2935 270.519 49.2935C270.519 49.2935 273.02 52.1114 272.351 54.5517ZM266.781 59.297C265.884 61.0923 264.369 62.2002 263.089 61.5905C261.808 60.9808 261.692 59.4718 262.559 57.7393C263.673 55.509 265.974 53.8677 265.974 53.8677C265.974 53.8677 267.896 57.0667 266.781 59.297ZM273.601 56.0788C276.256 54.8698 279.883 56.861 279.883 56.861C279.883 56.861 277.851 59.3163 275.194 60.5252C273.131 61.4643 271.379 61.3627 270.735 60.0159C270.092 58.6691 271.462 57.0519 273.601 56.0788ZM267.483 61.134C270.273 60.4551 273.397 63.0888 273.397 63.0888C273.397 63.0888 270.976 65.1045 268.187 65.7834C266.02 66.3108 264.349 65.8796 263.98 64.4409C263.613 63.0023 265.238 61.6805 267.483 61.134Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M99.9861 61L79.8724 7H68.2684L48 61H56.9738L62.389 45.9049H85.4424L90.7802 61H99.9861ZM73.529 14.9366H74.4573L82.8122 38.3574H65.0967L73.529 14.9366ZM116 61V7H107.336V61H116Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M171.512 42.08C174.136 44.832 176.824 47.2 179.64 49.056L183.096 43.616C178.424 40.928 174.584 37.472 171.704 33.184H180.088V27.488H159.8C160.056 26.848 160.376 25.824 160.824 24.48H177.016V18.912H162.168C162.424 17.888 162.616 16.864 162.744 15.776H178.552V10.08H163.576L164.216 4.704L157.88 4.512C157.816 5.344 157.624 7.2 157.24 10.08H144.696V15.776H156.408C156.216 17.12 155.96 18.144 155.768 18.912H145.656V24.48H154.232C153.912 25.568 153.528 26.592 153.08 27.488H143.736V31.456C142.264 29.664 140.984 28.192 139.768 26.976L138.424 28.32V22.112H144.312V15.84H138.424V4.96L132.472 4.704V15.84H126.136V22.112H131.96C130.872 25.632 129.464 29.344 127.736 33.184C126.072 37.024 124.472 40.224 123 42.848L127.096 47.84C129.08 44.256 130.872 40.48 132.472 36.512V62.24H138.424V33.632C139.768 35.232 140.984 36.896 142.008 38.496L145.784 34.144L145.08 33.184H150.072C147.576 36.96 143.992 40.736 139.32 44.384L143.352 49.44C146.232 47.136 148.664 44.832 150.712 42.592V45.6H158.136V48.928H144.312V54.816H158.136V63.072H164.344V54.816H177.528V48.928H164.344V45.6H171.512V42.08ZM153.08 39.712C154.68 37.664 156.088 35.488 157.304 33.184H165.112C166.328 35.424 167.736 37.6 169.464 39.712H164.344V35.104L158.136 34.784V39.712H153.08ZM237.688 27.808V31.968H243.96V22.176H236.6L237.816 15.904H243.96V9.888H229.88C229.048 7.264 228.408 5.344 227.896 4L221.112 4.896C221.816 6.944 222.328 8.608 222.648 9.888H208.76V15.904H214.904C215.16 17.504 215.48 19.552 215.8 22.176H208.248V31.968H214.52V27.808H237.688ZM208.184 32.864C206.584 30.496 205.048 28.576 203.512 27.104L201.592 29.024V22.432H207.16V16.032H201.592V5.088L195.384 4.832V16.032H188.984V22.432H195.192C194.104 25.76 192.696 29.152 191.096 32.672C189.496 36.192 188.088 38.88 186.872 40.864L190.968 46.624C192.44 44 193.912 40.8 195.384 37.088V62.56H201.592V33.696C202.552 34.912 203.448 36.128 204.28 37.472L208.184 32.864ZM231.416 15.904C231.224 17.504 230.84 19.552 230.264 22.176H222.008C221.688 19.808 221.432 17.696 221.112 15.904H231.416ZM244.984 33.824H230.072C229.304 31.328 228.728 29.472 228.28 28.384L221.944 29.28C222.776 32.032 223.288 33.504 223.352 33.824H207.864V39.84H215.416C215.16 47.648 213.496 53.536 204.088 58.464L207.672 64.224C214.776 60.128 218.424 55.008 220.28 49.12H233.336L232.76 53.088C232.44 55.2 231.864 55.648 229.112 55.904L225.08 56.288L227 62.496L230.84 62.112C237.176 61.472 238.584 60.448 239.352 54.816L239.864 48.416L240.248 43.104H221.56C221.624 42.4 221.752 41.312 221.816 39.84H244.984V33.824Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

View File

@ -0,0 +1,3 @@
<svg width="23" height="22" viewBox="0 0 23 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M0 7.6475C0 8.16531 0 14.2805 0 14.2805C0 16.59 1.5961 18.2743 2.9357 19.2803C4.49022 20.4491 6.05075 21.0036 6.34432 21.0597C6.6374 21.0036 8.19793 20.4491 9.75294 19.2803C11.092 18.2743 12.6881 16.59 12.6881 14.2805V13.7094C12.0797 13.3309 11.5066 12.8983 10.9758 12.4169C9.98786 13.0481 8.71463 13.012 7.76407 12.3257C7.56166 12.1719 7.51867 11.8848 7.66715 11.6785C7.81563 11.4721 8.1015 11.4217 8.31164 11.5647C8.89103 11.9827 9.65433 12.0452 10.294 11.727C10.1828 11.6028 10.0761 11.4765 9.97387 11.3483C9.4759 10.7211 9.10268 10.0648 8.85671 9.38445L8.6007 8.44662L8.58267 8.34643L8.58067 8.33541C8.57466 8.29922 8.56899 8.26299 8.56363 8.22671L8.56163 8.21319L8.54661 8.10548L8.5451 8.08944C8.5401 8.05437 8.53608 8.0183 8.53258 7.98274L8.53157 7.97272C8.52026 7.86458 8.5119 7.75616 8.50653 7.64757C8.50653 7.63454 8.50552 7.62202 8.50502 7.60899L8.50302 7.53885L8.50101 7.48124C6.62838 7.62753 4.55635 7.57593 2.85755 7.33295C1.58909 7.1511 1.57376 7.10999 1.07139 6.96036C0.518555 6.79431 0 7.12969 0 7.6475ZM6.35453 15.4708H6.33449C5.40904 15.4719 4.60439 16.1058 4.38672 17.0053H8.3023C8.08447 16.1059 7.27991 15.4721 6.35453 15.4708H6.35453ZM21.0654 0.431909C19.7504 0.192045 18.9529 -0.000125216 15.781 2.63279e-08C12.6091 0.000125268 11.8126 0.236747 10.5555 0.450036C9.8584 0.572662 9.4375 1.21924 9.4375 2C9.4375 3.93591 9.4375 7.35551 9.4375 7.35551C9.4375 7.41963 9.439 7.48375 9.44151 7.54688L9.44301 7.58796C9.44602 7.64908 9.45002 7.7097 9.45503 7.76981L9.45854 7.80638C9.46305 7.85848 9.46856 7.91009 9.47507 7.96119L9.48008 8.00126C9.4871 8.05637 9.49561 8.11198 9.50513 8.16709L9.51515 8.2247C9.52763 8.29497 9.54166 8.36497 9.55723 8.43462L9.58328 8.54483L9.60332 8.62248C9.62771 8.71476 9.65478 8.80632 9.68448 8.89712C10.3478 10.9279 12.2014 12.3251 13.3987 13.0456L13.4072 13.0516C14.5645 13.748 15.5539 14.0911 15.7813 14.1347C16.0744 14.0786 17.6348 13.524 19.1905 12.3557C20.5291 11.3492 22.1251 9.66562 22.1251 7.35552C22.1251 7.35552 22.1248 2.85222 22.1249 2.00018C22.125 1.14813 21.6113 0.536591 21.0654 0.431909ZM1.47105 11.6714C1.62246 11.4615 1.91529 11.414 2.12531 11.5652C2.79752 12.0504 3.70497 12.0504 4.37717 11.5652C4.58732 11.4222 4.87319 11.4726 5.02166 11.679C5.17014 11.8853 5.12715 12.1724 4.92474 12.3262C3.9256 13.0473 2.57689 13.0473 1.57775 12.3262C1.3677 12.1749 1.31993 11.8821 1.47105 11.6719V11.6714ZM17.7388 9.01526H13.8232C14.0411 9.91465 14.8456 10.5485 15.771 10.5497H15.7911C16.7165 10.5487 17.5212 9.91475 17.7388 9.01526L17.7388 9.01526ZM17.2011 4.6227C18.2003 3.90213 19.5485 3.90213 20.5476 4.6227C20.691 4.7176 20.7709 4.88334 20.7559 5.05461C20.7409 5.22588 20.6334 5.37521 20.4757 5.44375C20.3181 5.51229 20.1355 5.48905 20.0001 5.38318C19.328 4.89819 18.4208 4.89819 17.7487 5.38318C17.613 5.4863 17.432 5.50766 17.276 5.43897C17.1201 5.37028 17.0136 5.22237 16.9981 5.05264C16.9825 4.88292 17.0602 4.71811 17.2011 4.6222L17.2011 4.6227ZM11.0141 4.6227C12.0133 3.90189 13.3618 3.90189 14.3611 4.6227C14.5044 4.7176 14.5844 4.88334 14.5694 5.05461C14.5544 5.22588 14.4468 5.37521 14.2892 5.44374C14.1315 5.51228 13.949 5.48904 13.8135 5.38318C13.1414 4.89819 12.2342 4.89819 11.5621 5.38318C11.4263 5.48256 11.2475 5.50148 11.0939 5.43274C10.9402 5.36401 10.8352 5.21817 10.8187 5.05063C10.8022 4.8831 10.8768 4.71958 11.0141 4.6222L11.0141 4.6227Z" fill="#AFB1B7"/>
</svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@ -0,0 +1,3 @@
<svg width="20" height="18" viewBox="0 0 20 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.0559 0H0.945609C0.423363 0 0 0.447715 0 1V17C0 17.552 0.423633 18 0.945609 18H19.0559C19.5764 18 20 17.552 20 17V1C19.9992 0.448095 19.5763 0.000888824 19.0544 0H19.0559ZM4.50451 16H1.8916V13.9968H4.51359V11.9968H1.8916V9.9968H4.51359V7.9968H1.8916V5.9968H4.51359V3.9968H1.8916V2H4.50451V16V16ZM18.1093 3.99519L16.0637 3.99519V5.99519L18.1093 5.99519V7.99519L16.0637 7.99519V9.99519L18.1093 9.99519V11.9952H16.0637V13.9952L18.1093 13.9952V15.9968H16.041V1.9968H18.1093V3.9952V3.99519Z" fill="#AFB1B7"/>
</svg>

After

Width:  |  Height:  |  Size: 659 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

Before

Width:  |  Height:  |  Size: 563 B

After

Width:  |  Height:  |  Size: 563 B

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 447 B

After

Width:  |  Height:  |  Size: 447 B

View File

@ -0,0 +1,3 @@
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 0C17.072 0 22 4.928 22 11C22 17.072 17.072 22 11 22C4.928 22 0 17.072 0 11C0 4.928 4.928 0 11 0V0ZM11 5.5C7.964 5.5 5.5 7.964 5.5 11C5.50341 13.4706 7.15302 15.636 9.53393 16.2952C11.9148 16.9545 14.4433 15.946 15.7169 13.8291L13.8304 12.6983C13.0668 13.97 11.5486 14.5759 10.1193 14.1795C8.68994 13.7831 7.70072 12.4817 7.7012 10.9985C7.70168 9.51517 8.69175 8.21446 10.1213 7.81898C11.5509 7.4235 13.0687 8.03044 13.8315 9.3026L15.7169 8.1696C14.7228 6.51286 12.9321 5.49941 11 5.5H11Z" fill="#AFB1B7"/>
</svg>

After

Width:  |  Height:  |  Size: 662 B

View File

@ -1,4 +1,8 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import './style.css'
import App from './App.vue' import App from './App.vue'
import router from './router'
createApp(App).mount('#app') const app = createApp(App)
app.use(router).mount('#app')

View File

@ -0,0 +1,35 @@
import { createRouter, createWebHistory } from 'vue-router'
import AdminPanel from '../AdminPanel.vue'
import DramaDetail from '../DramaDetail.vue'
import ClaimPage from '../ClaimPage.vue'
import ClaimApplications from '../ClaimApplications.vue'
const routes = [
{
path: '/admin',
name: 'Admin',
component: AdminPanel
},
{
path: '/admin/claim-applications',
name: 'ClaimApplications',
component: ClaimApplications
},
{
path: '/drama/:id',
name: 'DramaDetail',
component: DramaDetail
},
{
path: '/claim/:id',
name: 'ClaimPage',
component: ClaimPage
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
export default router

60
frontend/src/style.css Normal file
View File

@ -0,0 +1,60 @@
/* 全局样式重置 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #ebedf2;
color: #333;
}
#app {
width: 100%;
min-height: 100vh;
}
/* 通用按钮样式 */
button {
cursor: pointer;
border: none;
outline: none;
font-family: inherit;
}
/* 通用输入框样式 */
input, textarea, select {
font-family: inherit;
outline: none;
}
/* 滚动条样式 */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #f1f1f1;
}
::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
/* 响应式设计 */
@media (max-width: 480px) {
body {
font-size: 14px;
}
}

View File

@ -13,4 +13,14 @@ export default defineConfig({
'@': fileURLToPath(new URL('./src', import.meta.url)) '@': fileURLToPath(new URL('./src', import.meta.url))
}, },
}, },
server: {
port: 5174,
proxy: {
'/api': {
target: 'http://159.75.150.210:8443',
changeOrigin: true,
secure: false
}
}
}
}) })