This commit is contained in:
陈思海 2026-02-02 20:51:52 +08:00
parent 23724aeba6
commit f7d03bd602
76 changed files with 6686 additions and 377 deletions

View File

@ -0,0 +1,29 @@
{
"permissions": {
"allow": [
"Bash(npm install)",
"Bash(node database/init.js)",
"Bash(timeout 3 cmd /c \"type C:\\\\Users\\\\admin\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\G--website\\\\tasks\\\\b65f3f2.output\")",
"Bash(timeout 3 cmd /c \"type C:\\\\Users\\\\admin\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\G--website\\\\tasks\\\\b04f030.output\")",
"Bash(netstat -ano)",
"Bash(findstr :3000)",
"Bash(findstr LISTENING)",
"Bash(taskkill /F /PID 32)",
"Bash(cmd /c \"taskkill /F /PID 32\")",
"Bash(tasklist)",
"Bash(findstr \" 32 \")",
"Bash(powershell -Command \"Stop-Process -Id 32 -Force\")",
"Bash(node verify-password.js)",
"Bash(node update-admin-password.js)",
"Bash(node add-videos-tables.js)",
"Bash(npm install tencentcloud-sdk-nodejs --save)",
"Bash(npm install cos-nodejs-sdk-v5 --save)",
"Bash(node -e \"const mysql = require\\(''mysql2/promise''\\); const fs = require\\(''fs''\\); require\\(''dotenv''\\).config\\(\\); \\(async \\(\\) => { const conn = await mysql.createConnection\\({ host: process.env.DB_HOST || ''localhost'', user: process.env.DB_USER || ''root'', password: process.env.DB_PASSWORD || ''1464576565..'', database: ''website_admin'', port: process.env.DB_PORT || 3306 }\\); const sql = fs.readFileSync\\(''database/add-cos-config.sql'', ''utf8''\\); await conn.query\\(sql\\); console.log\\(''✓ COS配置字段添加成功''\\); await conn.end\\(\\); }\\)\\(\\)\")",
"WebSearch",
"WebFetch(domain:cloud.tencent.com)",
"Bash(npm search vod-js-sdk)",
"Bash(npm view vod-js-sdk-v6 version)",
"Bash(node backend/database/fix-videos-simple.js)"
]
}
}

12
backend/.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
node_modules/
.env
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.DS_Store
.vscode/
.idea/
*.swp
*.swo
*~

View File

@ -0,0 +1,25 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
const pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '1464576565..',
database: process.env.DB_NAME || 'website_admin',
port: process.env.DB_PORT || 3306,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
// Test connection
pool.getConnection()
.then(connection => {
console.log('✓ Database connected successfully');
connection.release();
})
.catch(err => {
console.error('✗ Database connection failed:', err.message);
});
module.exports = pool;

View File

