1
29
.claude/settings.local.json
Normal 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
@ -0,0 +1,12 @@
|
||||
node_modules/
|
||||
.env
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
25
backend/config/database.js
Normal 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;
|
||||
136
backend/database/database.sql
Normal 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
@ -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();
|
||||
23
backend/middleware/auth.js
Normal 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
29
backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
875
backend/public/css/admin.css
Normal 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
@ -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">×</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')">×</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
174
backend/routes/auth.js
Normal 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
@ -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
@ -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
@ -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
@ -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`);
|
||||
});
|
||||
@ -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": ""
|
||||
}
|
||||
]
|
||||
@ -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": "合作客户案例"
|
||||
}
|
||||
]
|
||||
@ -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"
|
||||
}
|
||||
]
|
||||
@ -389,9 +389,6 @@ header.scrolled {
|
||||
object-fit: contain;
|
||||
filter: grayscale(100%);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.partner-logo:hover img {
|
||||
filter: grayscale(0%);
|
||||
}
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 260 KiB |
|
Before Width: | Height: | Size: 318 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 361 KiB |
|
Before Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 339 KiB |
|
Before Width: | Height: | Size: 436 KiB |
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 475 KiB |
|
Before Width: | Height: | Size: 323 KiB |
|
Before Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 333 KiB |
|
Before Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 3.1 MiB |
|
Before Width: | Height: | Size: 307 KiB |
|
Before Width: | Height: | Size: 743 KiB |
|
Before Width: | Height: | Size: 3.1 MiB |
|
Before Width: | Height: | Size: 207 KiB |
|
Before Width: | Height: | Size: 519 KiB |
|
Before Width: | Height: | Size: 270 KiB |
|
Before Width: | Height: | Size: 259 KiB |
|
Before Width: | Height: | Size: 375 KiB |
|
Before Width: | Height: | Size: 492 KiB |
|
Before Width: | Height: | Size: 320 KiB |
|
Before Width: | Height: | Size: 2.0 MiB |
|
Before Width: | Height: | Size: 433 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.2 MiB |
46
js/main.js
@ -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>';
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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.
|
||||