@ -0,0 +1,136 @@
-- ========================================
-- 虚境起源网站管理后台数据库初始化脚本
-- ========================================
-- 创建日期: 2026-02-02
-- 说明: 包含所有必需的数据库、表和初始数据
-- ========================================
-- 创建数据库
CREATE DATABASE IF NOT EXISTS website_admin CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE website_admin;
-- ========================================
-- 1. 管理员用户表
-- ========================================
CREATE TABLE IF NOT EXISTS admin_users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password VARCHAR(255) NOT NULL,
email VARCHAR(100),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
last_login TIMESTAMP NULL,
INDEX idx_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ========================================
-- 2. 合作伙伴表
-- ========================================
CREATE TABLE IF NOT EXISTS partners (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
logo VARCHAR(255) NOT NULL,
url VARCHAR(255),
sort_order INT DEFAULT 0,
status TINYINT DEFAULT 1 COMMENT '1=active, 0=inactive',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status (status),
INDEX idx_sort_order (sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ========================================
-- 3. 系统设置表
-- ========================================
CREATE TABLE IF NOT EXISTS system_settings (
id INT AUTO_INCREMENT PRIMARY KEY,
setting_key VARCHAR(100) UNIQUE NOT NULL,
setting_value TEXT,
description VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_setting_key (setting_key)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ========================================
-- 4. 视频表
-- ========================================
CREATE TABLE IF NOT EXISTS videos (
id INT AUTO_INCREMENT PRIMARY KEY,
file_id VARCHAR(100) UNIQUE COMMENT '腾讯云VOD文件ID',
title_cn VARCHAR(200) NOT NULL COMMENT '中文标题',
title_en VARCHAR(200) NOT NULL COMMENT '英文标题',
desc_cn TEXT COMMENT '中文描述',
desc_en TEXT COMMENT '英文描述',
cover_url VARCHAR(500) COMMENT '封面图URL',
video_url VARCHAR(500) COMMENT '视频播放URL',
duration INT COMMENT '视频时长(秒)',
file_size BIGINT COMMENT '文件大小(字节)',
status TINYINT DEFAULT 1 COMMENT '1=active, 0=inactive, 2=processing',
sort_order INT DEFAULT 0,
upload_status VARCHAR(50) DEFAULT 'pending' COMMENT 'pending, uploading, success, failed',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_status (status),
INDEX idx_sort_order (sort_order),
INDEX idx_file_id (file_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ========================================
-- 5. VOD配置表包含COS和防盗链配置
-- ========================================
CREATE TABLE IF NOT EXISTS vod_config (
id INT AUTO_INCREMENT PRIMARY KEY,
secret_id VARCHAR(255) NOT NULL COMMENT '腾讯云SecretId',
secret_key VARCHAR(255) NOT NULL COMMENT '腾讯云SecretKey',
region VARCHAR(50) DEFAULT 'ap-guangzhou' COMMENT '地域',
sub_app_id VARCHAR(50) COMMENT '点播子应用ID',
`procedure` VARCHAR(100) COMMENT '任务流',
storage_region VARCHAR(50) COMMENT '存储地域',
cos_bucket VARCHAR(100) COMMENT 'COS存储桶名称',
cos_region VARCHAR(50) DEFAULT 'ap-guangzhou' COMMENT 'COS地域',
cos_path VARCHAR(100) DEFAULT 'video-covers/' COMMENT 'COS存储路径前缀',
sign_key VARCHAR(100) COMMENT 'VOD防盗链签名密钥',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- ========================================
-- 初始数据插入
-- ========================================
-- 插入默认管理员账户
-- 用户名: admin
-- 密码: admin123456 (使用bcryptjs加密10轮salt)
INSERT INTO admin_users (username, password, email)
VALUES ('admin', '$2a$10$PGOF6JXp3CGjg.5evfJlG.oeOr/JvxYECbLxn2ypzsRKcT7UK5.YK', 'admin@xuking.com')
ON DUPLICATE KEY UPDATE username=username;
-- 插入默认存储配置
INSERT INTO system_settings (setting_key, setting_value, description)
VALUES
('logo_upload_dir', 'img/logo', 'Directory for partner logo uploads'),
('logo_max_size', '5242880', 'Maximum logo file size in bytes (5MB)'),
('allowed_extensions', 'jpg,jpeg,png,gif,svg', 'Allowed file extensions for logo upload')
ON DUPLICATE KEY UPDATE setting_key=setting_key;
-- 插入现有合作伙伴数据
INSERT INTO partners (name, logo, url, sort_order, status) VALUES
('vivo', 'img/logo/vivo.png', '', 1, 1),
('BOE', 'img/logo/boe.png', '', 2, 1),
('北京大运河博物馆-首都博物馆东馆', 'img/logo/bwg.png', '', 3, 1),
('江西日报', 'img/logo/jxrb.png', '', 4, 1),
('丽江市文化和旅游局', 'img/logo/ljs.png', '', 5, 1),
('Meta', 'img/logo/meta.png', '', 6, 1),
('中国文化传媒', 'img/logo/zgwhcm.png', '', 7, 1)
ON DUPLICATE KEY UPDATE name=VALUES(name);
-- 插入默认VOD配置空配置需要后台填写
INSERT INTO vod_config (secret_id, secret_key, region)
VALUES ('', '', 'ap-guangzhou')
ON DUPLICATE KEY UPDATE id=id;
-- ========================================
-- 初始化完成
-- ========================================

125
backend/database/setup.js Normal file
View File

@ -0,0 +1,125 @@
/**
* 数据库初始化脚本
*
* 功能
* - 创建 website_admin 数据库
* - 创建所有必需的表admin_users, partners, system_settings, videos, vod_config
* - 插入初始数据管理员账户系统配置合作伙伴数据
*
* 使用方法
* node backend/database/setup.js
*
* 注意
* - 请确保 .env 文件中配置了正确的数据库连接信息
* - 默认管理员账户admin / admin123456
*/
const mysql = require('mysql2/promise');
const fs = require('fs').promises;
const path = require('path');
require('dotenv').config();
async function setupDatabase() {
let connection;
try {
console.log('========================================');
console.log(' 数据库初始化程序');
console.log(' 虚境起源网站管理后台');
console.log('========================================\n');
console.log('正在连接到 MySQL 服务器...');
// 首先连接到 MySQL 服务器(不指定数据库)
connection = await mysql.createConnection({
host: process.env.DB_HOST || 'localhost',
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '1464576565..',
port: process.env.DB_PORT || 3306,
multipleStatements: true
});
console.log('✓ 已连接到 MySQL 服务器\n');
console.log('正在执行数据库初始化脚本...');
// 读取 SQL 文件
const sqlFile = path.join(__dirname, 'database.sql');
const sql = await fs.readFile(sqlFile, 'utf8');
// 分割 SQL 语句并执行
const statements = sql
.split(';')
.map(stmt => stmt.trim())
.filter(stmt => stmt.length > 0 && !stmt.startsWith('--'));
let successCount = 0;
let skipCount = 0;
for (const statement of statements) {
try {
await connection.query(statement);
successCount++;
} catch (err) {
// 忽略重复条目错误和已存在错误
if (err.message.includes('Duplicate entry') ||
err.message.includes('already exists') ||
err.code === 'ER_DUP_ENTRY') {
skipCount++;
} else {
console.error('执行语句时出错:', err.message);
console.error('语句:', statement.substring(0, 100) + '...');
}
}
}
console.log(`✓ 成功执行 ${successCount} 条语句`);
if (skipCount > 0) {
console.log(` 跳过 ${skipCount} 条已存在的记录`);
}
console.log('\n========================================');
console.log(' 初始化完成');
console.log('========================================\n');
console.log('数据库信息:');
console.log(` 数据库名称website_admin`);
console.log(` 主机:${process.env.DB_HOST || 'localhost'}`);
console.log(` 端口:${process.env.DB_PORT || 3306}\n`);
console.log('已创建的表:');
console.log(' ✓ admin_users - 管理员用户表');
console.log(' ✓ partners - 合作伙伴表');
console.log(' ✓ system_settings - 系统设置表');
console.log(' ✓ videos - 视频表');
console.log(' ✓ vod_config - VOD配置表\n');
console.log('默认管理员账户:');
console.log(' 用户名admin');
console.log(' 密码admin123456');
console.log(' 提示:请登录后尽快修改密码\n');
console.log('下一步:');
console.log(' 1. 启动后端服务cd backend && npm start');
console.log(' 2. 访问管理后台http://localhost:3000');
console.log(' 3. 使用默认账户登录');
console.log(' 4. 在"系统设置"中配置 VOD 和 COS 参数\n');
await connection.end();
process.exit(0);
} catch (error) {
console.error('\n✗ 数据库初始化失败:', error.message);
console.error('\n请检查');
console.error(' 1. MySQL 服务是否正在运行');
console.error(' 2. .env 文件中的数据库连接信息是否正确');
console.error(' 3. 数据库用户是否有足够的权限创建数据库和表\n');
if (connection) {
await connection.end();
}
process.exit(1);
}
}
// 运行初始化
setupDatabase();

View File

@ -0,0 +1,23 @@
// Authentication middleware
const requireAuth = (req, res, next) => {
if (req.session && req.session.userId) {
return next();
}
return res.status(401).json({
success: false,
message: 'Unauthorized. Please login first.'
});
};
// Check if already logged in
const redirectIfAuthenticated = (req, res, next) => {
if (req.session && req.session.userId) {
return res.redirect('/admin/dashboard');
}
next();
};
module.exports = {
requireAuth,
redirectIfAuthenticated
};

2386
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

29
backend/package.json Normal file
View File

@ -0,0 +1,29 @@
{
"name": "website-admin-backend",
"version": "1.0.0",
"description": "Admin backend for website management",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"bcryptjs": "^2.4.3",
"cors": "^2.8.5",
"cos-nodejs-sdk-v5": "^2.15.4",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"express-session": "^1.17.3",
"form-data": "^4.0.0",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.6.5",
"node-fetch": "^2.7.0",
"tencentcloud-sdk-nodejs": "^4.1.180"
},
"devDependencies": {
"nodemon": "^3.0.2"
}
}

View File

@ -0,0 +1,875 @@
/* Reset & Base Styles */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--primary-color: #4F46E5;
--primary-dark: #4338CA;
--secondary-color: #6B7280;
--success-color: #10B981;
--danger-color: #EF4444;
--warning-color: #F59E0B;
--bg-primary: #F9FAFB;
--bg-secondary: #FFFFFF;
--text-primary: #111827;
--text-secondary: #6B7280;
--border-color: #E5E7EB;
--sidebar-width: 280px;
--header-height: 70px;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
line-height: 1.6;
}
/* Login Page */
.login-page {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.login-container {
background: white;
padding: 40px;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
width: 100%;
max-width: 420px;
animation: slideUp 0.5s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(30px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-header i {
font-size: 48px;
color: var(--primary-color);
margin-bottom: 16px;
}
.login-header h1 {
font-size: 28px;
color: var(--text-primary);
margin-bottom: 8px;
}
.login-header p {
color: var(--text-secondary);
font-size: 14px;
}
.login-form .form-group {
margin-bottom: 20px;
}
.login-form label {
display: block;
margin-bottom: 8px;
color: var(--text-primary);
font-weight: 500;
font-size: 14px;
}
.login-form label i {
margin-right: 8px;
color: var(--text-secondary);
}
.login-form input {
width: 100%;
padding: 12px 16px;
border: 2px solid var(--border-color);
border-radius: 8px;
font-size: 14px;
transition: all 0.3s ease;
}
.login-form input:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
.error-message {
background-color: #FEE2E2;
color: #991B1B;
padding: 12px;
border-radius: 8px;
margin-bottom: 16px;
font-size: 14px;
border-left: 4px solid var(--danger-color);
}
/* Admin Dashboard Layout */
.admin-dashboard {
display: flex;
min-height: 100vh;
}
/* Sidebar */
.sidebar {
width: var(--sidebar-width);
background-color: #1F2937;
color: white;
display: flex;
flex-direction: column;
position: fixed;
height: 100vh;
overflow-y: auto;
}
.sidebar-header {
padding: 24px;
display: flex;
align-items: center;
gap: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
.sidebar-header i {
font-size: 32px;
color: var(--primary-color);
}
.sidebar-header h2 {
font-size: 20px;
font-weight: 600;
}
.sidebar-nav {
flex: 1;
padding: 16px;
}
.nav-section {
margin-bottom: 24px;
}
.nav-section-title {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
color: #9CA3AF;
margin-bottom: 4px;
}
.nav-link {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
color: #D1D5DB;
text-decoration: none;
border-radius: 8px;
transition: all 0.2s ease;
margin-bottom: 4px;
}
.nav-link:hover {
background-color: rgba(255, 255, 255, 0.1);
color: white;
}
.nav-link.active {
background-color: var(--primary-color);
color: white;
}
.nav-link i {
width: 20px;
text-align: center;
}
.sidebar-footer {
padding: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
/* Main Content */
.main-content {
flex: 1;
margin-left: var(--sidebar-width);
display: flex;
flex-direction: column;
}
.content-header {
height: var(--header-height);
background-color: white;
padding: 0 32px;
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid var(--border-color);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.content-header h1 {
font-size: 24px;
font-weight: 600;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-secondary);
}
.user-info i {
font-size: 24px;
}
.content-body {
flex: 1;
padding: 32px;
overflow-y: auto;
}
/* Card */
.card {
background-color: white;
border-radius: 12px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
margin-bottom: 24px;
}
.card-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header h3 {
font-size: 18px;
font-weight: 600;
}
.card-body {
padding: 24px;
}
/* Form Styles */
.form-group {
margin-bottom: 20px;
}
.form-group label {
display: block;
margin-bottom: 8px;
color: var(--text-primary);
font-weight: 500;
font-size: 14px;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 14px;
transition: all 0.2s ease;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}
.form-group small {
display: block;
margin-top: 6px;
color: var(--text-secondary);
font-size: 12px;
}
.input-with-status {
position: relative;
}
.status-indicators {
display: flex;
gap: 16px;
margin-top: 8px;
}
.status-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--text-secondary);
}
.status-indicator.success {
color: var(--success-color);
}
.status-indicator.error {
color: var(--danger-color);
}
.status-indicator i {
font-size: 10px;
}
.form-actions {
display: flex;
gap: 12px;
justify-content: flex-end;
margin-top: 24px;
}
/* Button Styles */
.btn {
padding: 10px 20px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
gap: 8px;
}
.btn-primary {
background-color: var(--primary-color);
color: white;
}
.btn-primary:hover {
background-color: var(--primary-dark);
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(79, 70, 229, 0.4);
}
.btn-secondary {
background-color: var(--secondary-color);
color: white;
}
.btn-secondary:hover {
background-color: #4B5563;
}
.btn-danger {
background-color: var(--danger-color);
color: white;
}
.btn-danger:hover {
background-color: #DC2626;
}
.btn-block {
width: 100%;
justify-content: center;
}
.btn-sm {
padding: 6px 12px;
font-size: 12px;
}
/* Table Styles */
.table-responsive {
overflow-x: auto;
}
.data-table {
width: 100%;
border-collapse: collapse;
}
.data-table thead {
background-color: var(--bg-primary);
}
.data-table th {
padding: 12px 16px;
text-align: left;
font-weight: 600;
font-size: 13px;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.data-table td {
padding: 16px;
border-bottom: 1px solid var(--border-color);
}
.data-table tbody tr:hover {
background-color: var(--bg-primary);
}
.data-table img {
max-width: 100px;
max-height: 50px;
object-fit: contain;
border-radius: 4px;
}
.text-center {
text-align: center;
}
.text-muted {
color: var(--text-secondary);
}
/* Badge */
.badge {
display: inline-block;
padding: 4px 12px;
border-radius: 12px;
font-size: 12px;
font-weight: 500;
}
.badge-success {
background-color: #D1FAE5;
color: #065F46;
}
.badge-danger {
background-color: #FEE2E2;
color: #991B1B;
}
/* Modal */
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
align-items: center;
justify-content: center;
animation: fadeIn 0.2s ease;
}
.modal.show {
display: flex;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal-content {
background-color: white;
border-radius: 12px;
width: 90%;
max-width: 600px;
max-height: 90vh;
overflow-y: auto;
animation: slideDown 0.3s ease;
}
@keyframes slideDown {
from {
transform: translateY(-50px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-header h3 {
font-size: 20px;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
font-size: 28px;
cursor: pointer;
color: var(--text-secondary);
padding: 0;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
}
.modal-close:hover {
background-color: var(--bg-primary);
}
.modal-body {
padding: 24px;
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--border-color);
display: flex;
gap: 12px;
justify-content: flex-end;
}
/* Toast Notification */
.toast {
position: fixed;
top: 24px;
right: 24px;
padding: 16px 24px;
background-color: white;
border-radius: 8px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
z-index: 2000;
display: none;
min-width: 300px;
animation: slideInRight 0.3s ease;
}
@keyframes slideInRight {
from {
transform: translateX(400px);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.toast.show {
display: block;
}
.toast.success {
border-left: 4px solid var(--success-color);
}
.toast.error {
border-left: 4px solid var(--danger-color);
}
.toast.warning {
border-left: 4px solid var(--warning-color);
}
/* Responsive Design */
@media (max-width: 768px) {
.sidebar {
width: 100%;
position: relative;
height: auto;
}
.main-content {
margin-left: 0;
}
.content-header {
padding: 0 16px;
}
.content-body {
padding: 16px;
}
.card-body {
padding: 16px;
}
}
/* Action Buttons in Table */
.action-buttons {
display: flex;
gap: 8px;
}
/* Modern Notification System */
.notification-container {
position: fixed;
top: 90px;
right: 24px;
z-index: 2000;
display: flex;
flex-direction: column;
gap: 16px;
max-width: 400px;
}
.notification {
background-color: white;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.15), 0 0 0 1px rgba(0, 0, 0, 0.05);
overflow: hidden;
animation: slideInRight 0.4s cubic-bezier(0.68, -0.55, 0.265, 1.55);
}
@keyframes slideOutRight {
from {
transform: translateX(0);
opacity: 1;
}
to {
transform: translateX(450px);
opacity: 0;
}
}
.notification.slideOutRight {
animation: slideOutRight 0.3s ease forwards;
}
.notification-header {
padding: 16px 20px;
background-color: var(--bg-primary);
border-bottom: 1px solid var(--border-color);
display: flex;
align-items: center;
justify-content: space-between;
}
.notification-warning .notification-header {
background-color: #FFFBEB;
border-bottom-color: #FEF3C7;
}
.notification-title {
display: flex;
align-items: center;
gap: 10px;
font-weight: 600;
font-size: 15px;
color: var(--text-primary);
}
.notification-warning .notification-title i {
color: var(--warning-color);
font-size: 18px;
}
.notification-close {
background: none;
border: none;
font-size: 16px;
cursor: pointer;
color: var(--text-secondary);
padding: 4px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
transition: all 0.2s ease;
}
.notification-close:hover {
background-color: rgba(0, 0, 0, 0.05);
color: var(--text-primary);
}
.notification-body {
padding: 20px;
}
.notification-intro {
margin-bottom: 12px;
font-size: 14px;
color: var(--text-secondary);
}
.notification-list {
list-style: none;
padding: 0;
margin: 0;
}
.notification-list li {
padding: 12px;
background-color: var(--bg-primary);
border-radius: 8px;
margin-bottom: 8px;
display: flex;
flex-direction: column;
gap: 4px;
transition: all 0.2s ease;
}
.notification-list li:last-child {
margin-bottom: 0;
}
.notification-list li:hover {
background-color: #F3F4F6;
transform: translateX(-2px);
}
.notification-list li strong {
color: var(--text-primary);
font-size: 14px;
font-weight: 600;
}
.notification-reason {
font-size: 13px;
color: var(--warning-color);
display: flex;
align-items: center;
gap: 6px;
}
.notification-reason::before {
content: "⚠";
font-size: 12px;
}
/* Responsive notifications */
@media (max-width: 768px) {
.notification-container {
right: 16px;
left: 16px;
max-width: none;
}
}
/* Upload Progress Styles */
.upload-progress-container {
background-color: var(--bg-primary);
border-radius: 12px;
padding: 20px;
margin: 20px 0;
}
.upload-info {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
font-size: 14px;
}
.upload-info span:first-child {
color: var(--text-primary);
font-weight: 500;
}
.upload-info span:last-child {
color: var(--primary-color);
font-weight: 600;
font-size: 16px;
}
.progress-bar {
width: 100%;
height: 8px;
background-color: #E5E7EB;
border-radius: 4px;
overflow: hidden;
margin-bottom: 12px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--primary-color), #667eea);
border-radius: 4px;
transition: width 0.3s ease;
}
.upload-status {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 13px;
color: var(--text-secondary);
}
.upload-status span:last-child {
font-weight: 500;
color: var(--success-color);
}
/* Badge Variants */
.badge-warning {
background-color: #FEF3C7;
color: #92400E;
}
.badge-secondary {
background-color: #E5E7EB;
color: #374151;
}
/* Textarea Styles */
textarea {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 14px;
font-family: inherit;
resize: vertical;
transition: all 0.2s ease;
}
textarea:focus {
outline: none;
border-color: var(--primary-color);
box-shadow: 0 0 0 3px rgba(79, 70, 229, 0.1);
}

570
backend/public/index.html Normal file
View File

@ -0,0 +1,570 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Admin Panel - 虚境起源</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="css/admin.css">
</head>
<body>
<!-- Login Page -->
<div id="loginPage" class="login-page">
<div class="login-container">
<div class="login-header">
<i class="fas fa-shield-alt"></i>
<h1>管理后台</h1>
<p>虚境起源管理系统</p>
</div>
<form id="loginForm" class="login-form">
<div class="form-group">
<label for="username">
<i class="fas fa-user"></i>
用户名
</label>
<input type="text" id="username" name="username" required autocomplete="username">
</div>
<div class="form-group">
<label for="password">
<i class="fas fa-lock"></i>
密码
</label>
<input type="password" id="password" name="password" required autocomplete="current-password">
</div>
<div id="loginError" class="error-message" style="display: none;"></div>
<button type="submit" class="btn btn-primary btn-block">
<i class="fas fa-sign-in-alt"></i>
登录
</button>
</form>
</div>
</div>
<!-- Admin Dashboard -->
<div id="adminDashboard" class="admin-dashboard" style="display: none;">
<!-- Sidebar -->
<aside class="sidebar">
<div class="sidebar-header">
<i class="fas fa-cube"></i>
<h2>虚境起源</h2>
</div>
<nav class="sidebar-nav">
<div class="nav-section">
<div class="nav-section-title">
<i class="fas fa-handshake"></i>
<span>合作伙伴管理</span>
</div>
<a href="#storage-config" class="nav-link" data-page="storage-config">
<i class="fas fa-folder-open"></i>
存储配置
</a>
<a href="#add-partner" class="nav-link" data-page="add-partner">
<i class="fas fa-plus-circle"></i>
新增合作伙伴
</a>
<a href="#partner-list" class="nav-link active" data-page="partner-list">
<i class="fas fa-list"></i>
合作伙伴列表
</a>
</div>
<div class="nav-section">
<div class="nav-section-title">
<i class="fas fa-video"></i>
<span>宣传视频</span>
</div>
<a href="#video-list" class="nav-link" data-page="video-list">
<i class="fas fa-list"></i>
媒体列表
</a>
<a href="#video-config" class="nav-link" data-page="video-config">
<i class="fas fa-cog"></i>
媒体配置
</a>
<a href="#add-video" class="nav-link" data-page="add-video">
<i class="fas fa-plus-circle"></i>
新增媒体
</a>
</div>
<div class="nav-section">
<div class="nav-section-title">
<i class="fas fa-cog"></i>
<span>系统设置</span>
</div>
<a href="#system-settings" class="nav-link" data-page="system-settings">
<i class="fas fa-sliders-h"></i>
系统设置
</a>
<a href="#change-password" class="nav-link" data-page="change-password">
<i class="fas fa-key"></i>
修改密码
</a>
</div>
</nav>
<div class="sidebar-footer">
<button id="logoutBtn" class="btn btn-secondary btn-block">
<i class="fas fa-sign-out-alt"></i>
退出登录
</button>
</div>
</aside>
<!-- Main Content -->
<main class="main-content">
<header class="content-header">
<h1 id="pageTitle">Partner List</h1>
<div class="user-info">
<i class="fas fa-user-circle"></i>
<span id="currentUser">Admin</span>
</div>
</header>
<div class="content-body">
<!-- Storage Configuration Page -->
<div id="storageConfigPage" class="page-content" style="display: none;">
<div class="card">
<div class="card-header">
<h3>存储配置</h3>
</div>
<div class="card-body">
<form id="storageConfigForm">
<div class="form-group">
<label>上传目录</label>
<div class="input-with-status">
<input type="text" id="uploadDir" name="logo_upload_dir" value="img/logo" required>
<div id="dirStatus" class="status-indicators">
<span class="status-indicator" id="dirExists">
<i class="fas fa-circle"></i>
<span>已存在</span>
</span>
<span class="status-indicator" id="dirWritable">
<i class="fas fa-circle"></i>
<span>可写入</span>
</span>
</div>
</div>
<small>相对于项目根目录的路径</small>
</div>
<div class="form-group">
<label>最大文件大小(字节)</label>
<input type="number" id="maxSize" name="logo_max_size" value="5242880" required>
<small>5242880 字节 = 5MB</small>
</div>
<div class="form-group">
<label>允许的扩展名</label>
<input type="text" id="allowedExt" name="allowed_extensions" value="jpg,jpeg,png,gif,svg" required>
<small>逗号分隔的扩展名列表</small>
</div>
<div class="form-actions">
<button type="button" id="createDirBtn" class="btn btn-secondary">
<i class="fas fa-folder-plus"></i>
创建目录
</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i>
保存配置
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Partner List Page -->
<div id="partnerListPage" class="page-content">
<div class="card">
<div class="card-header">
<h3>合作伙伴列表</h3>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>Logo</th>
<th>名称</th>
<th>URL</th>
<th>排序</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody id="partnersTableBody">
<tr>
<td colspan="7" class="text-center">加载中...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- System Settings Page -->
<div id="systemSettingsPage" class="page-content" style="display: none;">
<div class="card">
<div class="card-header">
<h3>系统设置</h3>
</div>
<div class="card-body">
<div id="settingsTableContainer">
<p class="text-muted">加载设置中...</p>
</div>
</div>
</div>
</div>
<!-- Change Password Page -->
<div id="changePasswordPage" class="page-content" style="display: none;">
<div class="card">
<div class="card-header">
<h3>修改管理员密码</h3>
</div>
<div class="card-body">
<form id="changePasswordForm" style="max-width: 500px;">
<div class="form-group">
<label for="currentPassword">当前密码 *</label>
<input type="password" id="currentPassword" name="current_password" required autocomplete="current-password">
</div>
<div class="form-group">
<label for="newPassword">新密码 *</label>
<input type="password" id="newPassword" name="new_password" required minlength="6" autocomplete="new-password">
<small>密码长度至少6位</small>
</div>
<div class="form-group">
<label for="confirmPassword">确认新密码 *</label>
<input type="password" id="confirmPassword" name="confirm_password" required minlength="6" autocomplete="new-password">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i>
修改密码
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Video List Page -->
<div id="videoListPage" class="page-content" style="display: none;">
<div class="card">
<div class="card-header">
<h3>媒体列表</h3>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>ID</th>
<th>封面</th>
<th>中文标题</th>
<th>英文标题</th>
<th>时长</th>
<th>上传状态</th>
<th>状态</th>
<th>操作</th>
</tr>
</thead>
<tbody id="videosTableBody">
<tr>
<td colspan="8" class="text-center">Loading...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- Video Config Page -->
<div id="videoConfigPage" class="page-content" style="display: none;">
<div class="card">
<div class="card-header">
<h3>腾讯云VOD配置</h3>
</div>
<div class="card-body">
<form id="vodConfigForm">
<div class="form-group">
<label for="secretId">SecretId *</label>
<input type="text" id="secretId" name="secret_id" required placeholder="腾讯云SecretId">
<small>在腾讯云访问管理控制台获取</small>
</div>
<div class="form-group">
<label for="secretKey">SecretKey *</label>
<input type="password" id="secretKey" name="secret_key" required placeholder="腾讯云SecretKey">
<small>请妥善保管密钥信息</small>
</div>
<div class="form-group">
<label for="vodRegion">地域</label>
<select id="vodRegion" name="region">
<option value="ap-guangzhou">华南地区(广州)</option>
<option value="ap-shanghai">华东地区(上海)</option>
<option value="ap-beijing">华北地区(北京)</option>
<option value="ap-chengdu">西南地区(成都)</option>
<option value="ap-chongqing">西南地区(重庆)</option>
<option value="ap-hongkong">港澳台地区(中国香港)</option>
<option value="ap-singapore">亚太东南(新加坡)</option>
<option value="ap-mumbai">亚太南部(孟买)</option>
<option value="ap-seoul">亚太东北(首尔)</option>
<option value="ap-bangkok">亚太东南(曼谷)</option>
<option value="ap-tokyo">亚太东北(东京)</option>
<option value="na-siliconvalley">美国西部(硅谷)</option>
<option value="na-ashburn">美国东部(弗吉尼亚)</option>
<option value="na-toronto">北美地区(多伦多)</option>
<option value="eu-frankfurt">欧洲地区(法兰克福)</option>
<option value="eu-moscow">欧洲地区(莫斯科)</option>
</select>
<small>选择离您最近的地域</small>
</div>
<div class="form-group">
<label for="subAppId">子应用ID</label>
<input type="text" id="subAppId" name="sub_app_id" placeholder="可选,留空使用主应用">
</div>
<div class="form-group">
<label for="vodProcedure">任务流</label>
<input type="text" id="vodProcedure" name="procedure" placeholder="可选,视频处理任务流">
<small>配置转码、截图等处理任务</small>
</div>
<div class="form-group">
<label for="storageRegion">存储地域</label>
<input type="text" id="storageRegion" name="storage_region" placeholder="可选,留空使用默认">
</div>
<hr style="margin: 30px 0; border: none; border-top: 2px solid #e5e7eb;">
<h4 style="margin-bottom: 20px; color: #374151;">COS 对象存储配置(用于封面图)</h4>
<div class="form-group">
<label for="cosBucket">COS存储桶 *</label>
<input type="text" id="cosBucket" name="cos_bucket" required placeholder="例如my-bucket-1234567890">
<small>在腾讯云COS控制台创建存储桶后获取格式bucket-appid</small>
</div>
<div class="form-group">
<label for="cosRegion">COS地域</label>
<select id="cosRegion" name="cos_region">
<option value="ap-guangzhou">华南地区(广州)</option>
<option value="ap-shanghai">华东地区(上海)</option>
<option value="ap-beijing">华北地区(北京)</option>
<option value="ap-chengdu">西南地区(成都)</option>
<option value="ap-chongqing">西南地区(重庆)</option>
<option value="ap-hongkong">港澳台地区(中国香港)</option>
<option value="ap-singapore">亚太东南(新加坡)</option>
<option value="ap-mumbai">亚太南部(孟买)</option>
<option value="ap-seoul">亚太东北(首尔)</option>
<option value="ap-bangkok">亚太东南(曼谷)</option>
<option value="ap-tokyo">亚太东北(东京)</option>
<option value="na-siliconvalley">美国西部(硅谷)</option>
<option value="na-ashburn">美国东部(弗吉尼亚)</option>
<option value="na-toronto">北美地区(多伦多)</option>
<option value="eu-frankfurt">欧洲地区(法兰克福)</option>
<option value="eu-moscow">欧洲地区(莫斯科)</option>
</select>
<small>存储桶所在地域建议与VOD地域相同</small>
</div>
<div class="form-group">
<label for="cosPath">COS存储路径前缀</label>
<input type="text" id="cosPath" name="cos_path" value="video-covers/" placeholder="video-covers/">
<small>封面图在存储桶中的路径前缀,末尾需要斜杠</small>
</div>
<hr>
<h4>VOD防盗链配置</h4>
<div class="form-group">
<label for="signKey">防盗链签名密钥(可选)</label>
<input type="text" id="signKey" name="sign_key" placeholder="请输入防盗链Key">
<small>如果开启了VOD防盗链需要填写Key以生成签名URL。留空则不使用防盗链。</small>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i>
保存配置
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Add Video Page -->
<div id="addVideoPage" class="page-content" style="display: none;">
<div class="card">
<div class="card-header">
<h3>新增媒体</h3>
</div>
<div class="card-body">
<form id="addVideoForm">
<div class="form-group">
<label for="videoTitleCn">中文标题 *</label>
<input type="text" id="videoTitleCn" name="title_cn" required>
</div>
<div class="form-group">
<label for="videoTitleEn">英文标题 *</label>
<input type="text" id="videoTitleEn" name="title_en" required>
</div>
<div class="form-group">
<label for="videoDescCn">中文描述</label>
<textarea id="videoDescCn" name="desc_cn" rows="3"></textarea>
</div>
<div class="form-group">
<label for="videoDescEn">英文描述</label>
<textarea id="videoDescEn" name="desc_en" rows="3"></textarea>
</div>
<div class="form-group">
<label for="videoCover">封面图</label>
<input type="file" id="videoCover" name="cover" accept="image/*">
<small>可选。如不上传,将自动使用腾讯云生成的封面图。支持 JPG, PNG, WEBP 格式,最大 10MB</small>
<div id="coverPreview" style="display: none; margin-top: 10px;">
<img id="coverPreviewImg" src="" alt="Cover Preview" style="max-width: 300px; max-height: 200px; border-radius: 8px;">
</div>
</div>
<div class="form-group">
<label for="videoFile">视频文件 *</label>
<input type="file" id="videoFile" accept="video/*" required>
<small>支持 MP4, AVI, MOV 等格式,支持大文件分片上传</small>
</div>
<!-- Upload Progress -->
<div id="uploadProgress" style="display: none;">
<div class="upload-progress-container">
<div class="upload-info">
<span id="uploadFileName"></span>
<span id="uploadPercent">0%</span>
</div>
<div class="progress-bar">
<div class="progress-fill" id="progressFill"></div>
</div>
<div class="upload-status">
<span id="uploadStatus">准备上传...</span>
<span id="uploadSpeed"></span>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" id="uploadVideoBtn">
<i class="fas fa-upload"></i>
开始上传
</button>
<button type="button" class="btn btn-secondary" id="cancelUploadBtn" style="display: none;">
<i class="fas fa-times"></i>
取消上传
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- Add/Edit Partner Modal -->
<div id="partnerModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">新增合作伙伴</h3>
<button class="modal-close">&times;</button>
</div>
<form id="partnerForm" enctype="multipart/form-data">
<div class="modal-body">
<input type="hidden" id="partnerId" name="partnerId">
<div class="form-group">
<label for="partnerName">合作伙伴名称 *</label>
<input type="text" id="partnerName" name="name" required>
</div>
<div class="form-group">
<label for="partnerLogo">Logo 图片 *</label>
<input type="file" id="partnerLogo" name="logo" accept="image/*">
<div id="currentLogoPreview" style="display: none; margin-top: 10px;">
<img id="currentLogo" src="" alt="当前 Logo" style="max-width: 200px; max-height: 100px;">
</div>
<small>支持JPG, PNG, GIF, SVG (最大 5MB)</small>
</div>
<div class="form-group">
<label for="partnerUrl">网站 URL</label>
<input type="url" id="partnerUrl" name="url" placeholder="https://example.com">
</div>
<div class="form-group">
<label for="partnerStatus">状态</label>
<select id="partnerStatus" name="status">
<option value="1">启用</option>
<option value="0">禁用</option>
</select>
</div>
<div class="form-group">
<label for="partnerSortOrder">排序</label>
<input type="number" id="partnerSortOrder" name="sort_order" value="0">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary modal-close">取消</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i>
保存
</button>
</div>
</form>
</div>
</div>
<!-- Toast Notification -->
<div id="toast" class="toast"></div>
<!-- Edit Video Modal -->
<div id="videoModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="videoModalTitle">编辑视频</h3>
<button class="modal-close" onclick="document.getElementById('videoModal').classList.remove('show')">&times;</button>
</div>
<form id="videoEditForm">
<div class="modal-body">
<input type="hidden" id="editVideoId">
<div class="form-group">
<label for="editTitleCn">中文标题 *</label>
<input type="text" id="editTitleCn" name="title_cn" required>
</div>
<div class="form-group">
<label for="editTitleEn">英文标题 *</label>
<input type="text" id="editTitleEn" name="title_en" required>
</div>
<div class="form-group">
<label for="editDescCn">中文描述</label>
<textarea id="editDescCn" name="desc_cn" rows="3"></textarea>
</div>
<div class="form-group">
<label for="editDescEn">英文描述</label>
<textarea id="editDescEn" name="desc_en" rows="3"></textarea>
</div>
<div class="form-group">
<label for="editStatus">状态</label>
<select id="editStatus" name="status">
<option value="1">启用</option>
<option value="0">禁用</option>
<option value="2">处理中</option>
</select>
</div>
<div class="form-group">
<label for="editSortOrder">排序</label>
<input type="number" id="editSortOrder" name="sort_order" value="0">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="document.getElementById('videoModal').classList.remove('show')">取消</button>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i>
保存
</button>
</div>
</form>
</div>
</div>
<!-- 腾讯云VOD上传SDK -->
<script src="https://cdn-go.cn/cdn/vod-js-sdk-v6/latest/vod-js-sdk-v6.js"></script>
<script src="js/admin.js"></script>
</body>
</html>

1109
backend/public/js/admin.js Normal file

File diff suppressed because it is too large Load Diff

174
backend/routes/auth.js Normal file
View File

@ -0,0 +1,174 @@
const express = require('express');
const router = express.Router();
const bcrypt = require('bcryptjs');
const db = require('../config/database');
const { requireAuth } = require('../middleware/auth');
// Login
router.post('/login', async (req, res) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({
success: false,
message: 'Username and password are required'
});
}
// Get user from database
const [users] = await db.query(
'SELECT * FROM admin_users WHERE username = ?',
[username]
);
if (users.length === 0) {
return res.status(401).json({
success: false,
message: 'Invalid username or password'
});
}
const user = users[0];
// Verify password
const isValidPassword = await bcrypt.compare(password, user.password);
if (!isValidPassword) {
return res.status(401).json({
success: false,
message: 'Invalid username or password'
});
}
// Update last login
await db.query(
'UPDATE admin_users SET last_login = NOW() WHERE id = ?',
[user.id]
);
// Set session
req.session.userId = user.id;
req.session.username = user.username;
res.json({
success: true,
message: 'Login successful',
user: {
id: user.id,
username: user.username,
email: user.email
}
});
} catch (error) {
console.error('Login error:', error);
res.status(500).json({
success: false,
message: 'Login failed'
});
}
});
// Logout
router.post('/logout', (req, res) => {
req.session.destroy((err) => {
if (err) {
return res.status(500).json({
success: false,
message: 'Logout failed'
});
}
res.json({
success: true,
message: 'Logout successful'
});
});
});
// Check auth status
router.get('/status', (req, res) => {
if (req.session && req.session.userId) {
res.json({
success: true,
authenticated: true,
user: {
id: req.session.userId,
username: req.session.username
}
});
} else {
res.json({
success: true,
authenticated: false
});
}
});
// Change password
router.post('/change-password', requireAuth, async (req, res) => {
try {
const { current_password, new_password } = req.body;
const userId = req.session.userId;
if (!current_password || !new_password) {
return res.status(400).json({
success: false,
message: '当前密码和新密码不能为空'
});
}
if (new_password.length < 6) {
return res.status(400).json({
success: false,
message: '新密码长度至少6位'
});
}
// 获取当前用户信息
const [users] = await db.query(
'SELECT * FROM admin_users WHERE id = ?',
[userId]
);
if (users.length === 0) {
return res.status(404).json({
success: false,
message: '用户不存在'
});
}
const user = users[0];
// 验证当前密码
const isValidPassword = await bcrypt.compare(current_password, user.password);
if (!isValidPassword) {
return res.status(401).json({
success: false,
message: '当前密码错误'
});
}
// 生成新密码哈希
const hashedPassword = await bcrypt.hash(new_password, 10);
// 更新密码
await db.query(
'UPDATE admin_users SET password = ? WHERE id = ?',
[hashedPassword, userId]
);
res.json({
success: true,
message: '密码修改成功'
});
} catch (error) {
console.error('Change password error:', error);
res.status(500).json({
success: false,
message: '密码修改失败'
});
}
});
module.exports = router;

139
backend/routes/config.js Normal file
View File

@ -0,0 +1,139 @@
const express = require('express');
const router = express.Router();
const path = require('path');
const fs = require('fs').promises;
const db = require('../config/database');
const { requireAuth } = require('../middleware/auth');
// Get storage configuration
router.get('/storage', requireAuth, async (req, res) => {
try {
const [settings] = await db.query(
'SELECT * FROM system_settings WHERE setting_key LIKE "logo%"'
);
const config = {};
settings.forEach(setting => {
config[setting.setting_key] = setting.setting_value;
});
// Check directory status
const uploadDir = path.join(__dirname, '../../', config.logo_upload_dir || 'img/logo');
let dirStatus = {
exists: false,
writable: false,
path: config.logo_upload_dir || 'img/logo'
};
try {
await fs.access(uploadDir, fs.constants.F_OK);
dirStatus.exists = true;
await fs.access(uploadDir, fs.constants.W_OK);
dirStatus.writable = true;
} catch (err) {
// Directory doesn't exist or not writable
}
res.json({
success: true,
data: {
config,
dirStatus
}
});
} catch (error) {
console.error('Get storage config error:', error);
res.status(500).json({
success: false,
message: 'Failed to fetch storage configuration'
});
}
});
// Update storage configuration
router.put('/storage', requireAuth, async (req, res) => {
try {
const { logo_upload_dir, logo_max_size, allowed_extensions } = req.body;
if (logo_upload_dir) {
await db.query(
'UPDATE system_settings SET setting_value = ? WHERE setting_key = ?',
[logo_upload_dir, 'logo_upload_dir']
);
}
if (logo_max_size) {
await db.query(
'UPDATE system_settings SET setting_value = ? WHERE setting_key = ?',
[logo_max_size, 'logo_max_size']
);
}
if (allowed_extensions) {
await db.query(
'UPDATE system_settings SET setting_value = ? WHERE setting_key = ?',
[allowed_extensions, 'allowed_extensions']
);
}
res.json({
success: true,
message: 'Storage configuration updated successfully'
});
} catch (error) {
console.error('Update storage config error:', error);
res.status(500).json({
success: false,
message: 'Failed to update storage configuration'
});
}
});
// Create upload directory
router.post('/storage/create-dir', requireAuth, async (req, res) => {
try {
const { dirPath } = req.body;
if (!dirPath) {
return res.status(400).json({
success: false,
message: 'Directory path is required'
});
}
const fullPath = path.join(__dirname, '../../', dirPath);
await fs.mkdir(fullPath, { recursive: true });
res.json({
success: true,
message: 'Directory created successfully'
});
} catch (error) {
console.error('Create directory error:', error);
res.status(500).json({
success: false,
message: 'Failed to create directory'
});
}
});
// Get all system settings
router.get('/settings', requireAuth, async (req, res) => {
try {
const [settings] = await db.query('SELECT * FROM system_settings');
res.json({
success: true,
data: settings
});
} catch (error) {
console.error('Get settings error:', error);
res.status(500).json({
success: false,
message: 'Failed to fetch settings'
});
}
});
module.exports = router;

259
backend/routes/partners.js Normal file
View File

@ -0,0 +1,259 @@
const express = require('express');
const router = express.Router();
const multer = require('multer');
const path = require('path');
const fs = require('fs').promises;
const db = require('../config/database');
const { requireAuth } = require('../middleware/auth');
// Configure multer for file upload
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadDir = path.join(__dirname, '../../img/logo');
try {
await fs.mkdir(uploadDir, { recursive: true });
cb(null, uploadDir);
} catch (error) {
cb(error);
}
},
filename: (req, file, cb) => {
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + path.extname(file.originalname));
}
});
const upload = multer({
storage: storage,
limits: {
fileSize: 5 * 1024 * 1024 // 5MB
},
fileFilter: (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|gif|svg/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (mimetype && extname) {
return cb(null, true);
} else {
cb(new Error('Only image files are allowed (jpg, jpeg, png, gif, svg)'));
}
}
});
// Public endpoint: Get active partners (for frontend display)
router.get('/public', async (req, res) => {
try {
const [partners] = await db.query(
'SELECT name, logo, url FROM partners WHERE status = 1 ORDER BY sort_order ASC, id DESC'
);
res.json({
success: true,
data: partners
});
} catch (error) {
console.error('Get public partners error:', error);
res.status(500).json({
success: false,
message: 'Failed to fetch partners'
});
}
});
// Get all partners
router.get('/', requireAuth, async (req, res) => {
try {
const [partners] = await db.query(
'SELECT * FROM partners ORDER BY sort_order ASC, id DESC'
);
res.json({
success: true,
data: partners
});
} catch (error) {
console.error('Get partners error:', error);
res.status(500).json({
success: false,
message: 'Failed to fetch partners'
});
}
});
// Get single partner
router.get('/:id', requireAuth, async (req, res) => {
try {
const [partners] = await db.query(
'SELECT * FROM partners WHERE id = ?',
[req.params.id]
);
if (partners.length === 0) {
return res.status(404).json({
success: false,
message: 'Partner not found'
});
}
res.json({
success: true,
data: partners[0]
});
} catch (error) {
console.error('Get partner error:', error);
res.status(500).json({
success: false,
message: 'Failed to fetch partner'
});
}
});
// Create partner
router.post('/', requireAuth, upload.single('logo'), async (req, res) => {
try {
const { name, url, status } = req.body;
if (!name) {
return res.status(400).json({
success: false,
message: 'Partner name is required'
});
}
if (!req.file) {
return res.status(400).json({
success: false,
message: 'Logo image is required'
});
}
const logoPath = 'img/logo/' + req.file.filename;
// Get max sort order
const [maxOrder] = await db.query(
'SELECT MAX(sort_order) as max_order FROM partners'
);
const sortOrder = (maxOrder[0].max_order || 0) + 1;
const [result] = await db.query(
'INSERT INTO partners (name, logo, url, sort_order, status) VALUES (?, ?, ?, ?, ?)',
[name, logoPath, url || '', sortOrder, status || 1]
);
res.json({
success: true,
message: 'Partner created successfully',
data: {
id: result.insertId,
name,
logo: logoPath,
url: url || '',
sort_order: sortOrder,
status: status || 1
}
});
} catch (error) {
console.error('Create partner error:', error);
res.status(500).json({
success: false,
message: 'Failed to create partner'
});
}
});
// Update partner
router.put('/:id', requireAuth, upload.single('logo'), async (req, res) => {
try {
const { name, url, status, sort_order } = req.body;
const partnerId = req.params.id;
// Check if partner exists
const [existingPartner] = await db.query(
'SELECT * FROM partners WHERE id = ?',
[partnerId]
);
if (existingPartner.length === 0) {
return res.status(404).json({
success: false,
message: 'Partner not found'
});
}
let logoPath = existingPartner[0].logo;
// If new logo uploaded, delete old one and use new one
if (req.file) {
// Delete old logo file
const oldLogoPath = path.join(__dirname, '../..', existingPartner[0].logo);
try {
await fs.unlink(oldLogoPath);
} catch (err) {
console.log('Old logo file not found or cannot be deleted');
}
logoPath = 'img/logo/' + req.file.filename;
}
await db.query(
'UPDATE partners SET name = ?, logo = ?, url = ?, status = ?, sort_order = ? WHERE id = ?',
[name, logoPath, url || '', status || 1, sort_order || existingPartner[0].sort_order, partnerId]
);
res.json({
success: true,
message: 'Partner updated successfully'
});
} catch (error) {
console.error('Update partner error:', error);
res.status(500).json({
success: false,
message: 'Failed to update partner'
});
}
});
// Delete partner
router.delete('/:id', requireAuth, async (req, res) => {
try {
const partnerId = req.params.id;
// Get partner info
const [partner] = await db.query(
'SELECT * FROM partners WHERE id = ?',
[partnerId]
);
if (partner.length === 0) {
return res.status(404).json({
success: false,
message: 'Partner not found'
});
}
// Delete logo file
const logoPath = path.join(__dirname, '../..', partner[0].logo);
try {
await fs.unlink(logoPath);
} catch (err) {
console.log('Logo file not found or cannot be deleted');
}
// Delete from database
await db.query('DELETE FROM partners WHERE id = ?', [partnerId]);
res.json({
success: true,
message: 'Partner deleted successfully'
});
} catch (error) {
console.error('Delete partner error:', error);
res.status(500).json({
success: false,
message: 'Failed to delete partner'
});
}
});
module.exports = router;

687
backend/routes/videos.js Normal file
View File

@ -0,0 +1,687 @@
const express = require('express');
const router = express.Router();
const multer = require('multer');
const path = require('path');
const fs = require('fs').promises;
const crypto = require('crypto');
const db = require('../config/database');
const { requireAuth } = require('../middleware/auth');
// 引入腾讯云SDK
const tencentcloud = require('tencentcloud-sdk-nodejs');
const VodClient = tencentcloud.vod.v20180717.Client;
const COS = require('cos-nodejs-sdk-v5');
// 获取腾讯云VOD客户端
async function getVodClient() {
const [configs] = await db.query('SELECT * FROM vod_config LIMIT 1');
if (configs.length === 0 || !configs[0].secret_id || !configs[0].secret_key) {
throw new Error('VOD configuration not found');
}
const config = configs[0];
const clientConfig = {
credential: {
secretId: config.secret_id,
secretKey: config.secret_key,
},
region: config.region || 'ap-guangzhou',
profile: {
httpProfile: {
endpoint: 'vod.tencentcloudapi.com',
},
},
};
return new VodClient(clientConfig);
}
// 从腾讯云VOD获取视频封面URL
async function getVideoCoverFromVod(fileId) {
try {
const client = await getVodClient();
// 调用DescribeMediaInfos接口获取媒体信息
const params = {
FileIds: [fileId]
};
const response = await client.DescribeMediaInfos(params);
if (response.MediaInfoSet && response.MediaInfoSet.length > 0) {
const mediaInfo = response.MediaInfoSet[0];
return mediaInfo.BasicInfo?.CoverUrl || null;
}
return null;
} catch (error) {
console.error('Get video cover from VOD error:', error);
return null;
}
}
// 生成VOD防盗链签名URL
// 根据腾讯云文档https://cloud.tencent.com/document/product/266/14047
// 签名算法sign = MD5(KEY + Dir + t + us)
function generateSignedUrl(videoUrl, signKey, expirationTime = 3600) {
if (!signKey || !videoUrl) {
return videoUrl;
}
try {
// 解析URL
const url = new URL(videoUrl);
const pathname = url.pathname; // 例如:/xxx/xxx/video.mp4
// 提取目录路径(不含文件名)
// 例如:/xxx/xxx/video.mp4 -> /xxx/xxx/
const lastSlashIndex = pathname.lastIndexOf('/');
const dir = pathname.substring(0, lastSlashIndex + 1);
// 计算过期时间戳(十六进制小写)
const expirationTimestamp = Math.floor(Date.now() / 1000) + expirationTime;
const t = expirationTimestamp.toString(16).toLowerCase();
// 生成随机字符串10位
const us = Math.random().toString(36).substring(2, 12);
// 生成签名MD5(KEY + Dir + t + us)
const signString = signKey + dir + t + us;
const sign = crypto.createHash('md5').update(signString).digest('hex');
// 拼接最终URL参数顺序必须是t, us, sign
const signedUrl = `${videoUrl}?t=${t}&us=${us}&sign=${sign}`;
console.log('=== VOD签名调试信息 ===');
console.log('原始URL:', videoUrl);
console.log('Dir路径:', dir);
console.log('过期时间t:', t);
console.log('随机字符串us:', us);
console.log('签名字符串:', signString);
console.log('MD5签名:', sign);
console.log('最终URL:', signedUrl);
console.log('=====================');
return signedUrl;
} catch (error) {
console.error('Generate signed URL error:', error);
return videoUrl;
}
}
// 获取COS客户端
async function getCosClient() {
const [configs] = await db.query('SELECT * FROM vod_config LIMIT 1');
if (configs.length === 0 || !configs[0].secret_id || !configs[0].secret_key) {
throw new Error('COS configuration not found');
}
const config = configs[0];
return new COS({
SecretId: config.secret_id,
SecretKey: config.secret_key,
});
}
// 上传封面图到腾讯云COS
async function uploadCoverToCos(fileBuffer, fileName, mimeType) {
try {
const [configs] = await db.query('SELECT * FROM vod_config LIMIT 1');
if (configs.length === 0 || !configs[0].cos_bucket) {
throw new Error('COS bucket not configured');
}
const config = configs[0];
const cos = await getCosClient();
// 生成唯一文件名
const ext = path.extname(fileName);
const timestamp = Date.now();
const randomStr = Math.random().toString(36).substring(2, 15);
const cosFileName = `${config.cos_path || 'video-covers/'}${timestamp}-${randomStr}${ext}`;
// 上传到COS
return new Promise((resolve, reject) => {
cos.putObject({
Bucket: config.cos_bucket,
Region: config.cos_region || 'ap-guangzhou',
Key: cosFileName,
Body: fileBuffer,
ContentType: mimeType,
}, (err, data) => {
if (err) {
console.error('COS upload error:', err);
reject(err);
} else {
// 构建访问URL
const url = `https://${config.cos_bucket}.cos.${config.cos_region || 'ap-guangzhou'}.myqcloud.com/${cosFileName}`;
console.log('Cover uploaded to COS:', url);
resolve(url);
}
});
});
} catch (error) {
console.error('Upload cover to COS failed:', error);
throw error;
}
}
// 从COS删除封面图
async function deleteCoverFromCos(coverUrl) {
try {
const [configs] = await db.query('SELECT * FROM vod_config LIMIT 1');
if (configs.length === 0 || !configs[0].cos_bucket) {
console.log('COS bucket not configured');
return;
}
const config = configs[0];
// 检查是否是COS URL
if (!coverUrl.includes('.myqcloud.com/')) {
console.log('Not a COS URL, skipping deletion');
return;
}
// 从URL提取文件Key
const urlParts = coverUrl.split('.myqcloud.com/');
if (urlParts.length < 2) {
console.log('Invalid COS URL format');
return;
}
const fileKey = urlParts[1];
const cos = await getCosClient();
return new Promise((resolve, reject) => {
cos.deleteObject({
Bucket: config.cos_bucket,
Region: config.cos_region || 'ap-guangzhou',
Key: fileKey,
}, (err, data) => {
if (err) {
console.error('Failed to delete cover from COS:', err);
resolve(); // 即使删除失败也继续
} else {
console.log('Cover deleted from COS:', fileKey);
resolve();
}
});
});
} catch (error) {
console.error('Delete cover from COS error:', error);
// 不抛出错误,避免影响主流程
}
}
// 配置封面图上传(使用内存存储)
const storage = multer.memoryStorage();
const upload = multer({
storage: storage,
limits: {
fileSize: 10 * 1024 * 1024 // 10MB for cover images
},
fileFilter: (req, file, cb) => {
const allowedTypes = /jpeg|jpg|png|webp/;
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
const mimetype = allowedTypes.test(file.mimetype);
if (mimetype && extname) {
return cb(null, true);
} else {
cb(new Error('Only image files are allowed (jpg, jpeg, png, webp)'));
}
}
});
// ==================== 公开接口 ====================
// 获取所有活跃视频(前台使用)
router.get('/public', async (req, res) => {
try {
// 获取VOD配置以获取签名密钥
const [configs] = await db.query('SELECT sign_key FROM vod_config LIMIT 1');
const signKey = configs.length > 0 ? configs[0].sign_key : null;
const [videos] = await db.query(
'SELECT file_id, title_cn, title_en, desc_cn, desc_en, cover_url, video_url, duration FROM videos WHERE status = 1 ORDER BY sort_order ASC, id DESC'
);
// 为每个视频URL生成签名
const videosWithSignedUrls = videos.map(video => {
if (video.video_url && signKey) {
video.video_url = generateSignedUrl(video.video_url, signKey);
}
return video;
});
res.json({
success: true,
data: videosWithSignedUrls
});
} catch (error) {
console.error('Get public videos error:', error);
res.status(500).json({
success: false,
message: 'Failed to fetch videos'
});
}
});
// ==================== VOD配置接口 ====================
// 获取VOD配置
router.get('/vod-config', requireAuth, async (req, res) => {
try {
const [configs] = await db.query('SELECT * FROM vod_config LIMIT 1');
if (configs.length === 0) {
return res.json({
success: true,
data: {
secret_id: '',
secret_key: '',
region: 'ap-guangzhou',
sub_app_id: '',
procedure: '',
storage_region: ''
}
});
}
// 隐藏部分密钥
const config = configs[0];
if (config.secret_key && config.secret_key.length > 8) {
config.secret_key_masked = config.secret_key.substring(0, 4) + '****' + config.secret_key.substring(config.secret_key.length - 4);
}
res.json({
success: true,
data: config
});
} catch (error) {
console.error('Get VOD config error:', error);
res.status(500).json({
success: false,
message: 'Failed to fetch VOD configuration'
});
}
});
// 更新VOD配置
router.put('/vod-config', requireAuth, async (req, res) => {
try {
const { secret_id, secret_key, region, sub_app_id, procedure, storage_region, cos_bucket, cos_region, cos_path, sign_key } = req.body;
if (!secret_id || !secret_key) {
return res.status(400).json({
success: false,
message: 'SecretId and SecretKey are required'
});
}
if (!cos_bucket) {
return res.status(400).json({
success: false,
message: 'COS存储桶名称必填'
});
}
// 检查是否存在配置
const [existing] = await db.query('SELECT id FROM vod_config LIMIT 1');
if (existing.length > 0) {
// 更新配置
await db.query(
'UPDATE vod_config SET secret_id = ?, secret_key = ?, region = ?, sub_app_id = ?, `procedure` = ?, storage_region = ?, cos_bucket = ?, cos_region = ?, cos_path = ?, sign_key = ? WHERE id = ?',
[secret_id, secret_key, region || 'ap-guangzhou', sub_app_id || '', procedure || '', storage_region || '', cos_bucket, cos_region || 'ap-guangzhou', cos_path || 'video-covers/', sign_key || '', existing[0].id]
);
} else {
// 插入新配置
await db.query(
'INSERT INTO vod_config (secret_id, secret_key, region, sub_app_id, `procedure`, storage_region, cos_bucket, cos_region, cos_path, sign_key) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[secret_id, secret_key, region || 'ap-guangzhou', sub_app_id || '', procedure || '', storage_region || '', cos_bucket, cos_region || 'ap-guangzhou', cos_path || 'video-covers/', sign_key || '']
);
}
res.json({
success: true,
message: 'VOD configuration updated successfully'
});
} catch (error) {
console.error('Update VOD config error:', error);
res.status(500).json({
success: false,
message: 'Failed to update VOD configuration'
});
}
});
// ==================== 视频管理接口 ====================
// 获取所有视频列表
router.get('/', requireAuth, async (req, res) => {
try {
const [videos] = await db.query(
'SELECT * FROM videos ORDER BY sort_order ASC, id DESC'
);
res.json({
success: true,
data: videos
});
} catch (error) {
console.error('Get videos error:', error);
res.status(500).json({
success: false,
message: 'Failed to fetch videos'
});
}
});
// 获取单个视频
router.get('/:id', requireAuth, async (req, res) => {
try {
const [videos] = await db.query(
'SELECT * FROM videos WHERE id = ?',
[req.params.id]
);
if (videos.length === 0) {
return res.status(404).json({
success: false,
message: 'Video not found'
});
}
res.json({
success: true,
data: videos[0]
});
} catch (error) {
console.error('Get video error:', error);
res.status(500).json({
success: false,
message: 'Failed to fetch video'
});
}
});
// 创建视频记录(上传前)
router.post('/', requireAuth, upload.single('cover'), async (req, res) => {
try {
const { title_cn, title_en, desc_cn, desc_en, file_id, video_url, duration, file_size, status } = req.body;
if (!title_cn || !title_en) {
return res.status(400).json({
success: false,
message: 'Title (CN & EN) are required'
});
}
let coverUrl = '';
if (req.file) {
// 上传封面图到腾讯云COS
try {
coverUrl = await uploadCoverToCos(req.file.buffer, req.file.originalname, req.file.mimetype);
} catch (uploadError) {
console.error('Failed to upload cover to COS:', uploadError);
return res.status(500).json({
success: false,
message: '封面图上传失败请检查COS配置'
});
}
}
// 获取最大排序值
const [maxOrder] = await db.query('SELECT MAX(sort_order) as max_order FROM videos');
const sortOrder = (maxOrder[0].max_order || 0) + 1;
const [result] = await db.query(
'INSERT INTO videos (title_cn, title_en, desc_cn, desc_en, cover_url, file_id, video_url, duration, file_size, sort_order, status, upload_status) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[title_cn, title_en, desc_cn || '', desc_en || '', coverUrl, file_id || '', video_url || '', duration || 0, file_size || 0, sortOrder, status || 2, 'pending']
);
res.json({
success: true,
message: 'Video record created successfully',
data: {
id: result.insertId,
title_cn,
title_en,
cover_url: coverUrl
}
});
} catch (error) {
console.error('Create video error:', error);
res.status(500).json({
success: false,
message: 'Failed to create video record'
});
}
});
// 更新视频信息
router.put('/:id', requireAuth, upload.single('cover'), async (req, res) => {
try {
const { title_cn, title_en, desc_cn, desc_en, status, sort_order, file_id, video_url, duration, file_size, upload_status } = req.body;
const videoId = req.params.id;
// 检查视频是否存在
const [existingVideo] = await db.query('SELECT * FROM videos WHERE id = ?', [videoId]);
if (existingVideo.length === 0) {
return res.status(404).json({
success: false,
message: 'Video not found'
});
}
let coverUrl = existingVideo[0].cover_url;
// 如果上传了新封面
if (req.file) {
// 删除旧封面(如果是本地文件)
if (existingVideo[0].cover_url && existingVideo[0].cover_url.startsWith('img/')) {
const oldCoverPath = path.join(__dirname, '../..', existingVideo[0].cover_url);
try {
await fs.unlink(oldCoverPath);
} catch (err) {
console.log('Old cover file not found or cannot be deleted');
}
}
// 上传新封面到COS
try {
coverUrl = await uploadCoverToCos(req.file.buffer, req.file.originalname, req.file.mimetype);
} catch (uploadError) {
console.error('Failed to upload cover to COS:', uploadError);
return res.status(500).json({
success: false,
message: '封面图上传失败'
});
}
} else if (file_id && !existingVideo[0].cover_url) {
// 如果有file_id且之前没有封面尝试从腾讯云获取
try {
const vodCoverUrl = await getVideoCoverFromVod(file_id);
if (vodCoverUrl) {
coverUrl = vodCoverUrl;
console.log(`Auto-fetched cover from VOD: ${vodCoverUrl}`);
}
} catch (error) {
console.error('Failed to auto-fetch cover:', error);
// 继续执行,不影响主流程
}
}
await db.query(
'UPDATE videos SET title_cn = ?, title_en = ?, desc_cn = ?, desc_en = ?, cover_url = ?, status = ?, sort_order = ?, file_id = ?, video_url = ?, duration = ?, file_size = ?, upload_status = ? WHERE id = ?',
[
title_cn || existingVideo[0].title_cn,
title_en || existingVideo[0].title_en,
desc_cn !== undefined ? desc_cn : existingVideo[0].desc_cn,
desc_en !== undefined ? desc_en : existingVideo[0].desc_en,
coverUrl,
status !== undefined ? status : existingVideo[0].status,
sort_order !== undefined ? sort_order : existingVideo[0].sort_order,
file_id !== undefined ? file_id : existingVideo[0].file_id,
video_url !== undefined ? video_url : existingVideo[0].video_url,
duration !== undefined ? duration : existingVideo[0].duration,
file_size !== undefined ? file_size : existingVideo[0].file_size,
upload_status !== undefined ? upload_status : existingVideo[0].upload_status,
videoId
]
);
res.json({
success: true,
message: 'Video updated successfully'
});
} catch (error) {
console.error('Update video error:', error);
res.status(500).json({
success: false,
message: 'Failed to update video'
});
}
});
// 删除视频
router.delete('/:id', requireAuth, async (req, res) => {
try {
const videoId = req.params.id;
// 获取视频信息
const [video] = await db.query('SELECT * FROM videos WHERE id = ?', [videoId]);
if (video.length === 0) {
return res.status(404).json({
success: false,
message: 'Video not found'
});
}
const videoData = video[0];
// 如果有file_id从腾讯云VOD删除视频
if (videoData.file_id) {
try {
const client = await getVodClient();
// 调用删除媒体接口
const params = {
FileId: videoData.file_id
};
await client.DeleteMedia(params);
console.log(`Successfully deleted video from Tencent Cloud VOD: ${videoData.file_id}`);
} catch (vodError) {
console.error('Failed to delete video from VOD:', vodError);
// 继续删除数据库记录即使VOD删除失败
// 可能视频已经在VOD中被删除了
}
}
// 删除封面文件
if (videoData.cover_url) {
if (videoData.cover_url.includes('.myqcloud.com/')) {
// 如果是COS URL从COS删除
await deleteCoverFromCos(videoData.cover_url);
} else if (videoData.cover_url.startsWith('img/')) {
// 如果是本地文件,从本地删除
const coverPath = path.join(__dirname, '../..', videoData.cover_url);
try {
await fs.unlink(coverPath);
} catch (err) {
console.log('Cover file not found or cannot be deleted');
}
}
}
// 从数据库删除
await db.query('DELETE FROM videos WHERE id = ?', [videoId]);
res.json({
success: true,
message: 'Video deleted successfully from both database and Tencent Cloud VOD'
});
} catch (error) {
console.error('Delete video error:', error);
res.status(500).json({
success: false,
message: 'Failed to delete video: ' + error.message
});
}
});
// ==================== 腾讯云VOD上传接口 ====================
// 获取上传签名
router.post('/upload-signature', requireAuth, async (req, res) => {
try {
const [configs] = await db.query('SELECT * FROM vod_config LIMIT 1');
if (configs.length === 0 || !configs[0].secret_id || !configs[0].secret_key) {
return res.status(400).json({
success: false,
message: 'VOD configuration not found. Please configure VOD settings first.'
});
}
const config = configs[0];
const currentTime = Math.floor(Date.now() / 1000);
const expireTime = currentTime + 86400; // 24小时有效期
// 构建签名原始字符串
const signatureParams = {
secretId: config.secret_id,
currentTimeStamp: currentTime,
expireTime: expireTime,
random: Math.floor(Math.random() * 1000000),
};
// 添加可选参数
if (config.procedure) {
signatureParams.procedure = config.procedure;
}
if (config.storage_region) {
signatureParams.storageRegion = config.storage_region;
}
// 构建原始字符串
const originalString = Object.keys(signatureParams)
.sort()
.map(key => `${key}=${signatureParams[key]}`)
.join('&');
// 使用 SecretKey 对原始字符串进行 HMAC-SHA1 加密
const hmac = crypto.createHmac('sha1', config.secret_key);
const signature = hmac.update(originalString).digest();
// 将加密后的字符串和原始字符串拼接
const signatureStr = Buffer.concat([signature, Buffer.from(originalString)]).toString('base64');
res.json({
success: true,
data: {
signature: signatureStr,
region: config.region,
sub_app_id: config.sub_app_id
}
});
} catch (error) {
console.error('Generate upload signature error:', error);
res.status(500).json({
success: false,
message: 'Failed to generate upload signature'
});
}
});
module.exports = router;

74
backend/server.js Normal file
View File

@ -0,0 +1,74 @@
const express = require('express');
const session = require('express-session');
const cors = require('cors');
const path = require('path');
require('dotenv').config();
const app = express();
const PORT = process.env.PORT || 3000;
// Middleware
app.use(cors({
origin: true,
credentials: true
}));
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Session configuration
app.use(session({
secret: process.env.SESSION_SECRET || 'xuking_origin_secret_key',
resave: false,
saveUninitialized: false,
cookie: {
secure: false, // set to true if using https
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
// Serve static files from parent directory
app.use(express.static(path.join(__dirname, '..')));
app.use('/admin', express.static(path.join(__dirname, 'public')));
// API Routes
const authRoutes = require('./routes/auth');
const partnerRoutes = require('./routes/partners');
const configRoutes = require('./routes/config');
const videoRoutes = require('./routes/videos');
app.use('/api/auth', authRoutes);
app.use('/api/partners', partnerRoutes);
app.use('/api/config', configRoutes);
app.use('/api/videos', videoRoutes);
// Admin panel routes
app.get('/admin', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
app.get('/admin/*', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Root redirect
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, '..', 'index.html'));
});
// Error handling middleware
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({
success: false,
message: 'Something went wrong!',
error: process.env.NODE_ENV === 'development' ? err.message : undefined
});
});
// Start server
app.listen(PORT, () => {
console.log(`\n🚀 Server running on http://localhost:${PORT}`);
console.log(`📊 Admin panel: http://localhost:${PORT}/admin`);
console.log(`🌐 Website: http://localhost:${PORT}\n`);
});

View File

@ -1,37 +0,0 @@
[
{
"name": "vivo",
"logo": "img/logo/vivo.png",
"url": ""
},
{
"name": "BOE",
"logo": "img/logo/boe.png",
"url": ""
},
{
"name": "北京大运河博物馆-首都博物馆东馆",
"logo": "img/logo/bwg.png",
"url": ""
},
{
"name": "江西日报",
"logo": "img/logo/jxrb.png",
"url": ""
},
{
"name": "丽江市文化和旅游局",
"logo": "img/logo/ljs.png",
"url": ""
},
{
"name": "Meta",
"logo": "img/logo/meta.png",
"url": ""
},
{
"name": "中国文化传媒",
"logo": "img/logo/zgwhcm.png",
"url": ""
}
]

View File

@ -1,158 +0,0 @@
[
{
"src": "video/数字企业-MR园区展示制作.mp4",
"preview": "数字企业-MR园区展示制作",
"title": "数字企业-MR园区展示制作",
"desc": "合作客户案例"
},
{
"src": "video/智慧文旅-MR大空间制作.mp4",
"preview": "智慧文旅-MR大空间制作",
"title": "智慧文旅-MR大空间制作",
"desc": "合作客户案例"
},
{
"src": "video/XR游戏-定制游戏制作.mp4",
"preview": "XR游戏-定制游戏制作",
"title": "XR游戏-定制游戏制作",
"desc": "合作客户案例"
},
{
"src": "video/房产土地-VR可移动观览制作.mp4",
"preview": "房产土地-VR可移动观览制作",
"title": "房产土地-VR可移动观览制作",
"desc": "合作客户案例"
},
{
"src": "video/MR古建筑.mp4",
"preview": "MR古建筑",
"title": "MR古建筑",
"desc": "合作客户案例"
},
{
"src": "video/国博MR展示.mp4",
"preview": "国博MR展示",
"title": "国博MR展示",
"desc": "合作客户案例"
},
{
"src": "video/传统复兴-文化传承教育.mp4",
"preview": "传统复兴-文化传承教育",
"title": "传统复兴-文化传承教育",
"desc": "合作客户案例"
},
{
"src": "video/生产制造工农业-展示制作.mp4",
"preview": "生产制造工农业-展示制作",
"title": "生产制造工农业-展示制作",
"desc": "合作客户案例"
},
{
"src": "video/建筑家庭·实景装修制作.mp4",
"preview": "建筑家庭·实景装修制作",
"title": "建筑家庭·实景装修制作",
"desc": "合作客户案例"
},
{
"src": "video/Mobile应用移植.mp4",
"preview": "Mobile应用移植",
"title": "Mobile应用移植",
"desc": "合作客户案例"
},
{
"src": "video/VR博物馆-互动式展示制作.mp4",
"preview": "VR博物馆-互动式展示制作",
"title": "VR博物馆-互动式展示制作",
"desc": "合作客户案例"
},
{
"src": "video/XR课件-生物课程教学.mp4",
"preview": "XR课件-生物课程教学",
"title": "XR课件-生物课程教学",
"desc": "合作客户案例"
},
{
"src": "video/游戏定制-XR塔防游戏.mp4",
"preview": "游戏定制-XR塔防游戏",
"title": "游戏定制-XR塔防游戏",
"desc": "合作客户案例"
},
{
"src": "video/3D数字人-AR全息演绎.mp4",
"preview": "3D数字人-AR全息演绎",
"title": "3D数字人-AR全息演绎",
"desc": "合作客户案例"
},
{
"src": "video/AR桌游-线下社交.mp4",
"preview": "AR桌游-线下社交",
"title": "AR桌游-线下社交",
"desc": "合作客户案例"
},
{
"src": "video/VIVO-桌鼓达人.mp4",
"preview": "VIVO-桌鼓达人",
"title": "VIVO-桌鼓达人",
"desc": "合作客户案例"
},
{
"src": "video/兵马俑博物馆-青铜车马复原.mp4",
"preview": "兵马俑博物馆-青铜车马复原",
"title": "兵马俑博物馆-青铜车马复原",
"desc": "合作客户案例"
},
{
"src": "video/大空间侏罗纪-MR虚拟实结合.mp4",
"preview": "大空间侏罗纪-MR虚拟实结合",
"title": "大空间侏罗纪-MR虚拟实结合",
"desc": "合作客户案例"
},
{
"src": "video/山海经-灵气复苏.mp4",
"preview": "山海经-灵气复苏",
"title": "山海经-灵气复苏",
"desc": "合作客户案例"
},
{
"src": "video/VR大空间-秦始皇陵.mp4",
"preview": "大空间-秦始皇陵",
"title": "大空间-秦始皇陵",
"desc": "合作客户案例"
},
{
"src": "video/MR丧尸入侵现实.mp4",
"preview": "MR丧尸入侵现实",
"title": "MR丧尸入侵现实",
"desc": "合作客户案例"
},
{
"src": "video/游船MR-大空间娱乐.mp4",
"preview": "游船MR-大空间娱乐",
"title": "游船MR-大空间娱乐",
"desc": "合作客户案例"
},
{
"src": "video/XR多人互动展览馆.mp4",
"preview": "XR多人互动展览馆",
"title": "XR多人互动展览馆",
"desc": "XR多人互动展览馆"
},
{
"src": "video/大空间-中国家风.mp4",
"preview": "大空间-传统家风文明化",
"title": "大空间-传统家风文明化",
"desc": "大空间-传统家风文明化"
},
{
"src": "video/超清VR大空间-古堡漫游.mp4",
"preview": "超清VR大空间-古堡漫游",
"title": "超清VR大空间-古堡漫游",
"desc": "合作客户案例"
},
{
"src": "video/超清VR-坐式观览.mp4",
"preview": "超清VR-坐式观览",
"title": "超清VR-坐式观览",
"desc": "合作客户案例"
}
]

View File

@ -1,158 +0,0 @@
[
{
"src": "video/数字企业-MR园区展示制作.mp4",
"preview": "数字企业-MR园区展示制作",
"title": "Digital Enterprise - MR Park Display Production",
"desc": "Client Case Study"
},
{
"src": "video/智慧文旅-MR大空间制作.mp4",
"preview": "智慧文旅-MR大空间制作",
"title": "Smart Cultural Tourism - MR Large Space Production",
"desc": "Client Case Study"
},
{
"src": "video/XR游戏-定制游戏制作.mp4",
"preview": "XR游戏-定制游戏制作",
"title": "XR Game - Custom Game Production",
"desc": "Client Case Study"
},
{
"src": "video/房产土地-VR可移动观览制作.mp4",
"preview": "房产土地-VR可移动观览制作",
"title": "Real Estate - VR Mobile Viewing Production",
"desc": "Client Case Study"
},
{
"src": "video/MR古建筑.mp4",
"preview": "MR古建筑",
"title": "MR Ancient Architecture",
"desc": "Client Case Study"
},
{
"src": "video/国博MR展示.mp4",
"preview": "国博MR展示",
"title": "National Museum MR Display",
"desc": "Client Case Study"
},
{
"src": "video/传统复兴-文化传承教育.mp4",
"preview": "传统复兴-文化传承教育",
"title": "Traditional Revival - Cultural Heritage Education",
"desc": "Client Case Study"
},
{
"src": "video/生产制造工农业-展示制作.mp4",
"preview": "生产制造工农业-展示制作",
"title": "Manufacturing & Agriculture - Display Production",
"desc": "Client Case Study"
},
{
"src": "video/建筑家庭·实景装修制作.mp4",
"preview": "建筑家庭·实景装修制作",
"title": "Architecture & Home - Real Scene Decoration",
"desc": "Client Case Study"
},
{
"src": "video/Mobile应用移植.mp4",
"preview": "Mobile应用移植",
"title": "Mobile Application Porting",
"desc": "Client Case Study"
},
{
"src": "video/VR博物馆-互动式展示制作.mp4",
"preview": "VR博物馆-互动式展示制作",
"title": "VR Museum - Interactive Display Production",
"desc": "Client Case Study"
},
{
"src": "video/XR课件-生物课程教学.mp4",
"preview": "XR课件-生物课程教学",
"title": "XR Courseware - Biology Teaching",
"desc": "Client Case Study"
},
{
"src": "video/游戏定制-XR塔防游戏.mp4",
"preview": "游戏定制-XR塔防游戏",
"title": "Game Customization - XR Tower Defense",
"desc": "Client Case Study"
},
{
"src": "video/3D数字人-AR全息演绎.mp4",
"preview": "3D数字人-AR全息演绎",
"title": "3D Digital Human - AR Holographic Performance",
"desc": "Client Case Study"
},
{
"src": "video/AR桌游-线下社交.mp4",
"preview": "AR桌游-线下社交",
"title": "AR Board Game - Offline Social",
"desc": "Client Case Study"
},
{
"src": "video/VIVO-桌鼓达人.mp4",
"preview": "VIVO-桌鼓达人",
"title": "VIVO - Table Drum Master",
"desc": "Client Case Study"
},
{
"src": "video/兵马俑博物馆-青铜车马复原.mp4",
"preview": "兵马俑博物馆-青铜车马复原",
"title": "Terracotta Warriors Museum - Bronze Chariot Restoration",
"desc": "Client Case Study"
},
{
"src": "video/大空间侏罗纪-MR虚拟实结合.mp4",
"preview": "大空间侏罗纪-MR虚拟实结合",
"title": "Large Space Jurassic - MR Virtual-Reality Fusion",
"desc": "Client Case Study"
},
{
"src": "video/山海经-灵气复苏.mp4",
"preview": "山海经-灵气复苏",
"title": "Classic of Mountains and Seas - Spiritual Revival",
"desc": "Client Case Study"
},
{
"src": "video/VR大空间-秦始皇陵.mp4",
"preview": "大空间-秦始皇陵",
"title": "Large Space - Mausoleum of the First Qin Emperor",
"desc": "Client Case Study"
},
{
"src": "video/MR丧尸入侵现实.mp4",
"preview": "MR丧尸入侵现实",
"title": "MR Zombie Invasion Reality",
"desc": "Client Case Study"
},
{
"src": "video/游船MR-大空间娱乐.mp4",
"preview": "游船MR-大空间娱乐",
"title": "Cruise Ship MR - Large Space Entertainment",
"desc": "Client Case Study"
},
{
"src": "video/XR多人互动展览馆.mp4",
"preview": "XR多人互动展览馆",
"title": "XR Multiplayer Interactive Exhibition Hall",
"desc": "XR Multiplayer Interactive Exhibition Hall"
},
{
"src": "video/大空间-中国家风.mp4",
"preview": "大空间-传统家风文明化",
"title": "Large Space - Traditional Chinese Family Values",
"desc": "Large Space - Traditional Chinese Family Values"
},
{
"src": "video/超清VR大空间-古堡漫游.mp4",
"preview": "Ultra-clear VR large space-castle roaming",
"title": "超清VR大空间-古堡漫游",
"desc": "Client Case Study"
},
{
"src": "video/超清VR-坐式观览.mp4",
"preview": "超清VR-坐式观览",
"title": "Ultra-clear VR- sitting view",
"desc": "Client Case Study"
}
]

View File

@ -389,9 +389,6 @@ header.scrolled {
object-fit: contain;
filter: grayscale(100%);
transition: var(--transition);
}
.partner-logo:hover img {
filter: grayscale(0%);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 436 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 475 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 323 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 333 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 307 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 743 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 519 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 270 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 492 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -21,7 +21,7 @@ async function loadLanguage(lang) {
// Reload services and cases with new language
loadServices();
loadCases();
loadCases(); // 重新加载视频以更新语言
} catch (err) {
console.error("加载语言文件失败:", err);
}
@ -92,10 +92,17 @@ async function loadServices() {
async function loadPartners() {
try {
const res = await fetch("config/partners.json");
const partners = await res.json();
// 从后台 API 获取合作伙伴数据
const res = await fetch("/api/partners/public");
const result = await res.json();
if (!result.success) {
throw new Error(result.message || '获取合作伙伴数据失败');
}
const partners = result.data;
const grid = document.getElementById("partnersGrid");
grid.innerHTML = ''; // 清空现有内容
partners.forEach(item => {
const partnerCard = document.createElement("div");
@ -122,6 +129,9 @@ async function loadPartners() {
});
} catch (err) {
console.error("加载合作伙伴失败:", err);
// 显示友好的错误提示
const grid = document.getElementById("partnersGrid");
grid.innerHTML = '<p style="text-align: center; color: #666;">暂无合作伙伴数据</p>';
}
}
@ -153,25 +163,34 @@ document.addEventListener("DOMContentLoaded", () => {
async function loadCases() {
try {
// 读取 config/video-{lang}.json
const res = await fetch(`config/video-${currentLang}.json`);
const cases = await res.json();
// 从后台 API 获取视频数据
const res = await fetch('/api/videos/public');
const result = await res.json();
if (!result.success) {
throw new Error(result.message || '获取视频数据失败');
}
const cases = result.data;
const grid = document.getElementById("casesGrid");
grid.innerHTML = ''; // Clear existing content
// 根据当前语言选择标题和描述
cases.forEach(item => {
const card = document.createElement("div");
card.className = "case-card";
card.setAttribute("data-video", item.src);
const previewPath = "img/preview/" + item.preview + ".png";
// 用 preview 图片当封面
card.setAttribute("data-video", item.video_url);
const title = currentLang === 'cn' ? item.title_cn : item.title_en;
const desc = currentLang === 'cn' ? item.desc_cn : item.desc_en;
// 使用封面图
card.innerHTML = `
<img src="${previewPath}" alt="${item.title}" class="case-preview">
<img src="${item.cover_url}" alt="${title}" class="case-preview">
<div class="play-button"><i class="fa-solid fa-play"></i></div>
<div class="case-overlay">
<h3>${item.title}</h3>
<p>${item.desc}</p>
<h3>${title}</h3>
<p>${desc || ''}</p>
</div>
`;
@ -182,6 +201,9 @@ async function loadCases() {
bindVideoModal();
} catch (err) {
console.error("加载案例失败:", err);
// 显示友好的错误提示
const grid = document.getElementById("casesGrid");
grid.innerHTML = '<p style="text-align: center; color: #666;">暂无视频数据</p>';
}
}

View File

@ -1,9 +0,0 @@
All configurations of the current website are dynamically read. By reading the configurations in the config directory,
Main.js in the js directory reads all the corresponding configuration files by distinguishing languages.
Img/preview in img directory stores previews of videos.
Img/logo in img directory stores the logo pictures of the partners.
The img/CARD directory under the IMG directory stores the pictures displayed by the website cards.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.