初始化
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
es2021: true,
|
||||
node: true,
|
||||
},
|
||||
extends: ['standard'],
|
||||
parserOptions: {
|
||||
ecmaVersion: 12,
|
||||
sourceType: 'module',
|
||||
},
|
||||
rules: {
|
||||
curly: 'off',
|
||||
camelcase: 'off',
|
||||
'no-case-declarations': 'off',
|
||||
semi: ['error', 'always'],
|
||||
'comma-dangle': ['error', 'only-multiline'],
|
||||
'space-before-function-paren': 'off',
|
||||
'no-tabs': 'off',
|
||||
indent: 'off',
|
||||
eqeqeq: 'off',
|
||||
'no-async-promise-executor': 'off',
|
||||
'no-control-regex': 'off',
|
||||
'prefer-promise-reject-errors': 'off',
|
||||
},
|
||||
};
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
/test
|
||||
/package-lock.json
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variables file
|
||||
.env
|
||||
|
||||
# next.js build output
|
||||
.next
|
||||
|
||||
old/
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"printWidth": 200,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"arrowParens": "avoid",
|
||||
"useTabs": false,
|
||||
"tabWidth": 2,
|
||||
"semi": true
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
# Pixiv 后端服务
|
||||
|
||||
这是一个优雅的 Pixiv 后端服务架构,提供作品信息获取、作者信息查询、文件下载等功能。
|
||||
|
||||
## 🏗️ 项目架构
|
||||
|
||||
```
|
||||
backend/
|
||||
├── server.js # 主服务器文件
|
||||
├── core.js # 核心后端逻辑
|
||||
├── auth.js # 认证模块
|
||||
├── config.js # 代理配置
|
||||
├── start.js # 启动脚本
|
||||
├── test-login.js # 登录测试脚本
|
||||
├── middleware/ # 中间件
|
||||
│ ├── auth.js # 认证中间件
|
||||
│ └── errorHandler.js # 错误处理中间件
|
||||
├── routes/ # 路由模块
|
||||
│ ├── auth.js # 认证路由
|
||||
│ ├── artwork.js # 作品路由
|
||||
│ ├── artist.js # 作者路由
|
||||
│ └── download.js # 下载路由
|
||||
├── services/ # 服务层
|
||||
│ ├── artwork.js # 作品服务
|
||||
│ ├── artist.js # 作者服务
|
||||
│ └── download.js # 下载服务
|
||||
└── utils/ # 工具类
|
||||
└── response.js # 响应工具
|
||||
```
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. 启动服务器
|
||||
|
||||
```bash
|
||||
# 开发模式
|
||||
npm run dev
|
||||
|
||||
# 生产模式
|
||||
npm start
|
||||
|
||||
# 或直接运行
|
||||
node backend/start.js
|
||||
```
|
||||
|
||||
### 3. 测试登录
|
||||
|
||||
```bash
|
||||
node backend/test-login.js
|
||||
```
|
||||
|
||||
## 📡 API 接口
|
||||
|
||||
### 认证相关
|
||||
|
||||
- `GET /api/auth/status` - 获取登录状态
|
||||
- `GET /api/auth/login-url` - 获取登录URL
|
||||
- `POST /api/auth/callback` - 处理登录回调
|
||||
- `POST /api/auth/relogin` - 重新登录
|
||||
- `POST /api/auth/logout` - 登出
|
||||
|
||||
### 作品相关
|
||||
|
||||
- `GET /api/artwork/:id` - 获取作品详情
|
||||
- `GET /api/artwork/:id/preview` - 获取作品预览
|
||||
- `GET /api/artwork/:id/images` - 获取作品图片URL
|
||||
- `GET /api/artwork/search` - 搜索作品
|
||||
|
||||
### 作者相关
|
||||
|
||||
- `GET /api/artist/:id` - 获取作者信息
|
||||
- `GET /api/artist/:id/artworks` - 获取作者作品列表
|
||||
- `GET /api/artist/:id/following` - 获取作者关注列表
|
||||
- `GET /api/artist/:id/followers` - 获取作者粉丝列表
|
||||
- `POST /api/artist/:id/follow` - 关注/取消关注作者
|
||||
|
||||
### 下载相关
|
||||
|
||||
- `POST /api/download/artwork/:id` - 下载单个作品
|
||||
- `POST /api/download/artworks` - 批量下载作品
|
||||
- `POST /api/download/artist/:id` - 下载作者作品
|
||||
- `GET /api/download/progress/:taskId` - 获取下载进度
|
||||
- `DELETE /api/download/cancel/:taskId` - 取消下载任务
|
||||
- `GET /api/download/history` - 获取下载历史
|
||||
|
||||
## 🔧 配置说明
|
||||
|
||||
### 代理配置
|
||||
|
||||
在 `config.js` 中配置代理设置:
|
||||
|
||||
```javascript
|
||||
const proxyConfig = {
|
||||
system: {
|
||||
host: '127.0.0.1',
|
||||
port: 7897,
|
||||
protocol: 'http'
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
|
||||
- `PORT` - 服务器端口 (默认: 3000)
|
||||
- `NODE_ENV` - 运行环境 (development/production)
|
||||
- `FRONTEND_URL` - 前端URL (用于CORS)
|
||||
|
||||
## 📁 文件结构说明
|
||||
|
||||
### 核心模块
|
||||
|
||||
- **server.js**: 主服务器类,负责初始化、配置和启动服务器
|
||||
- **core.js**: 核心后端逻辑,管理认证状态和配置
|
||||
- **auth.js**: 认证模块,处理OAuth2.0登录流程
|
||||
|
||||
### 中间件
|
||||
|
||||
- **auth.js**: 认证中间件,验证用户登录状态
|
||||
- **errorHandler.js**: 全局错误处理中间件
|
||||
|
||||
### 路由模块
|
||||
|
||||
- **auth.js**: 认证相关路由
|
||||
- **artwork.js**: 作品相关路由
|
||||
- **artist.js**: 作者相关路由
|
||||
- **download.js**: 下载相关路由
|
||||
|
||||
### 服务层
|
||||
|
||||
- **artwork.js**: 作品服务,处理作品API调用
|
||||
- **artist.js**: 作者服务,处理作者API调用
|
||||
- **download.js**: 下载服务,处理文件下载
|
||||
|
||||
### 工具类
|
||||
|
||||
- **response.js**: 统一API响应格式工具
|
||||
|
||||
## 🎯 主要功能
|
||||
|
||||
### 1. 作品信息获取
|
||||
- 获取作品详细信息
|
||||
- 获取作品预览信息
|
||||
- 获取作品图片URL
|
||||
- 搜索作品
|
||||
|
||||
### 2. 作者信息查询
|
||||
- 获取作者基本信息
|
||||
- 获取作者作品列表
|
||||
- 获取作者关注/粉丝列表
|
||||
- 关注/取消关注作者
|
||||
|
||||
### 3. 文件下载
|
||||
- 下载单个作品
|
||||
- 批量下载作品
|
||||
- 下载作者作品
|
||||
- 下载进度跟踪
|
||||
- 下载历史记录
|
||||
|
||||
### 4. 认证管理
|
||||
- OAuth2.0 登录流程
|
||||
- 自动刷新令牌
|
||||
- 登录状态管理
|
||||
|
||||
## 🔒 安全特性
|
||||
|
||||
- 统一的错误处理
|
||||
- 请求参数验证
|
||||
- 认证中间件保护
|
||||
- CORS 配置
|
||||
- 代理支持
|
||||
|
||||
## 📊 监控和日志
|
||||
|
||||
- 请求日志记录
|
||||
- 错误日志记录
|
||||
- 健康检查接口
|
||||
- 下载进度跟踪
|
||||
|
||||
## 🛠️ 开发指南
|
||||
|
||||
### 添加新路由
|
||||
|
||||
1. 在 `routes/` 目录下创建新的路由文件
|
||||
2. 在 `server.js` 中注册路由
|
||||
3. 添加相应的中间件保护
|
||||
|
||||
### 添加新服务
|
||||
|
||||
1. 在 `services/` 目录下创建新的服务类
|
||||
2. 实现相应的业务逻辑
|
||||
3. 在路由中调用服务
|
||||
|
||||
### 添加新中间件
|
||||
|
||||
1. 在 `middleware/` 目录下创建新的中间件文件
|
||||
2. 在 `server.js` 中注册中间件
|
||||
|
||||
## 📝 注意事项
|
||||
|
||||
1. 确保代理配置正确
|
||||
2. 首次使用需要登录获取访问令牌
|
||||
3. 下载功能需要足够的磁盘空间
|
||||
4. 建议在生产环境中使用PM2等进程管理器
|
||||
|
||||
## 🤝 贡献
|
||||
|
||||
欢迎提交 Issue 和 Pull Request!
|
||||
|
||||
## �� 许可证
|
||||
|
||||
MIT License
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
const axios = require('axios');
|
||||
const Crypto = require('crypto');
|
||||
const { Base64 } = require('js-base64');
|
||||
const { stringify } = require('qs');
|
||||
const moment = require('moment');
|
||||
const { ProxyAgent } = require('proxy-agent');
|
||||
|
||||
// OAuth 2.0 配置
|
||||
const CLIENT_ID = 'MOBrBDS8blbauoSck0ZfDbtuzpyT';
|
||||
const CLIENT_SECRET = 'lsACyCD94FhDUtGTXi3QzcFE2uU1hqtDaKeqrdwj';
|
||||
const REDIRECT_URI = 'https://app-api.pixiv.net/web/v1/users/auth/pixiv/callback';
|
||||
const LOGIN_URL = 'https://app-api.pixiv.net/web/v1/login';
|
||||
const HASH_SECRET = '28c1fdd170a5204386cb1313c7077b34f83e4aaf4aa829ce78c231e05b0bae2c';
|
||||
|
||||
class PixivAuth {
|
||||
constructor(proxy = null) {
|
||||
this.accessToken = null;
|
||||
this.refreshToken = null;
|
||||
this.user = null;
|
||||
this.proxy = proxy;
|
||||
|
||||
// 创建 axios 实例,支持代理
|
||||
this.axiosInstance = this.createAxiosInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建支持代理的 axios 实例
|
||||
*/
|
||||
createAxiosInstance() {
|
||||
const config = {
|
||||
timeout: 30000, // 30秒超时
|
||||
headers: this.getDefaultHeaders()
|
||||
};
|
||||
|
||||
// 如果设置了代理,添加代理配置
|
||||
if (this.proxy) {
|
||||
console.log('使用代理:', this.proxy);
|
||||
config.httpsAgent = new ProxyAgent(this.proxy);
|
||||
} else {
|
||||
// 尝试使用系统代理
|
||||
const systemProxy = process.env.HTTP_PROXY || process.env.HTTPS_PROXY || process.env.http_proxy || process.env.https_proxy;
|
||||
if (systemProxy) {
|
||||
console.log('使用系统代理:', systemProxy);
|
||||
config.httpsAgent = new ProxyAgent(systemProxy);
|
||||
}
|
||||
}
|
||||
|
||||
return axios.create(config);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置代理
|
||||
*/
|
||||
setProxy(proxy) {
|
||||
this.proxy = proxy;
|
||||
this.axiosInstance = this.createAxiosInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认头部信息
|
||||
*/
|
||||
getDefaultHeaders() {
|
||||
const datetime = moment().format();
|
||||
return {
|
||||
'App-OS': 'android',
|
||||
'Accept-Language': 'en-us',
|
||||
'App-OS-Version': '9.0',
|
||||
'App-Version': '5.0.234',
|
||||
'User-Agent': 'PixivAndroidApp/5.0.234 (Android 9.0; Pixel 3)',
|
||||
'X-Client-Time': datetime,
|
||||
'X-Client-Hash': Crypto.createHash('md5').update(`${datetime}${HASH_SECRET}`).digest('hex')
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 PKCE 参数
|
||||
*/
|
||||
generatePKCE() {
|
||||
const codeVerifier = Base64.fromUint8Array(Crypto.randomBytes(32), true);
|
||||
const codeChallenge = Base64.encodeURI(Crypto.createHash('sha256').update(codeVerifier).digest());
|
||||
|
||||
return {
|
||||
code_verifier: codeVerifier,
|
||||
code_challenge: codeChallenge
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取登录URL
|
||||
*/
|
||||
getLoginUrl() {
|
||||
const pkce = this.generatePKCE();
|
||||
|
||||
const params = {
|
||||
code_challenge: pkce.code_challenge,
|
||||
code_challenge_method: 'S256',
|
||||
client: 'pixiv-android'
|
||||
};
|
||||
|
||||
const loginUrl = `${LOGIN_URL}?${stringify(params)}`;
|
||||
|
||||
return {
|
||||
login_url: loginUrl,
|
||||
code_verifier: pkce.code_verifier
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用授权码获取访问令牌
|
||||
*/
|
||||
async getAccessToken(code, codeVerifier) {
|
||||
try {
|
||||
console.log('正在获取访问令牌...');
|
||||
console.log('Code:', code);
|
||||
console.log('Code Verifier:', codeVerifier);
|
||||
|
||||
const data = {
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
code: code,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri: REDIRECT_URI,
|
||||
grant_type: 'authorization_code',
|
||||
include_policy: true
|
||||
};
|
||||
|
||||
console.log('请求数据:', data);
|
||||
|
||||
const headers = {
|
||||
...this.getDefaultHeaders(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
};
|
||||
|
||||
console.log('请求头部:', headers);
|
||||
|
||||
const response = await this.axiosInstance.post('https://oauth.secure.pixiv.net/auth/token',
|
||||
stringify(data),
|
||||
{ headers }
|
||||
);
|
||||
|
||||
console.log('响应状态:', response.status);
|
||||
console.log('响应数据:', JSON.stringify(response.data, null, 2));
|
||||
|
||||
const tokenData = response.data.response;
|
||||
|
||||
this.accessToken = tokenData.access_token;
|
||||
this.refreshToken = tokenData.refresh_token;
|
||||
this.user = tokenData.user;
|
||||
|
||||
console.log('获取访问令牌成功');
|
||||
return {
|
||||
success: true,
|
||||
access_token: tokenData.access_token,
|
||||
refresh_token: tokenData.refresh_token,
|
||||
user: tokenData.user
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取访问令牌失败:');
|
||||
console.error('错误对象:', error);
|
||||
console.error('响应状态:', error.response?.status);
|
||||
console.error('响应数据:', error.response?.data);
|
||||
console.error('错误消息:', error.message);
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data || error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用刷新令牌更新访问令牌
|
||||
*/
|
||||
async refreshAccessToken(refreshToken) {
|
||||
try {
|
||||
console.log('正在刷新访问令牌...');
|
||||
|
||||
const data = {
|
||||
client_id: CLIENT_ID,
|
||||
client_secret: CLIENT_SECRET,
|
||||
get_secure_url: true,
|
||||
include_policy: true,
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken
|
||||
};
|
||||
|
||||
const headers = {
|
||||
...this.getDefaultHeaders(),
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
};
|
||||
|
||||
const response = await this.axiosInstance.post('https://oauth.secure.pixiv.net/auth/token',
|
||||
stringify(data),
|
||||
{ headers }
|
||||
);
|
||||
|
||||
const tokenData = response.data.response;
|
||||
|
||||
this.accessToken = tokenData.access_token;
|
||||
this.refreshToken = tokenData.refresh_token;
|
||||
|
||||
console.log('刷新访问令牌成功');
|
||||
return {
|
||||
success: true,
|
||||
access_token: tokenData.access_token,
|
||||
refresh_token: tokenData.refresh_token
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('刷新访问令牌失败:', error.response?.data || error.message);
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data || error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户信息
|
||||
*/
|
||||
async getUserInfo() {
|
||||
if (!this.accessToken) {
|
||||
return { success: false, error: '未登录' };
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = {
|
||||
...this.getDefaultHeaders(),
|
||||
'Authorization': `Bearer ${this.accessToken}`
|
||||
};
|
||||
|
||||
const response = await this.axiosInstance.get('https://app-api.pixiv.net/v1/user/me', {
|
||||
headers
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
user: response.data.user
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('获取用户信息失败:', error.response?.data || error.message);
|
||||
return {
|
||||
success: false,
|
||||
error: error.response?.data || error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
logout() {
|
||||
this.accessToken = null;
|
||||
this.refreshToken = null;
|
||||
this.user = null;
|
||||
console.log('已登出');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前状态
|
||||
*/
|
||||
getStatus() {
|
||||
return {
|
||||
isLoggedIn: !!this.accessToken,
|
||||
user: this.user,
|
||||
hasRefreshToken: !!this.refreshToken
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PixivAuth;
|
||||
@@ -0,0 +1,36 @@
|
||||
// 代理配置
|
||||
const proxyConfig = {
|
||||
// 系统代理配置
|
||||
system: {
|
||||
host: '127.0.0.1',
|
||||
port: 7897,
|
||||
protocol: 'http'
|
||||
},
|
||||
|
||||
// 代理URL格式
|
||||
get proxyUrl() {
|
||||
return `${this.system.protocol}://${this.system.host}:${this.system.port}`;
|
||||
},
|
||||
|
||||
// 环境变量设置
|
||||
setEnvironmentVariables() {
|
||||
process.env.HTTP_PROXY = this.proxyUrl;
|
||||
process.env.HTTPS_PROXY = this.proxyUrl;
|
||||
process.env.http_proxy = this.proxyUrl;
|
||||
process.env.https_proxy = this.proxyUrl;
|
||||
|
||||
console.log('代理环境变量已设置:', this.proxyUrl);
|
||||
},
|
||||
|
||||
// 清除环境变量
|
||||
clearEnvironmentVariables() {
|
||||
delete process.env.HTTP_PROXY;
|
||||
delete process.env.HTTPS_PROXY;
|
||||
delete process.env.http_proxy;
|
||||
delete process.env.https_proxy;
|
||||
|
||||
console.log('代理环境变量已清除');
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = proxyConfig;
|
||||
+258
@@ -0,0 +1,258 @@
|
||||
const Fse = require('fs-extra');
|
||||
const Path = require('path');
|
||||
const PixivAuth = require('./auth');
|
||||
|
||||
// 配置文件路径
|
||||
const CONFIG_FILE_DIR = require('appdata-path').getAppDataPath('pxder');
|
||||
const CONFIG_FILE = Path.resolve(CONFIG_FILE_DIR, 'config.json');
|
||||
|
||||
// 默认配置
|
||||
const defaultConfig = {
|
||||
download: {
|
||||
thread: 5,
|
||||
timeout: 30,
|
||||
path: null
|
||||
},
|
||||
refresh_token: null,
|
||||
access_token: null,
|
||||
user: null,
|
||||
proxy: null
|
||||
};
|
||||
|
||||
class PixivBackend {
|
||||
constructor() {
|
||||
this.config = null;
|
||||
this.auth = null;
|
||||
this.isLoggedIn = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化后端
|
||||
*/
|
||||
async init() {
|
||||
console.log('正在初始化 Pixiv 后端...');
|
||||
|
||||
// 初始化配置
|
||||
this.initConfig();
|
||||
this.config = this.readConfig();
|
||||
|
||||
// 创建认证实例,传入代理配置
|
||||
this.auth = new PixivAuth(this.config.proxy);
|
||||
|
||||
// 检查登录状态
|
||||
if (this.config.refresh_token) {
|
||||
console.log('检测到已保存的登录信息,正在验证...');
|
||||
await this.relogin();
|
||||
} else {
|
||||
console.log('未检测到登录信息,需要先登录');
|
||||
}
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化配置文件
|
||||
*/
|
||||
initConfig() {
|
||||
Fse.ensureDirSync(CONFIG_FILE_DIR);
|
||||
if (!Fse.existsSync(CONFIG_FILE)) {
|
||||
Fse.writeJSONSync(CONFIG_FILE, defaultConfig);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取配置
|
||||
*/
|
||||
readConfig() {
|
||||
try {
|
||||
const config = Fse.readJsonSync(CONFIG_FILE);
|
||||
// 合并默认配置
|
||||
return { ...defaultConfig, ...config };
|
||||
} catch (error) {
|
||||
console.error('读取配置文件失败:', error.message);
|
||||
return { ...defaultConfig };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存配置
|
||||
*/
|
||||
saveConfig() {
|
||||
try {
|
||||
Fse.writeJsonSync(CONFIG_FILE, this.config);
|
||||
console.log('配置已保存');
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取登录URL
|
||||
*/
|
||||
getLoginUrl() {
|
||||
const loginData = this.auth.getLoginUrl();
|
||||
this.config.code_verifier = loginData.code_verifier;
|
||||
this.saveConfig();
|
||||
|
||||
return {
|
||||
login_url: loginData.login_url,
|
||||
code_verifier: loginData.code_verifier
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理登录回调
|
||||
*/
|
||||
async handleLoginCallback(code) {
|
||||
try {
|
||||
console.log('正在处理登录回调...');
|
||||
|
||||
if (!this.config.code_verifier) {
|
||||
throw new Error('缺少 code_verifier,请重新获取登录URL');
|
||||
}
|
||||
|
||||
// 使用新的认证模块进行登录
|
||||
const result = await this.auth.getAccessToken(code, this.config.code_verifier);
|
||||
|
||||
if (result.success) {
|
||||
// 保存登录信息
|
||||
this.config.refresh_token = result.refresh_token;
|
||||
this.config.access_token = result.access_token;
|
||||
this.config.user = result.user;
|
||||
|
||||
// 清理临时数据
|
||||
delete this.config.code_verifier;
|
||||
|
||||
this.saveConfig();
|
||||
this.isLoggedIn = true;
|
||||
|
||||
console.log(`登录成功!用户: ${result.user.account}`);
|
||||
return {
|
||||
success: true,
|
||||
user: result.user
|
||||
};
|
||||
} else {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('登录失败:', error.message);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新登录(使用保存的 refresh_token)
|
||||
*/
|
||||
async relogin() {
|
||||
try {
|
||||
if (!this.config.refresh_token) {
|
||||
throw new Error('没有保存的登录信息');
|
||||
}
|
||||
|
||||
console.log('正在使用保存的登录信息重新登录...');
|
||||
|
||||
const result = await this.auth.refreshAccessToken(this.config.refresh_token);
|
||||
|
||||
if (result.success) {
|
||||
// 更新配置
|
||||
this.config.access_token = result.access_token;
|
||||
this.config.refresh_token = result.refresh_token;
|
||||
this.saveConfig();
|
||||
|
||||
this.isLoggedIn = true;
|
||||
console.log('重新登录成功!');
|
||||
|
||||
return { success: true };
|
||||
} else {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('重新登录失败:', error.message);
|
||||
// 清除无效的登录信息
|
||||
this.config.refresh_token = null;
|
||||
this.config.access_token = null;
|
||||
this.config.user = null;
|
||||
this.saveConfig();
|
||||
this.isLoggedIn = false;
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
logout() {
|
||||
this.auth.logout();
|
||||
this.config.refresh_token = null;
|
||||
this.config.access_token = null;
|
||||
this.config.user = null;
|
||||
this.isLoggedIn = false;
|
||||
|
||||
this.saveConfig();
|
||||
console.log('已登出');
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取登录状态
|
||||
*/
|
||||
getLoginStatus() {
|
||||
const status = this.auth.getStatus();
|
||||
return {
|
||||
isLoggedIn: status.isLoggedIn,
|
||||
username: this.config.user?.account,
|
||||
user_id: this.config.user?.id
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置下载路径
|
||||
*/
|
||||
setDownloadPath(path) {
|
||||
this.config.download.path = path;
|
||||
this.saveConfig();
|
||||
console.log(`下载路径已设置为: ${path}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取配置信息
|
||||
*/
|
||||
getConfig() {
|
||||
return {
|
||||
download: this.config.download,
|
||||
proxy: this.config.proxy,
|
||||
isLoggedIn: this.isLoggedIn
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置代理
|
||||
*/
|
||||
setProxy(proxy) {
|
||||
this.config.proxy = proxy;
|
||||
this.auth.setProxy(proxy);
|
||||
this.saveConfig();
|
||||
console.log(`代理已设置为: ${proxy}`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取认证实例(用于后续API调用)
|
||||
*/
|
||||
getAuth() {
|
||||
return this.auth;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = PixivBackend;
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* 认证中间件
|
||||
*/
|
||||
const authMiddleware = (req, res, next) => {
|
||||
try {
|
||||
// 检查后端是否已登录
|
||||
if (!req.backend || !req.backend.isLoggedIn) {
|
||||
return res.status(401).json({
|
||||
error: true,
|
||||
message: 'Authentication required',
|
||||
code: 'AUTH_REQUIRED'
|
||||
});
|
||||
}
|
||||
|
||||
// 检查访问令牌是否有效
|
||||
const auth = req.backend.getAuth();
|
||||
if (!auth || !auth.accessToken) {
|
||||
return res.status(401).json({
|
||||
error: true,
|
||||
message: 'Invalid access token',
|
||||
code: 'INVALID_TOKEN'
|
||||
});
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 可选的认证中间件(不强制要求登录)
|
||||
*/
|
||||
const optionalAuthMiddleware = (req, res, next) => {
|
||||
try {
|
||||
// 如果后端已登录,将用户信息添加到请求对象
|
||||
if (req.backend && req.backend.isLoggedIn) {
|
||||
req.user = req.backend.config.user;
|
||||
}
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
authMiddleware,
|
||||
optionalAuthMiddleware
|
||||
};
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 全局错误处理中间件
|
||||
*/
|
||||
const errorHandler = (err, req, res, next) => {
|
||||
console.error('错误详情:', err);
|
||||
|
||||
// 默认错误信息
|
||||
let statusCode = 500;
|
||||
let message = 'Internal Server Error';
|
||||
let details = null;
|
||||
|
||||
// 根据错误类型设置状态码和消息
|
||||
if (err.name === 'ValidationError') {
|
||||
statusCode = 400;
|
||||
message = 'Validation Error';
|
||||
details = err.message;
|
||||
} else if (err.name === 'UnauthorizedError') {
|
||||
statusCode = 401;
|
||||
message = 'Unauthorized';
|
||||
} else if (err.name === 'ForbiddenError') {
|
||||
statusCode = 403;
|
||||
message = 'Forbidden';
|
||||
} else if (err.name === 'NotFoundError') {
|
||||
statusCode = 404;
|
||||
message = 'Not Found';
|
||||
} else if (err.code === 'ENOTFOUND') {
|
||||
statusCode = 503;
|
||||
message = 'Service Unavailable';
|
||||
details = 'Network connection failed';
|
||||
} else if (err.code === 'ECONNREFUSED') {
|
||||
statusCode = 503;
|
||||
message = 'Service Unavailable';
|
||||
details = 'Connection refused';
|
||||
} else if (err.response) {
|
||||
// Axios 错误
|
||||
statusCode = err.response.status || 500;
|
||||
message = err.response.statusText || 'Request Failed';
|
||||
details = err.response.data;
|
||||
} else if (err.message) {
|
||||
message = err.message;
|
||||
}
|
||||
|
||||
// 构建错误响应
|
||||
const errorResponse = {
|
||||
error: true,
|
||||
message,
|
||||
statusCode,
|
||||
timestamp: new Date().toISOString(),
|
||||
path: req.originalUrl,
|
||||
method: req.method
|
||||
};
|
||||
|
||||
// 在开发环境下添加详细信息
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
errorResponse.details = details;
|
||||
errorResponse.stack = err.stack;
|
||||
}
|
||||
|
||||
res.status(statusCode).json(errorResponse);
|
||||
};
|
||||
|
||||
module.exports = { errorHandler };
|
||||
@@ -0,0 +1,225 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const ArtistService = require('../services/artist');
|
||||
|
||||
/**
|
||||
* 获取作者信息
|
||||
* GET /api/artist/:id
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id || isNaN(parseInt(id))) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid artist ID'
|
||||
});
|
||||
}
|
||||
|
||||
const artistService = new ArtistService(req.backend.getAuth());
|
||||
const result = await artistService.getArtistInfo(parseInt(id));
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取作者作品列表
|
||||
* GET /api/artist/:id/artworks
|
||||
*/
|
||||
router.get('/:id/artworks', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const {
|
||||
type = 'art',
|
||||
filter = 'for_ios',
|
||||
offset = 0,
|
||||
limit = 30
|
||||
} = req.query;
|
||||
|
||||
if (!id || isNaN(parseInt(id))) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid artist ID'
|
||||
});
|
||||
}
|
||||
|
||||
const artistService = new ArtistService(req.backend.getAuth());
|
||||
const result = await artistService.getArtistArtworks(parseInt(id), {
|
||||
type,
|
||||
filter,
|
||||
offset: parseInt(offset),
|
||||
limit: parseInt(limit)
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取作者关注列表
|
||||
* GET /api/artist/:id/following
|
||||
*/
|
||||
router.get('/:id/following', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const {
|
||||
restrict = 'public',
|
||||
offset = 0,
|
||||
limit = 30
|
||||
} = req.query;
|
||||
|
||||
if (!id || isNaN(parseInt(id))) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid artist ID'
|
||||
});
|
||||
}
|
||||
|
||||
const artistService = new ArtistService(req.backend.getAuth());
|
||||
const result = await artistService.getArtistFollowing(parseInt(id), {
|
||||
restrict,
|
||||
offset: parseInt(offset),
|
||||
limit: parseInt(limit)
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取作者粉丝列表
|
||||
* GET /api/artist/:id/followers
|
||||
*/
|
||||
router.get('/:id/followers', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const {
|
||||
offset = 0,
|
||||
limit = 30
|
||||
} = req.query;
|
||||
|
||||
if (!id || isNaN(parseInt(id))) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid artist ID'
|
||||
});
|
||||
}
|
||||
|
||||
const artistService = new ArtistService(req.backend.getAuth());
|
||||
const result = await artistService.getArtistFollowers(parseInt(id), {
|
||||
offset: parseInt(offset),
|
||||
limit: parseInt(limit)
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 关注/取消关注作者
|
||||
* POST /api/artist/:id/follow
|
||||
*/
|
||||
router.post('/:id/follow', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { action = 'follow' } = req.body;
|
||||
|
||||
if (!id || isNaN(parseInt(id))) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid artist ID'
|
||||
});
|
||||
}
|
||||
|
||||
if (!['follow', 'unfollow'].includes(action)) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid action. Must be "follow" or "unfollow"'
|
||||
});
|
||||
}
|
||||
|
||||
const artistService = new ArtistService(req.backend.getAuth());
|
||||
const result = await artistService.followArtist(parseInt(id), action);
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Artist ${action === 'follow' ? 'followed' : 'unfollowed'} successfully`
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,172 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const ArtworkService = require('../services/artwork');
|
||||
|
||||
/**
|
||||
* 搜索作品
|
||||
* GET /api/artwork/search
|
||||
*/
|
||||
router.get('/search', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
keyword,
|
||||
type = 'all',
|
||||
sort = 'date_desc',
|
||||
duration = 'all',
|
||||
offset = 0,
|
||||
limit = 30
|
||||
} = req.query;
|
||||
|
||||
if (!keyword) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Search keyword is required'
|
||||
});
|
||||
}
|
||||
|
||||
const artworkService = new ArtworkService(req.backend.getAuth());
|
||||
const result = await artworkService.searchArtworks({
|
||||
keyword,
|
||||
type,
|
||||
sort,
|
||||
duration,
|
||||
offset: parseInt(offset),
|
||||
limit: parseInt(limit)
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取作品详情
|
||||
* GET /api/artwork/:id
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { include_user = 'true', include_series = 'false' } = req.query;
|
||||
|
||||
if (!id || isNaN(parseInt(id))) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid artwork ID'
|
||||
});
|
||||
}
|
||||
|
||||
const artworkService = new ArtworkService(req.backend.getAuth());
|
||||
const result = await artworkService.getArtworkDetail(parseInt(id), {
|
||||
include_user: include_user === 'true',
|
||||
include_series: include_series === 'true'
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取作品预览信息
|
||||
* GET /api/artwork/:id/preview
|
||||
*/
|
||||
router.get('/:id/preview', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
if (!id || isNaN(parseInt(id))) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid artwork ID'
|
||||
});
|
||||
}
|
||||
|
||||
const artworkService = new ArtworkService(req.backend.getAuth());
|
||||
const result = await artworkService.getArtworkPreview(parseInt(id));
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取作品图片URL
|
||||
* GET /api/artwork/:id/images
|
||||
*/
|
||||
router.get('/:id/images', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { size = 'medium' } = req.query;
|
||||
|
||||
if (!id || isNaN(parseInt(id))) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid artwork ID'
|
||||
});
|
||||
}
|
||||
|
||||
const artworkService = new ArtworkService(req.backend.getAuth());
|
||||
const result = await artworkService.getArtworkImages(parseInt(id), size);
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,124 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* 获取登录状态
|
||||
* GET /api/auth/status
|
||||
*/
|
||||
router.get('/status', (req, res) => {
|
||||
try {
|
||||
const status = req.backend.getLoginStatus();
|
||||
res.json({
|
||||
success: true,
|
||||
data: status
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取登录URL
|
||||
* GET /api/auth/login-url
|
||||
*/
|
||||
router.get('/login-url', (req, res) => {
|
||||
try {
|
||||
const loginData = req.backend.getLoginUrl();
|
||||
res.json({
|
||||
success: true,
|
||||
data: loginData
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 处理登录回调
|
||||
* POST /api/auth/callback
|
||||
*/
|
||||
router.post('/callback', async (req, res) => {
|
||||
try {
|
||||
const { code } = req.body;
|
||||
|
||||
if (!code) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Authorization code is required'
|
||||
});
|
||||
}
|
||||
|
||||
const result = await req.backend.handleLoginCallback(code);
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: result
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 重新登录
|
||||
* POST /api/auth/relogin
|
||||
*/
|
||||
router.post('/relogin', async (req, res) => {
|
||||
try {
|
||||
const result = await req.backend.relogin();
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Relogin successful'
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 登出
|
||||
* POST /api/auth/logout
|
||||
*/
|
||||
router.post('/logout', (req, res) => {
|
||||
try {
|
||||
const result = req.backend.logout();
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Logout successful'
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,270 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const DownloadService = require('../services/download');
|
||||
|
||||
/**
|
||||
* 下载单个作品
|
||||
* POST /api/download/artwork/:id
|
||||
*/
|
||||
router.post('/artwork/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const {
|
||||
size = 'original',
|
||||
quality = 'high',
|
||||
format = 'auto'
|
||||
} = req.body;
|
||||
|
||||
if (!id || isNaN(parseInt(id))) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid artwork ID'
|
||||
});
|
||||
}
|
||||
|
||||
const downloadService = new DownloadService(req.backend.getAuth());
|
||||
const result = await downloadService.downloadArtwork(parseInt(id), {
|
||||
size,
|
||||
quality,
|
||||
format
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 批量下载作品
|
||||
* POST /api/download/artworks
|
||||
*/
|
||||
router.post('/artworks', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
artworkIds,
|
||||
size = 'original',
|
||||
quality = 'high',
|
||||
format = 'auto',
|
||||
concurrent = 3
|
||||
} = req.body;
|
||||
|
||||
if (!artworkIds || !Array.isArray(artworkIds) || artworkIds.length === 0) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Artwork IDs array is required'
|
||||
});
|
||||
}
|
||||
|
||||
if (artworkIds.length > 50) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Maximum 50 artworks can be downloaded at once'
|
||||
});
|
||||
}
|
||||
|
||||
const downloadService = new DownloadService(req.backend.getAuth());
|
||||
const result = await downloadService.downloadMultipleArtworks(artworkIds, {
|
||||
size,
|
||||
quality,
|
||||
format,
|
||||
concurrent: parseInt(concurrent)
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 下载作者作品
|
||||
* POST /api/download/artist/:id
|
||||
*/
|
||||
router.post('/artist/:id', async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const {
|
||||
type = 'art',
|
||||
filter = 'for_ios',
|
||||
size = 'original',
|
||||
quality = 'high',
|
||||
format = 'auto',
|
||||
limit = 50,
|
||||
concurrent = 3
|
||||
} = req.body;
|
||||
|
||||
if (!id || isNaN(parseInt(id))) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid artist ID'
|
||||
});
|
||||
}
|
||||
|
||||
const downloadService = new DownloadService(req.backend.getAuth());
|
||||
const result = await downloadService.downloadArtistArtworks(parseInt(id), {
|
||||
type,
|
||||
filter,
|
||||
size,
|
||||
quality,
|
||||
format,
|
||||
limit: parseInt(limit),
|
||||
concurrent: parseInt(concurrent)
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取下载进度
|
||||
* GET /api/download/progress/:taskId
|
||||
*/
|
||||
router.get('/progress/:taskId', async (req, res) => {
|
||||
try {
|
||||
const { taskId } = req.params;
|
||||
|
||||
if (!taskId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Task ID is required'
|
||||
});
|
||||
}
|
||||
|
||||
const downloadService = new DownloadService(req.backend.getAuth());
|
||||
const result = await downloadService.getDownloadProgress(taskId);
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data
|
||||
});
|
||||
} else {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 取消下载任务
|
||||
* DELETE /api/download/cancel/:taskId
|
||||
*/
|
||||
router.delete('/cancel/:taskId', async (req, res) => {
|
||||
try {
|
||||
const { taskId } = req.params;
|
||||
|
||||
if (!taskId) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Task ID is required'
|
||||
});
|
||||
}
|
||||
|
||||
const downloadService = new DownloadService(req.backend.getAuth());
|
||||
const result = await downloadService.cancelDownload(taskId);
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Download task cancelled successfully'
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 获取下载历史
|
||||
* GET /api/download/history
|
||||
*/
|
||||
router.get('/history', async (req, res) => {
|
||||
try {
|
||||
const {
|
||||
offset = 0,
|
||||
limit = 20
|
||||
} = req.query;
|
||||
|
||||
const downloadService = new DownloadService(req.backend.getAuth());
|
||||
const result = await downloadService.getDownloadHistory({
|
||||
offset: parseInt(offset),
|
||||
limit: parseInt(limit)
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
data: result.data
|
||||
});
|
||||
} else {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: result.error
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,155 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const morgan = require('morgan');
|
||||
const path = require('path');
|
||||
|
||||
// 导入路由模块 - 临时注释掉来定位问题
|
||||
const authRoutes = require('./routes/auth');
|
||||
const artworkRoutes = require('./routes/artwork');
|
||||
const artistRoutes = require('./routes/artist');
|
||||
const downloadRoutes = require('./routes/download');
|
||||
|
||||
// 导入中间件 - 临时注释掉来定位问题
|
||||
const { errorHandler } = require('./middleware/errorHandler');
|
||||
const { authMiddleware } = require('./middleware/auth');
|
||||
|
||||
// 导入核心模块
|
||||
const PixivBackend = require('./core');
|
||||
const proxyConfig = require('./config');
|
||||
|
||||
class PixivServer {
|
||||
constructor() {
|
||||
this.app = express();
|
||||
this.backend = null;
|
||||
this.port = process.env.PORT || 3000;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化服务器
|
||||
*/
|
||||
async init() {
|
||||
console.log('正在初始化 Pixiv 后端服务器...');
|
||||
|
||||
// 设置代理
|
||||
proxyConfig.setEnvironmentVariables();
|
||||
|
||||
// 初始化 Pixiv 后端
|
||||
this.backend = new PixivBackend();
|
||||
await this.backend.init();
|
||||
|
||||
// 配置中间件
|
||||
this.setupMiddleware();
|
||||
|
||||
// 配置路由
|
||||
this.setupRoutes();
|
||||
|
||||
// 配置错误处理 - 临时注释掉
|
||||
this.setupErrorHandling();
|
||||
|
||||
console.log('服务器初始化完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置中间件
|
||||
*/
|
||||
setupMiddleware() {
|
||||
// 日志中间件
|
||||
this.app.use(morgan('combined'));
|
||||
|
||||
// CORS 中间件
|
||||
this.app.use(cors({
|
||||
origin: process.env.FRONTEND_URL || 'http://localhost:3001',
|
||||
credentials: true
|
||||
}));
|
||||
|
||||
// JSON 解析中间件
|
||||
this.app.use(express.json({ limit: '10mb' }));
|
||||
this.app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||||
|
||||
// 静态文件服务 - 临时注释掉
|
||||
this.app.use('/downloads', express.static(path.join(__dirname, '../downloads')));
|
||||
|
||||
// 将后端实例注入到请求对象中
|
||||
this.app.use((req, res, next) => {
|
||||
req.backend = this.backend;
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置路由
|
||||
*/
|
||||
setupRoutes() {
|
||||
// 健康检查
|
||||
this.app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
backend: {
|
||||
isLoggedIn: req.backend.isLoggedIn,
|
||||
user: req.backend.config.user?.account
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// API 路由 - 临时注释掉来定位问题
|
||||
this.app.use('/api/auth', authRoutes);
|
||||
this.app.use('/api/artwork', authMiddleware, artworkRoutes);
|
||||
this.app.use('/api/artist', authMiddleware, artistRoutes);
|
||||
this.app.use('/api/download', authMiddleware, downloadRoutes);
|
||||
|
||||
// 404 处理
|
||||
this.app.use((req, res) => {
|
||||
res.status(404).json({
|
||||
error: 'Not Found',
|
||||
message: `Route ${req.originalUrl} not found`
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置错误处理
|
||||
*/
|
||||
setupErrorHandling() {
|
||||
this.app.use(errorHandler);
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动服务器
|
||||
*/
|
||||
start() {
|
||||
this.app.listen(this.port, () => {
|
||||
console.log(`🚀 Pixiv 后端服务器已启动`);
|
||||
console.log(`📍 地址: http://localhost:${this.port}`);
|
||||
console.log(`🔗 健康检查: http://localhost:${this.port}/health`);
|
||||
console.log(`📊 登录状态: ${this.backend.isLoggedIn ? '已登录' : '未登录'}`);
|
||||
if (this.backend.isLoggedIn) {
|
||||
console.log(`👤 用户: ${this.backend.config.user?.account}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 优雅关闭
|
||||
*/
|
||||
async shutdown() {
|
||||
console.log('正在关闭服务器...');
|
||||
// 清理代理环境变量
|
||||
proxyConfig.clearEnvironmentVariables();
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
// 如果直接运行此文件
|
||||
if (require.main === module) {
|
||||
const server = new PixivServer();
|
||||
|
||||
// 处理进程信号
|
||||
process.on('SIGINT', () => server.shutdown());
|
||||
process.on('SIGTERM', () => server.shutdown());
|
||||
|
||||
// 启动服务器
|
||||
server.init().then(() => server.start()).catch(console.error);
|
||||
}
|
||||
|
||||
module.exports = PixivServer;
|
||||
@@ -0,0 +1,344 @@
|
||||
const axios = require('axios');
|
||||
const { stringify } = require('qs');
|
||||
|
||||
class ArtistService {
|
||||
constructor(auth) {
|
||||
this.auth = auth;
|
||||
this.baseURL = 'https://app-api.pixiv.net';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取作者信息
|
||||
*/
|
||||
async getArtistInfo(artistId) {
|
||||
try {
|
||||
const response = await this.makeRequest(
|
||||
'GET',
|
||||
'/v1/user/detail',
|
||||
{ user_id: artistId }
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.user
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取作者作品列表
|
||||
*/
|
||||
async getArtistArtworks(artistId, options = {}) {
|
||||
try {
|
||||
const {
|
||||
type = 'art',
|
||||
filter = 'for_ios',
|
||||
offset = 0,
|
||||
limit = 30
|
||||
} = options;
|
||||
|
||||
const params = {
|
||||
user_id: artistId,
|
||||
type,
|
||||
filter,
|
||||
offset
|
||||
};
|
||||
|
||||
const response = await this.makeRequest(
|
||||
'GET',
|
||||
`/v1/user/illusts?${stringify(params)}`
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
artworks: response.illusts,
|
||||
next_url: response.next_url,
|
||||
total: response.illusts.length
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取作者关注列表
|
||||
*/
|
||||
async getArtistFollowing(artistId, options = {}) {
|
||||
try {
|
||||
const {
|
||||
restrict = 'public',
|
||||
offset = 0,
|
||||
limit = 30
|
||||
} = options;
|
||||
|
||||
const params = {
|
||||
user_id: artistId,
|
||||
restrict,
|
||||
offset
|
||||
};
|
||||
|
||||
const response = await this.makeRequest(
|
||||
'GET',
|
||||
`/v1/user/following?${stringify(params)}`
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
users: response.user_previews,
|
||||
next_url: response.next_url,
|
||||
total: response.user_previews.length
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取作者粉丝列表
|
||||
*/
|
||||
async getArtistFollowers(artistId, options = {}) {
|
||||
try {
|
||||
const {
|
||||
offset = 0,
|
||||
limit = 30
|
||||
} = options;
|
||||
|
||||
const params = {
|
||||
user_id: artistId,
|
||||
offset
|
||||
};
|
||||
|
||||
const response = await this.makeRequest(
|
||||
'GET',
|
||||
`/v1/user/follower?${stringify(params)}`
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
users: response.user_previews,
|
||||
next_url: response.next_url,
|
||||
total: response.user_previews.length
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 关注/取消关注作者
|
||||
*/
|
||||
async followArtist(artistId, action = 'follow') {
|
||||
try {
|
||||
const data = {
|
||||
user_id: artistId,
|
||||
restrict: 'public'
|
||||
};
|
||||
|
||||
const endpoint = action === 'follow' ? '/v1/user/follow/add' : '/v1/user/follow/delete';
|
||||
|
||||
const response = await this.makeRequest(
|
||||
'POST',
|
||||
endpoint,
|
||||
data
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索作者
|
||||
*/
|
||||
async searchArtists(searchOptions) {
|
||||
try {
|
||||
const {
|
||||
keyword,
|
||||
sort = 'date_desc',
|
||||
duration = 'all',
|
||||
offset = 0,
|
||||
limit = 30
|
||||
} = searchOptions;
|
||||
|
||||
const params = {
|
||||
word: keyword,
|
||||
sort,
|
||||
duration,
|
||||
offset,
|
||||
filter: 'for_ios'
|
||||
};
|
||||
|
||||
const response = await this.makeRequest(
|
||||
'GET',
|
||||
`/v1/search/user?${stringify(params)}`
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
users: response.user_previews,
|
||||
next_url: response.next_url,
|
||||
search_span_limit: response.search_span_limit,
|
||||
total: response.user_previews.length
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取推荐作者
|
||||
*/
|
||||
async getRecommendedArtists(options = {}) {
|
||||
try {
|
||||
const {
|
||||
offset = 0,
|
||||
limit = 30
|
||||
} = options;
|
||||
|
||||
const params = {
|
||||
offset,
|
||||
filter: 'for_ios'
|
||||
};
|
||||
|
||||
const response = await this.makeRequest(
|
||||
'GET',
|
||||
`/v1/user/recommended?${stringify(params)}`
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
users: response.user_previews,
|
||||
next_url: response.next_url
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取作者统计信息
|
||||
*/
|
||||
async getArtistStats(artistId) {
|
||||
try {
|
||||
const response = await this.makeRequest(
|
||||
'GET',
|
||||
'/v1/user/detail',
|
||||
{ user_id: artistId }
|
||||
);
|
||||
|
||||
const user = response.user;
|
||||
|
||||
const stats = {
|
||||
user_id: user.id,
|
||||
total_illusts: user.total_illusts,
|
||||
total_manga: user.total_manga,
|
||||
total_novels: user.total_novels,
|
||||
total_bookmarked_illust: user.total_bookmarked_illust,
|
||||
total_following: user.total_following,
|
||||
total_followers: user.total_followers,
|
||||
total_illust_bookmarks_public: user.total_illust_bookmarks_public,
|
||||
total_illust_series: user.total_illust_series,
|
||||
total_novel_series: user.total_novel_series,
|
||||
background: user.background,
|
||||
twitter_account: user.twitter_account,
|
||||
twitter_url: user.twitter_url,
|
||||
pawoo_url: user.pawoo_url,
|
||||
is_followed: user.is_followed,
|
||||
is_following: user.is_following,
|
||||
is_friend: user.is_friend,
|
||||
is_blocking: user.is_blocking,
|
||||
is_blocked: user.is_blocked,
|
||||
accept_request: user.accept_request
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: stats
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送API请求
|
||||
*/
|
||||
async makeRequest(method, endpoint, data = null) {
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${this.auth.accessToken}`,
|
||||
'Accept-Language': 'en-us',
|
||||
'App-OS': 'android',
|
||||
'App-OS-Version': '9.0',
|
||||
'App-Version': '5.0.234',
|
||||
'User-Agent': 'PixivAndroidApp/5.0.234 (Android 9.0; Pixel 3)'
|
||||
};
|
||||
|
||||
const config = {
|
||||
method,
|
||||
url: `${this.baseURL}${endpoint}`,
|
||||
headers,
|
||||
timeout: 30000
|
||||
};
|
||||
|
||||
if (data) {
|
||||
if (method === 'GET') {
|
||||
config.params = data;
|
||||
} else {
|
||||
config.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios(config);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ArtistService;
|
||||
@@ -0,0 +1,307 @@
|
||||
const axios = require('axios');
|
||||
const { stringify } = require('qs');
|
||||
|
||||
class ArtworkService {
|
||||
constructor(auth) {
|
||||
this.auth = auth;
|
||||
this.baseURL = 'https://app-api.pixiv.net';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取作品详情
|
||||
*/
|
||||
async getArtworkDetail(artworkId, options = {}) {
|
||||
try {
|
||||
const { include_user = true, include_series = false } = options;
|
||||
|
||||
const params = {
|
||||
include_user,
|
||||
include_series
|
||||
};
|
||||
|
||||
const response = await this.makeRequest(
|
||||
'GET',
|
||||
`/v1/illust/detail?${stringify(params)}`,
|
||||
{ illust_id: artworkId }
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.illust
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取作品预览信息
|
||||
*/
|
||||
async getArtworkPreview(artworkId) {
|
||||
try {
|
||||
const response = await this.makeRequest(
|
||||
'GET',
|
||||
'/v1/illust/detail',
|
||||
{ illust_id: artworkId }
|
||||
);
|
||||
|
||||
const artwork = response.illust;
|
||||
|
||||
// 构建预览信息
|
||||
const preview = {
|
||||
id: artwork.id,
|
||||
title: artwork.title,
|
||||
description: artwork.caption,
|
||||
user: {
|
||||
id: artwork.user.id,
|
||||
name: artwork.user.name,
|
||||
account: artwork.user.account
|
||||
},
|
||||
image_urls: artwork.image_urls,
|
||||
tags: artwork.tags.map(tag => tag.name),
|
||||
create_date: artwork.create_date,
|
||||
update_date: artwork.update_date,
|
||||
type: artwork.type,
|
||||
width: artwork.width,
|
||||
height: artwork.height,
|
||||
page_count: artwork.page_count,
|
||||
is_bookmarked: artwork.is_bookmarked,
|
||||
total_bookmarks: artwork.total_bookmarks,
|
||||
total_view: artwork.total_view,
|
||||
is_muted: artwork.is_muted,
|
||||
meta_single_page: artwork.meta_single_page,
|
||||
meta_pages: artwork.meta_pages
|
||||
};
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: preview
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取作品图片URL
|
||||
*/
|
||||
async getArtworkImages(artworkId, size = 'medium') {
|
||||
try {
|
||||
const response = await this.makeRequest(
|
||||
'GET',
|
||||
'/v1/illust/detail',
|
||||
{ illust_id: artworkId }
|
||||
);
|
||||
|
||||
const artwork = response.illust;
|
||||
const images = [];
|
||||
|
||||
if (artwork.meta_single_page && artwork.meta_single_page.original_image_url) {
|
||||
// 单页作品
|
||||
images.push({
|
||||
page: 1,
|
||||
original: artwork.meta_single_page.original_image_url,
|
||||
large: artwork.meta_single_page.large_image_url,
|
||||
medium: artwork.image_urls.medium,
|
||||
square_medium: artwork.image_urls.square_medium
|
||||
});
|
||||
} else if (artwork.meta_pages && artwork.meta_pages.length > 0) {
|
||||
// 多页作品
|
||||
artwork.meta_pages.forEach((page, index) => {
|
||||
images.push({
|
||||
page: index + 1,
|
||||
original: page.image_urls.original,
|
||||
large: page.image_urls.large,
|
||||
medium: page.image_urls.medium,
|
||||
square_medium: page.image_urls.square_medium
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
artwork_id: artworkId,
|
||||
total_pages: artwork.page_count,
|
||||
images: images,
|
||||
selected_size: size
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索作品
|
||||
*/
|
||||
async searchArtworks(searchOptions) {
|
||||
try {
|
||||
const {
|
||||
keyword,
|
||||
type = 'all',
|
||||
sort = 'date_desc',
|
||||
duration = 'all',
|
||||
offset = 0,
|
||||
limit = 30
|
||||
} = searchOptions;
|
||||
|
||||
const params = {
|
||||
word: keyword,
|
||||
search_target: type,
|
||||
sort: sort,
|
||||
duration: duration,
|
||||
offset,
|
||||
filter: 'for_ios'
|
||||
};
|
||||
|
||||
const response = await this.makeRequest(
|
||||
'GET',
|
||||
`/v1/search/illust?${stringify(params)}`
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
artworks: response.illusts,
|
||||
next_url: response.next_url,
|
||||
search_span_limit: response.search_span_limit,
|
||||
total: response.illusts.length
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取推荐作品
|
||||
*/
|
||||
async getRecommendedArtworks(options = {}) {
|
||||
try {
|
||||
const {
|
||||
offset = 0,
|
||||
limit = 30,
|
||||
include_ranking_illusts = true,
|
||||
include_privacy_policy = false
|
||||
} = options;
|
||||
|
||||
const params = {
|
||||
offset,
|
||||
include_ranking_illusts,
|
||||
include_privacy_policy,
|
||||
filter: 'for_ios'
|
||||
};
|
||||
|
||||
const response = await this.makeRequest(
|
||||
'GET',
|
||||
`/v1/illust/recommended?${stringify(params)}`
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
artworks: response.illusts,
|
||||
next_url: response.next_url,
|
||||
ranking_illusts: response.ranking_illusts || []
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取排行榜作品
|
||||
*/
|
||||
async getRankingArtworks(options = {}) {
|
||||
try {
|
||||
const {
|
||||
mode = 'day',
|
||||
filter = 'for_ios',
|
||||
offset = 0
|
||||
} = options;
|
||||
|
||||
const params = {
|
||||
mode,
|
||||
filter,
|
||||
offset
|
||||
};
|
||||
|
||||
const response = await this.makeRequest(
|
||||
'GET',
|
||||
`/v1/illust/ranking?${stringify(params)}`
|
||||
);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
artworks: response.illusts,
|
||||
next_url: response.next_url,
|
||||
mode,
|
||||
date: response.date
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送API请求
|
||||
*/
|
||||
async makeRequest(method, endpoint, data = null) {
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${this.auth.accessToken}`,
|
||||
'Accept-Language': 'en-us',
|
||||
'App-OS': 'android',
|
||||
'App-OS-Version': '9.0',
|
||||
'App-Version': '5.0.234',
|
||||
'User-Agent': 'PixivAndroidApp/5.0.234 (Android 9.0; Pixel 3)'
|
||||
};
|
||||
|
||||
const config = {
|
||||
method,
|
||||
url: `${this.baseURL}${endpoint}`,
|
||||
headers,
|
||||
timeout: 30000
|
||||
};
|
||||
|
||||
if (data) {
|
||||
if (method === 'GET') {
|
||||
config.params = data;
|
||||
} else {
|
||||
config.data = data;
|
||||
}
|
||||
}
|
||||
|
||||
const response = await axios(config);
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ArtworkService;
|
||||
@@ -0,0 +1,444 @@
|
||||
const axios = require('axios');
|
||||
const fs = require('fs-extra');
|
||||
const path = require('path');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const ArtworkService = require('./artwork');
|
||||
const ArtistService = require('./artist');
|
||||
|
||||
class DownloadService {
|
||||
constructor(auth) {
|
||||
this.auth = auth;
|
||||
this.artworkService = new ArtworkService(auth);
|
||||
this.artistService = new ArtistService(auth);
|
||||
this.downloadPath = path.join(__dirname, '../../downloads');
|
||||
this.tasks = new Map(); // 存储下载任务状态
|
||||
|
||||
// 确保下载目录存在
|
||||
this.ensureDownloadDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保下载目录存在
|
||||
*/
|
||||
async ensureDownloadDir() {
|
||||
try {
|
||||
await fs.ensureDir(this.downloadPath);
|
||||
console.log('下载目录已创建:', this.downloadPath);
|
||||
} catch (error) {
|
||||
console.error('创建下载目录失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载单个作品
|
||||
*/
|
||||
async downloadArtwork(artworkId, options = {}) {
|
||||
const taskId = uuidv4();
|
||||
const { size = 'original', quality = 'high', format = 'auto' } = options;
|
||||
|
||||
try {
|
||||
// 创建任务记录
|
||||
this.tasks.set(taskId, {
|
||||
id: taskId,
|
||||
type: 'artwork',
|
||||
artwork_id: artworkId,
|
||||
status: 'downloading',
|
||||
progress: 0,
|
||||
total: 1,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
files: [],
|
||||
start_time: new Date(),
|
||||
end_time: null
|
||||
});
|
||||
|
||||
// 获取作品信息
|
||||
const artworkResult = await this.artworkService.getArtworkDetail(artworkId);
|
||||
if (!artworkResult.success) {
|
||||
throw new Error(`获取作品信息失败: ${artworkResult.error}`);
|
||||
}
|
||||
|
||||
const artwork = artworkResult.data;
|
||||
const artistName = artwork.user.name.replace(/[<>:"/\\|?*]/g, '_');
|
||||
const artworkTitle = artwork.title.replace(/[<>:"/\\|?*]/g, '_');
|
||||
|
||||
// 创建作品目录
|
||||
const artworkDir = path.join(this.downloadPath, `${artistName}_${artworkId}`, artworkTitle);
|
||||
await fs.ensureDir(artworkDir);
|
||||
|
||||
// 获取图片URL
|
||||
const imagesResult = await this.artworkService.getArtworkImages(artworkId, size);
|
||||
if (!imagesResult.success) {
|
||||
throw new Error(`获取图片URL失败: ${imagesResult.error}`);
|
||||
}
|
||||
|
||||
const images = imagesResult.data.images;
|
||||
const task = this.tasks.get(taskId);
|
||||
task.total = images.length;
|
||||
|
||||
// 下载所有图片
|
||||
const downloadPromises = images.map(async (image, index) => {
|
||||
try {
|
||||
const imageUrl = image[size] || image.original;
|
||||
const fileExt = this.getFileExtension(imageUrl);
|
||||
const fileName = `${artworkTitle}_${artworkId}_${index + 1}${fileExt}`;
|
||||
const filePath = path.join(artworkDir, fileName);
|
||||
|
||||
await this.downloadFile(imageUrl, filePath);
|
||||
|
||||
task.completed++;
|
||||
task.progress = Math.round((task.completed / task.total) * 100);
|
||||
task.files.push({
|
||||
path: filePath,
|
||||
url: imageUrl,
|
||||
size: size
|
||||
});
|
||||
|
||||
return { success: true, file: fileName };
|
||||
} catch (error) {
|
||||
task.failed++;
|
||||
console.error(`下载图片失败 ${index + 1}:`, error.message);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(downloadPromises);
|
||||
|
||||
// 保存作品信息
|
||||
const infoPath = path.join(artworkDir, 'artwork_info.json');
|
||||
await fs.writeJson(infoPath, artwork, { spaces: 2 });
|
||||
|
||||
// 更新任务状态
|
||||
task.status = task.failed === 0 ? 'completed' : 'partial';
|
||||
task.end_time = new Date();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
task_id: taskId,
|
||||
artwork_id: artworkId,
|
||||
artist_name: artistName,
|
||||
artwork_title: artworkTitle,
|
||||
download_path: artworkDir,
|
||||
total_files: task.total,
|
||||
completed_files: task.completed,
|
||||
failed_files: task.failed,
|
||||
files: task.files
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const task = this.tasks.get(taskId);
|
||||
if (task) {
|
||||
task.status = 'failed';
|
||||
task.end_time = new Date();
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量下载作品
|
||||
*/
|
||||
async downloadMultipleArtworks(artworkIds, options = {}) {
|
||||
const taskId = uuidv4();
|
||||
const { concurrent = 3, size = 'original', quality = 'high', format = 'auto' } = options;
|
||||
|
||||
try {
|
||||
// 创建任务记录
|
||||
this.tasks.set(taskId, {
|
||||
id: taskId,
|
||||
type: 'batch',
|
||||
artwork_ids: artworkIds,
|
||||
status: 'downloading',
|
||||
progress: 0,
|
||||
total: artworkIds.length,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
results: [],
|
||||
start_time: new Date(),
|
||||
end_time: null
|
||||
});
|
||||
|
||||
const task = this.tasks.get(taskId);
|
||||
const results = [];
|
||||
|
||||
// 分批下载
|
||||
for (let i = 0; i < artworkIds.length; i += concurrent) {
|
||||
const batch = artworkIds.slice(i, i + concurrent);
|
||||
const batchPromises = batch.map(async (artworkId) => {
|
||||
try {
|
||||
const result = await this.downloadArtwork(artworkId, { size, quality, format });
|
||||
task.completed++;
|
||||
results.push({ artwork_id: artworkId, ...result });
|
||||
return result;
|
||||
} catch (error) {
|
||||
task.failed++;
|
||||
results.push({ artwork_id: artworkId, success: false, error: error.message });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(batchPromises);
|
||||
task.progress = Math.round((task.completed / task.total) * 100);
|
||||
}
|
||||
|
||||
// 更新任务状态
|
||||
task.status = task.failed === 0 ? 'completed' : 'partial';
|
||||
task.end_time = new Date();
|
||||
task.results = results;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
task_id: taskId,
|
||||
total_artworks: task.total,
|
||||
completed_artworks: task.completed,
|
||||
failed_artworks: task.failed,
|
||||
results: results
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
const task = this.tasks.get(taskId);
|
||||
if (task) {
|
||||
task.status = 'failed';
|
||||
task.end_time = new Date();
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载作者作品
|
||||
*/
|
||||
async downloadArtistArtworks(artistId, options = {}) {
|
||||
const taskId = uuidv4();
|
||||
const {
|
||||
type = 'art',
|
||||
filter = 'for_ios',
|
||||
size = 'original',
|
||||
quality = 'high',
|
||||
format = 'auto',
|
||||
limit = 50,
|
||||
concurrent = 3
|
||||
} = options;
|
||||
|
||||
try {
|
||||
// 获取作者信息
|
||||
const artistResult = await this.artistService.getArtistInfo(artistId);
|
||||
if (!artistResult.success) {
|
||||
throw new Error(`获取作者信息失败: ${artistResult.error}`);
|
||||
}
|
||||
|
||||
const artist = artistResult.data;
|
||||
const artistName = artist.name.replace(/[<>:"/\\|?*]/g, '_');
|
||||
|
||||
// 获取作者作品列表
|
||||
const artworksResult = await this.artistService.getArtistArtworks(artistId, {
|
||||
type,
|
||||
filter,
|
||||
limit
|
||||
});
|
||||
|
||||
if (!artworksResult.success) {
|
||||
throw new Error(`获取作者作品列表失败: ${artworksResult.error}`);
|
||||
}
|
||||
|
||||
const artworks = artworksResult.data.artworks;
|
||||
const artworkIds = artworks.map(artwork => artwork.id);
|
||||
|
||||
// 创建任务记录
|
||||
this.tasks.set(taskId, {
|
||||
id: taskId,
|
||||
type: 'artist',
|
||||
artist_id: artistId,
|
||||
artist_name: artistName,
|
||||
status: 'downloading',
|
||||
progress: 0,
|
||||
total: artworkIds.length,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
results: [],
|
||||
start_time: new Date(),
|
||||
end_time: null
|
||||
});
|
||||
|
||||
// 批量下载作品
|
||||
const batchResult = await this.downloadMultipleArtworks(artworkIds, {
|
||||
concurrent,
|
||||
size,
|
||||
quality,
|
||||
format
|
||||
});
|
||||
|
||||
if (batchResult.success) {
|
||||
const task = this.tasks.get(taskId);
|
||||
task.status = batchResult.data.failed_artworks === 0 ? 'completed' : 'partial';
|
||||
task.end_time = new Date();
|
||||
task.results = batchResult.data.results;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
task_id: taskId,
|
||||
artist_id: artistId,
|
||||
artist_name: artistName,
|
||||
total_artworks: batchResult.data.total_artworks,
|
||||
completed_artworks: batchResult.data.completed_artworks,
|
||||
failed_artworks: batchResult.data.failed_artworks,
|
||||
results: batchResult.data.results
|
||||
}
|
||||
};
|
||||
} else {
|
||||
throw new Error(batchResult.error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
const task = this.tasks.get(taskId);
|
||||
if (task) {
|
||||
task.status = 'failed';
|
||||
task.end_time = new Date();
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取下载进度
|
||||
*/
|
||||
async getDownloadProgress(taskId) {
|
||||
const task = this.tasks.get(taskId);
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Task not found'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: task.id,
|
||||
type: task.type,
|
||||
status: task.status,
|
||||
progress: task.progress,
|
||||
total: task.total,
|
||||
completed: task.completed,
|
||||
failed: task.failed,
|
||||
start_time: task.start_time,
|
||||
end_time: task.end_time,
|
||||
files: task.files || [],
|
||||
results: task.results || []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消下载任务
|
||||
*/
|
||||
async cancelDownload(taskId) {
|
||||
const task = this.tasks.get(taskId);
|
||||
|
||||
if (!task) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Task not found'
|
||||
};
|
||||
}
|
||||
|
||||
if (task.status === 'completed' || task.status === 'failed') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Task already finished'
|
||||
};
|
||||
}
|
||||
|
||||
task.status = 'cancelled';
|
||||
task.end_time = new Date();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Download task cancelled successfully'
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取下载历史
|
||||
*/
|
||||
async getDownloadHistory(options = {}) {
|
||||
const { offset = 0, limit = 20 } = options;
|
||||
|
||||
try {
|
||||
const tasks = Array.from(this.tasks.values())
|
||||
.filter(task => task.status === 'completed' || task.status === 'partial')
|
||||
.sort((a, b) => b.end_time - a.end_time)
|
||||
.slice(offset, offset + limit);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
tasks: tasks,
|
||||
total: this.tasks.size,
|
||||
offset,
|
||||
limit
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载单个文件
|
||||
*/
|
||||
async downloadFile(url, filePath) {
|
||||
const headers = {
|
||||
'Referer': 'https://app-api.pixiv.net/',
|
||||
'User-Agent': 'PixivAndroidApp/5.0.234 (Android 9.0; Pixel 3)'
|
||||
};
|
||||
|
||||
const response = await axios({
|
||||
method: 'GET',
|
||||
url: url,
|
||||
headers,
|
||||
responseType: 'stream',
|
||||
timeout: 60000
|
||||
});
|
||||
|
||||
const writer = fs.createWriteStream(filePath);
|
||||
response.data.pipe(writer);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
writer.on('finish', resolve);
|
||||
writer.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
*/
|
||||
getFileExtension(url) {
|
||||
const match = url.match(/\.([a-zA-Z0-9]+)(?:\?|$)/);
|
||||
return match ? `.${match[1]}` : '.jpg';
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = DownloadService;
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Pixiv 后端服务器启动脚本
|
||||
*/
|
||||
|
||||
const PixivServer = require('./server');
|
||||
|
||||
// 设置环境变量
|
||||
process.env.NODE_ENV = process.env.NODE_ENV || 'development';
|
||||
|
||||
console.log('🚀 启动 Pixiv 后端服务器...');
|
||||
console.log(`📊 环境: ${process.env.NODE_ENV}`);
|
||||
console.log(`🌐 端口: ${process.env.PORT || 3000}`);
|
||||
|
||||
// 创建服务器实例
|
||||
const server = new PixivServer();
|
||||
|
||||
// 处理进程信号
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\n🛑 收到 SIGINT 信号,正在关闭服务器...');
|
||||
await server.shutdown();
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('\n🛑 收到 SIGTERM 信号,正在关闭服务器...');
|
||||
await server.shutdown();
|
||||
});
|
||||
|
||||
// 处理未捕获的异常
|
||||
process.on('uncaughtException', (error) => {
|
||||
console.error('❌ 未捕获的异常:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason, promise) => {
|
||||
console.error('❌ 未处理的 Promise 拒绝:', reason);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
// 启动服务器
|
||||
server.init()
|
||||
.then(() => server.start())
|
||||
.catch((error) => {
|
||||
console.error('❌ 服务器启动失败:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
const PixivBackend = require('./core');
|
||||
const proxyConfig = require('./config');
|
||||
const readline = require('readline');
|
||||
|
||||
// 创建命令行交互接口
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
// 询问用户输入
|
||||
function askQuestion(question) {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// 测试登录流程
|
||||
async function testLogin() {
|
||||
console.log('=== Pixiv 登录测试脚本 ===\n');
|
||||
|
||||
try {
|
||||
// 1. 设置代理环境变量
|
||||
console.log('1. 设置代理配置...');
|
||||
proxyConfig.setEnvironmentVariables();
|
||||
|
||||
// 2. 初始化后端
|
||||
console.log('\n2. 初始化 Pixiv 后端...');
|
||||
const backend = new PixivBackend();
|
||||
await backend.init();
|
||||
|
||||
// 3. 检查登录状态
|
||||
console.log('\n3. 检查当前登录状态...');
|
||||
const loginStatus = backend.getLoginStatus();
|
||||
console.log('登录状态:', loginStatus);
|
||||
|
||||
if (loginStatus.isLoggedIn) {
|
||||
console.log('✅ 已登录,用户:', loginStatus.username);
|
||||
return;
|
||||
}
|
||||
|
||||
// 4. 获取登录URL
|
||||
console.log('\n4. 获取登录URL...');
|
||||
const loginData = backend.getLoginUrl();
|
||||
console.log('请访问以下URL进行登录:');
|
||||
console.log(loginData.login_url);
|
||||
console.log('\n登录完成后,请复制回调URL中的code参数');
|
||||
|
||||
// 5. 等待用户输入授权码
|
||||
const code = await askQuestion('\n请输入授权码 (code参数): ');
|
||||
|
||||
if (!code || code.trim() === '') {
|
||||
console.log('❌ 未输入授权码,测试终止');
|
||||
return;
|
||||
}
|
||||
|
||||
// 6. 处理登录回调
|
||||
console.log('\n5. 处理登录回调...');
|
||||
const loginResult = await backend.handleLoginCallback(code.trim());
|
||||
|
||||
if (loginResult.success) {
|
||||
console.log('✅ 登录成功!');
|
||||
console.log('用户信息:', loginResult.user);
|
||||
|
||||
// 7. 再次检查登录状态
|
||||
console.log('\n6. 验证登录状态...');
|
||||
const finalStatus = backend.getLoginStatus();
|
||||
console.log('最终登录状态:', finalStatus);
|
||||
|
||||
// 8. 测试获取用户信息
|
||||
console.log('\n7. 测试获取用户信息...');
|
||||
const auth = backend.getAuth();
|
||||
const userInfo = await auth.getUserInfo();
|
||||
|
||||
if (userInfo.success) {
|
||||
console.log('✅ 获取用户信息成功:', userInfo.user);
|
||||
} else {
|
||||
console.log('❌ 获取用户信息失败:', userInfo.error);
|
||||
}
|
||||
|
||||
} else {
|
||||
console.log('❌ 登录失败:', loginResult.error);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 测试过程中发生错误:', error.message);
|
||||
console.error('错误详情:', error);
|
||||
} finally {
|
||||
// 清理资源
|
||||
rl.close();
|
||||
console.log('\n=== 测试完成 ===');
|
||||
}
|
||||
}
|
||||
|
||||
// 测试重新登录功能
|
||||
async function testRelogin() {
|
||||
console.log('=== 测试重新登录功能 ===\n');
|
||||
|
||||
try {
|
||||
// 设置代理
|
||||
proxyConfig.setEnvironmentVariables();
|
||||
|
||||
// 初始化后端
|
||||
const backend = new PixivBackend();
|
||||
await backend.init();
|
||||
|
||||
// 检查是否有保存的登录信息
|
||||
const loginStatus = backend.getLoginStatus();
|
||||
|
||||
if (loginStatus.isLoggedIn) {
|
||||
console.log('✅ 检测到已保存的登录信息');
|
||||
console.log('用户:', loginStatus.username);
|
||||
console.log('用户ID:', loginStatus.user_id);
|
||||
} else {
|
||||
console.log('❌ 没有保存的登录信息,无法测试重新登录');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 重新登录测试失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 测试登出功能
|
||||
async function testLogout() {
|
||||
console.log('=== 测试登出功能 ===\n');
|
||||
|
||||
try {
|
||||
// 设置代理
|
||||
proxyConfig.setEnvironmentVariables();
|
||||
|
||||
// 初始化后端
|
||||
const backend = new PixivBackend();
|
||||
await backend.init();
|
||||
|
||||
// 执行登出
|
||||
const logoutResult = backend.logout();
|
||||
|
||||
if (logoutResult.success) {
|
||||
console.log('✅ 登出成功');
|
||||
|
||||
// 验证登出状态
|
||||
const loginStatus = backend.getLoginStatus();
|
||||
console.log('登出后状态:', loginStatus);
|
||||
} else {
|
||||
console.log('❌ 登出失败');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ 登出测试失败:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 主函数
|
||||
async function main() {
|
||||
console.log('请选择测试功能:');
|
||||
console.log('1. 测试完整登录流程');
|
||||
console.log('2. 测试重新登录');
|
||||
console.log('3. 测试登出');
|
||||
console.log('4. 运行所有测试');
|
||||
|
||||
const choice = await askQuestion('\n请输入选择 (1-4): ');
|
||||
|
||||
switch (choice.trim()) {
|
||||
case '1':
|
||||
await testLogin();
|
||||
break;
|
||||
case '2':
|
||||
await testRelogin();
|
||||
break;
|
||||
case '3':
|
||||
await testLogout();
|
||||
break;
|
||||
case '4':
|
||||
console.log('\n=== 运行所有测试 ===\n');
|
||||
await testLogin();
|
||||
console.log('\n' + '='.repeat(50) + '\n');
|
||||
await testRelogin();
|
||||
console.log('\n' + '='.repeat(50) + '\n');
|
||||
await testLogout();
|
||||
break;
|
||||
default:
|
||||
console.log('❌ 无效选择');
|
||||
rl.close();
|
||||
}
|
||||
}
|
||||
|
||||
// 如果直接运行此脚本
|
||||
if (require.main === module) {
|
||||
main().catch(console.error);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
testLogin,
|
||||
testRelogin,
|
||||
testLogout
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 统一API响应格式工具类
|
||||
*/
|
||||
class ResponseUtil {
|
||||
/**
|
||||
* 成功响应
|
||||
*/
|
||||
static success(data = null, message = 'Success') {
|
||||
return {
|
||||
success: true,
|
||||
message,
|
||||
data,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误响应
|
||||
*/
|
||||
static error(message = 'Error', code = null, details = null) {
|
||||
return {
|
||||
success: false,
|
||||
message,
|
||||
code,
|
||||
details,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页响应
|
||||
*/
|
||||
static paginated(data, page, limit, total) {
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
pagination: {
|
||||
page: parseInt(page),
|
||||
limit: parseInt(limit),
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
},
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表响应
|
||||
*/
|
||||
static list(data, total = null) {
|
||||
return {
|
||||
success: true,
|
||||
data,
|
||||
total: total || (Array.isArray(data) ? data.length : 0),
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ResponseUtil;
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "pixiv-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Pixiv 后端服务 - 支持 OAuth 2.0 登录和 API 调用",
|
||||
"main": "backend/start.js",
|
||||
"scripts": {
|
||||
"start": "node backend/start.js",
|
||||
"dev": "node backend/start.js",
|
||||
"test": "node backend/test-login.js",
|
||||
"set-proxy": "node backend/set-proxy.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"appdata-path": "^1.0.0",
|
||||
"axios": "^1.11.0",
|
||||
"cors": "^2.8.5",
|
||||
"crypto": "^1.0.1",
|
||||
"express": "^5.1.0",
|
||||
"fs-extra": "^11.3.1",
|
||||
"js-base64": "^3.7.8",
|
||||
"moment": "^2.30.1",
|
||||
"morgan": "^1.10.1",
|
||||
"proxy-agent": "^6.5.0",
|
||||
"qs": "^6.14.0",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"readline-sync": "^1.4.10"
|
||||
},
|
||||
"keywords": [
|
||||
"pixiv",
|
||||
"oauth",
|
||||
"api",
|
||||
"backend",
|
||||
"download"
|
||||
],
|
||||
"author": "Your Name",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
}
|
||||
Generated
+1047
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
max_line_length = 100
|
||||
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
@@ -0,0 +1,30 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
Vendored
+7
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig"
|
||||
]
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
# Pixiv Manager Frontend
|
||||
|
||||
一个优雅的 Pixiv 作品管理前端应用,基于 Vue 3 + TypeScript + Vite + Pinia 构建。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 🎨 **作品搜索与浏览** - 支持关键词搜索、标签筛选、排序等功能
|
||||
- 👨🎨 **作者管理** - 关注喜欢的作者,查看作品列表和统计信息
|
||||
- 📥 **下载管理** - 支持单个作品、批量作品、作者作品下载
|
||||
- 🔐 **用户认证** - 基于 Pixiv OAuth 2.0 的安全登录系统
|
||||
- 📱 **响应式设计** - 完美适配桌面端和移动端
|
||||
- ⚡ **现代化技术栈** - Vue 3 + TypeScript + Vite + Pinia
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **框架**: Vue 3 (Composition API)
|
||||
- **语言**: TypeScript
|
||||
- **构建工具**: Vite
|
||||
- **状态管理**: Pinia
|
||||
- **路由**: Vue Router 4
|
||||
- **HTTP 客户端**: Axios
|
||||
- **样式**: CSS3 + 响应式设计
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── components/ # 组件目录
|
||||
│ ├── common/ # 通用组件
|
||||
│ │ ├── LoadingSpinner.vue
|
||||
│ │ └── ErrorMessage.vue
|
||||
│ └── artwork/ # 作品相关组件
|
||||
│ └── ArtworkCard.vue
|
||||
├── views/ # 页面组件
|
||||
│ ├── HomeView.vue # 首页
|
||||
│ ├── LoginView.vue # 登录页
|
||||
│ ├── SearchView.vue # 搜索页
|
||||
│ ├── ArtworkView.vue # 作品详情页
|
||||
│ ├── ArtistView.vue # 作者详情页
|
||||
│ ├── DownloadsView.vue # 下载管理页
|
||||
│ └── ArtistsView.vue # 作者管理页
|
||||
├── services/ # API 服务层
|
||||
│ ├── api.ts # 基础 API 服务
|
||||
│ ├── auth.ts # 认证相关 API
|
||||
│ ├── artwork.ts # 作品相关 API
|
||||
│ ├── artist.ts # 作者相关 API
|
||||
│ └── download.ts # 下载相关 API
|
||||
├── stores/ # Pinia 状态管理
|
||||
│ └── auth.ts # 认证状态管理
|
||||
├── types/ # TypeScript 类型定义
|
||||
│ └── index.ts # 全局类型定义
|
||||
├── router/ # 路由配置
|
||||
│ └── index.ts # 路由定义
|
||||
├── assets/ # 静态资源
|
||||
│ └── main.css # 全局样式
|
||||
├── App.vue # 根组件
|
||||
└── main.ts # 应用入口
|
||||
```
|
||||
|
||||
## 开发指南
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Node.js >= 16
|
||||
- pnpm >= 7
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 开发模式
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 构建生产版本
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### 代码检查
|
||||
|
||||
```bash
|
||||
pnpm lint
|
||||
```
|
||||
|
||||
## 设计原则
|
||||
|
||||
### 组件设计
|
||||
- **单一职责**: 每个组件只负责一个特定功能
|
||||
- **可复用性**: 组件设计时考虑复用性,避免过度耦合
|
||||
- **类型安全**: 全面使用 TypeScript 确保类型安全
|
||||
|
||||
### 状态管理
|
||||
- **集中管理**: 使用 Pinia 进行集中状态管理
|
||||
- **响应式**: 状态变化自动触发 UI 更新
|
||||
- **持久化**: 关键状态支持本地持久化
|
||||
|
||||
### API 设计
|
||||
- **服务层**: 所有 API 调用封装在服务层
|
||||
- **错误处理**: 统一的错误处理机制
|
||||
- **类型安全**: API 响应类型定义完整
|
||||
|
||||
### 样式设计
|
||||
- **响应式**: 支持多种屏幕尺寸
|
||||
- **一致性**: 统一的设计语言和组件库
|
||||
- **可维护性**: 模块化的 CSS 结构
|
||||
|
||||
## 主要功能模块
|
||||
|
||||
### 1. 认证模块
|
||||
- Pixiv OAuth 2.0 登录
|
||||
- 登录状态管理
|
||||
- 自动刷新 Token
|
||||
|
||||
### 2. 作品搜索
|
||||
- 关键词搜索
|
||||
- 标签筛选
|
||||
- 排序选项
|
||||
- 分页加载
|
||||
|
||||
### 3. 作品详情
|
||||
- 作品信息展示
|
||||
- 多页作品支持
|
||||
- 下载功能
|
||||
- 作者信息
|
||||
|
||||
### 4. 作者管理
|
||||
- 作者信息展示
|
||||
- 作品列表
|
||||
- 关注/取消关注
|
||||
- 批量下载
|
||||
|
||||
### 5. 下载管理
|
||||
- 下载任务创建
|
||||
- 进度跟踪
|
||||
- 历史记录
|
||||
- 任务取消
|
||||
|
||||
## 部署说明
|
||||
|
||||
### 环境变量
|
||||
|
||||
创建 `.env` 文件:
|
||||
|
||||
```env
|
||||
VITE_API_BASE_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
### 构建部署
|
||||
|
||||
```bash
|
||||
# 构建生产版本
|
||||
pnpm build
|
||||
|
||||
# 部署到静态服务器
|
||||
# dist/ 目录包含所有静态文件
|
||||
```
|
||||
|
||||
## 贡献指南
|
||||
|
||||
1. Fork 项目
|
||||
2. 创建功能分支 (`git checkout -b feature/AmazingFeature`)
|
||||
3. 提交更改 (`git commit -m 'Add some AmazingFeature'`)
|
||||
4. 推送到分支 (`git push origin feature/AmazingFeature`)
|
||||
5. 打开 Pull Request
|
||||
|
||||
## 许可证
|
||||
|
||||
本项目仅供学习和个人使用,请遵守 Pixiv 的服务条款。
|
||||
|
||||
## 更新日志
|
||||
|
||||
### v1.0.0
|
||||
- 初始版本发布
|
||||
- 基础功能实现
|
||||
- 响应式设计
|
||||
- TypeScript 支持
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,20 @@
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
|
||||
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{ts,mts,tsx,vue}'],
|
||||
},
|
||||
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
|
||||
pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "pixivdownload",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint": "eslint . --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.11.0",
|
||||
"pinia": "^3.0.3",
|
||||
"vue": "^3.5.18",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node22": "^22.0.2",
|
||||
"@types/node": "^22.16.5",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"eslint": "^9.31.0",
|
||||
"eslint-plugin-vue": "~10.3.0",
|
||||
"jiti": "^2.4.2",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"typescript": "~5.8.0",
|
||||
"vite": "^7.0.6",
|
||||
"vite-plugin-vue-devtools": "^8.0.0",
|
||||
"vue-tsc": "^3.0.4"
|
||||
}
|
||||
}
|
||||
Generated
+3296
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
+225
@@ -0,0 +1,225 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink, RouterView } from 'vue-router'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const isLoggedIn = computed(() => authStore.isLoggedIn)
|
||||
const username = computed(() => authStore.username)
|
||||
|
||||
onMounted(async () => {
|
||||
await authStore.fetchLoginStatus()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="app">
|
||||
<!-- 导航栏 -->
|
||||
<nav class="navbar">
|
||||
<div class="nav-container">
|
||||
<div class="nav-brand">
|
||||
<RouterLink to="/" class="brand-link">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="brand-icon">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||
</svg>
|
||||
<span class="brand-text">Pixiv Manager</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-menu">
|
||||
<RouterLink to="/" class="nav-link">首页</RouterLink>
|
||||
<RouterLink to="/search" class="nav-link" v-if="isLoggedIn">搜索</RouterLink>
|
||||
<RouterLink to="/downloads" class="nav-link" v-if="isLoggedIn">下载管理</RouterLink>
|
||||
<RouterLink to="/artists" class="nav-link" v-if="isLoggedIn">作者管理</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="nav-auth">
|
||||
<div v-if="isLoggedIn" class="user-info">
|
||||
<span class="username">{{ username }}</span>
|
||||
<button @click="authStore.logout" class="btn btn-text">登出</button>
|
||||
</div>
|
||||
<RouterLink v-else to="/login" class="btn btn-primary">登录</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<main class="main-content">
|
||||
<RouterView />
|
||||
</main>
|
||||
|
||||
<!-- 页脚 -->
|
||||
<footer class="footer">
|
||||
<div class="footer-container">
|
||||
<p>© 2024 Pixiv Manager. 仅供学习和个人使用。</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.navbar {
|
||||
background: white;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.nav-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 4rem;
|
||||
}
|
||||
|
||||
.nav-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.brand-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
text-decoration: none;
|
||||
color: #1f2937;
|
||||
font-weight: 700;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.brand-icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
text-decoration: none;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
transition: color 0.2s;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.nav-link.router-link-active {
|
||||
color: #3b82f6;
|
||||
border-bottom-color: #3b82f6;
|
||||
}
|
||||
|
||||
.nav-auth {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.username {
|
||||
color: #374151;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: none;
|
||||
color: #6b7280;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
|
||||
.btn-text:hover {
|
||||
color: #374151;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.footer {
|
||||
background: #f8fafc;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding: 2rem 0;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.footer-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.nav-container {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.nav-menu {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.username {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>
|
||||
|
After Width: | Height: | Size: 276 B |
@@ -0,0 +1,281 @@
|
||||
/* 全局样式重置 */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
line-height: 1.6;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
/* 全局按钮样式 */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover:not(:disabled) {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: none;
|
||||
color: #6b7280;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.btn-text:hover {
|
||||
color: #374151;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
/* 全局容器样式 */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #f1f1f1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #c1c1c1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #a8a8a8;
|
||||
}
|
||||
|
||||
/* 工具类 */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mt-1 { margin-top: 0.25rem; }
|
||||
.mt-2 { margin-top: 0.5rem; }
|
||||
.mt-3 { margin-top: 0.75rem; }
|
||||
.mt-4 { margin-top: 1rem; }
|
||||
.mt-5 { margin-top: 1.25rem; }
|
||||
.mt-6 { margin-top: 1.5rem; }
|
||||
|
||||
.mb-1 { margin-bottom: 0.25rem; }
|
||||
.mb-2 { margin-bottom: 0.5rem; }
|
||||
.mb-3 { margin-bottom: 0.75rem; }
|
||||
.mb-4 { margin-bottom: 1rem; }
|
||||
.mb-5 { margin-bottom: 1.25rem; }
|
||||
.mb-6 { margin-bottom: 1.5rem; }
|
||||
|
||||
.ml-1 { margin-left: 0.25rem; }
|
||||
.ml-2 { margin-left: 0.5rem; }
|
||||
.ml-3 { margin-left: 0.75rem; }
|
||||
.ml-4 { margin-left: 1rem; }
|
||||
|
||||
.mr-1 { margin-right: 0.25rem; }
|
||||
.mr-2 { margin-right: 0.5rem; }
|
||||
.mr-3 { margin-right: 0.75rem; }
|
||||
.mr-4 { margin-right: 1rem; }
|
||||
|
||||
.p-1 { padding: 0.25rem; }
|
||||
.p-2 { padding: 0.5rem; }
|
||||
.p-3 { padding: 0.75rem; }
|
||||
.p-4 { padding: 1rem; }
|
||||
.p-5 { padding: 1.25rem; }
|
||||
.p-6 { padding: 1.5rem; }
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.gap-1 { gap: 0.25rem; }
|
||||
.gap-2 { gap: 0.5rem; }
|
||||
.gap-3 { gap: 0.75rem; }
|
||||
.gap-4 { gap: 1rem; }
|
||||
.gap-5 { gap: 1.25rem; }
|
||||
.gap-6 { gap: 1.5rem; }
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.h-full {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.rounded {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.rounded-lg {
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.rounded-xl {
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.shadow {
|
||||
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.shadow-lg {
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.bg-white {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.bg-gray-50 {
|
||||
background-color: #f9fafb;
|
||||
}
|
||||
|
||||
.bg-gray-100 {
|
||||
background-color: #f3f4f6;
|
||||
}
|
||||
|
||||
.text-gray-500 {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.text-gray-600 {
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.text-gray-700 {
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.text-gray-900 {
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.text-blue-600 {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.text-red-600 {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.text-green-600 {
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.text-base {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.text-lg {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.text-xl {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.text-2xl {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import WelcomeItem from './WelcomeItem.vue'
|
||||
import DocumentationIcon from './icons/IconDocumentation.vue'
|
||||
import ToolingIcon from './icons/IconTooling.vue'
|
||||
import EcosystemIcon from './icons/IconEcosystem.vue'
|
||||
import CommunityIcon from './icons/IconCommunity.vue'
|
||||
import SupportIcon from './icons/IconSupport.vue'
|
||||
|
||||
const openReadmeInEditor = () => fetch('/__open-in-editor?file=README.md')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<DocumentationIcon />
|
||||
</template>
|
||||
<template #heading>Documentation</template>
|
||||
|
||||
Vue’s
|
||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">official documentation</a>
|
||||
provides you with all information you need to get started.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<ToolingIcon />
|
||||
</template>
|
||||
<template #heading>Tooling</template>
|
||||
|
||||
This project is served and bundled with
|
||||
<a href="https://vite.dev/guide/features.html" target="_blank" rel="noopener">Vite</a>. The
|
||||
recommended IDE setup is
|
||||
<a href="https://code.visualstudio.com/" target="_blank" rel="noopener">VSCode</a>
|
||||
+
|
||||
<a href="https://github.com/vuejs/language-tools" target="_blank" rel="noopener">Vue - Official</a>. If
|
||||
you need to test your components and web pages, check out
|
||||
<a href="https://vitest.dev/" target="_blank" rel="noopener">Vitest</a>
|
||||
and
|
||||
<a href="https://www.cypress.io/" target="_blank" rel="noopener">Cypress</a>
|
||||
/
|
||||
<a href="https://playwright.dev/" target="_blank" rel="noopener">Playwright</a>.
|
||||
|
||||
<br />
|
||||
|
||||
More instructions are available in
|
||||
<a href="javascript:void(0)" @click="openReadmeInEditor"><code>README.md</code></a
|
||||
>.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<EcosystemIcon />
|
||||
</template>
|
||||
<template #heading>Ecosystem</template>
|
||||
|
||||
Get official tools and libraries for your project:
|
||||
<a href="https://pinia.vuejs.org/" target="_blank" rel="noopener">Pinia</a>,
|
||||
<a href="https://router.vuejs.org/" target="_blank" rel="noopener">Vue Router</a>,
|
||||
<a href="https://test-utils.vuejs.org/" target="_blank" rel="noopener">Vue Test Utils</a>, and
|
||||
<a href="https://github.com/vuejs/devtools" target="_blank" rel="noopener">Vue Dev Tools</a>. If
|
||||
you need more resources, we suggest paying
|
||||
<a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">Awesome Vue</a>
|
||||
a visit.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<CommunityIcon />
|
||||
</template>
|
||||
<template #heading>Community</template>
|
||||
|
||||
Got stuck? Ask your question on
|
||||
<a href="https://chat.vuejs.org" target="_blank" rel="noopener">Vue Land</a>
|
||||
(our official Discord server), or
|
||||
<a href="https://stackoverflow.com/questions/tagged/vue.js" target="_blank" rel="noopener"
|
||||
>StackOverflow</a
|
||||
>. You should also follow the official
|
||||
<a href="https://bsky.app/profile/vuejs.org" target="_blank" rel="noopener">@vuejs.org</a>
|
||||
Bluesky account or the
|
||||
<a href="https://x.com/vuejs" target="_blank" rel="noopener">@vuejs</a>
|
||||
X account for latest news in the Vue world.
|
||||
</WelcomeItem>
|
||||
|
||||
<WelcomeItem>
|
||||
<template #icon>
|
||||
<SupportIcon />
|
||||
</template>
|
||||
<template #heading>Support Vue</template>
|
||||
|
||||
As an independent project, Vue relies on community backing for its sustainability. You can help
|
||||
us by
|
||||
<a href="https://vuejs.org/sponsor/" target="_blank" rel="noopener">becoming a sponsor</a>.
|
||||
</WelcomeItem>
|
||||
</template>
|
||||
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="item">
|
||||
<i>
|
||||
<slot name="icon"></slot>
|
||||
</i>
|
||||
<div class="details">
|
||||
<h3>
|
||||
<slot name="heading"></slot>
|
||||
</h3>
|
||||
<slot></slot>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.item {
|
||||
margin-top: 2rem;
|
||||
display: flex;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.details {
|
||||
flex: 1;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
i {
|
||||
display: flex;
|
||||
place-items: center;
|
||||
place-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.4rem;
|
||||
color: var(--color-heading);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.item {
|
||||
margin-top: 0;
|
||||
padding: 0.4rem 0 1rem calc(var(--section-gap) / 2);
|
||||
}
|
||||
|
||||
i {
|
||||
top: calc(50% - 25px);
|
||||
left: -26px;
|
||||
position: absolute;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-background);
|
||||
border-radius: 8px;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.item:before {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:after {
|
||||
content: ' ';
|
||||
border-left: 1px solid var(--color-border);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: calc(50% + 25px);
|
||||
height: calc(50% - 25px);
|
||||
}
|
||||
|
||||
.item:first-of-type:before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.item:last-of-type:after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<div class="artwork-card" @click="handleClick">
|
||||
<div class="artwork-image">
|
||||
<img
|
||||
:src="artwork.image_urls.medium"
|
||||
:alt="artwork.title"
|
||||
@load="imageLoaded = true"
|
||||
@error="imageError = true"
|
||||
:class="{ loaded: imageLoaded, error: imageError }"
|
||||
/>
|
||||
<div v-if="!imageLoaded && !imageError" class="image-placeholder">
|
||||
<LoadingSpinner text="加载中..." />
|
||||
</div>
|
||||
<div v-if="imageError" class="image-error">
|
||||
<span>图片加载失败</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="artwork-info">
|
||||
<h3 class="artwork-title" :title="artwork.title">
|
||||
{{ artwork.title }}
|
||||
</h3>
|
||||
|
||||
<div class="artwork-meta">
|
||||
<div class="artist-info">
|
||||
<img
|
||||
:src="artwork.user.profile_image_urls.medium"
|
||||
:alt="artwork.user.name"
|
||||
class="artist-avatar"
|
||||
/>
|
||||
<span class="artist-name">{{ artwork.user.name }}</span>
|
||||
</div>
|
||||
|
||||
<div class="artwork-stats">
|
||||
<span class="stat">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||
</svg>
|
||||
{{ artwork.total_bookmarks }}
|
||||
</span>
|
||||
<span class="stat">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/>
|
||||
</svg>
|
||||
{{ artwork.total_view }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="artwork-tags">
|
||||
<span
|
||||
v-for="tag in artwork.tags.slice(0, 3)"
|
||||
:key="tag.name"
|
||||
class="tag"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</span>
|
||||
<span v-if="artwork.tags.length > 3" class="tag-more">
|
||||
+{{ artwork.tags.length - 3 }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
|
||||
import type { Artwork } from '@/types';
|
||||
|
||||
interface Props {
|
||||
artwork: Artwork;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'click', artwork: Artwork): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const imageLoaded = ref(false);
|
||||
const imageError = ref(false);
|
||||
|
||||
const handleClick = () => {
|
||||
emit('click', props.artwork);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.artwork-card {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.artwork-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.artwork-image {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
background: #f3f4f6;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.artwork-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.artwork-image img.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.artwork-image img.error {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.image-placeholder,
|
||||
.image-error {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.image-error {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.artwork-info {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.artwork-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.75rem 0;
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.artwork-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.artist-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.artist-avatar {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.artist-name {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.artwork-stats {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.stat svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
|
||||
.artwork-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tag-more {
|
||||
background: #e5e7eb;
|
||||
color: #6b7280;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<template>
|
||||
<div v-if="error" class="error-message" :class="type">
|
||||
<div class="error-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="error-content">
|
||||
<div class="error-title">{{ title }}</div>
|
||||
<div class="error-text">{{ error }}</div>
|
||||
</div>
|
||||
<button v-if="dismissible" @click="$emit('dismiss')" class="error-close">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
error: string | null;
|
||||
title?: string;
|
||||
type?: 'error' | 'warning' | 'info';
|
||||
dismissible?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'dismiss'): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
title: '错误',
|
||||
type: 'error',
|
||||
dismissible: false
|
||||
});
|
||||
|
||||
defineEmits<Emits>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.error-message.error {
|
||||
background-color: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.error-message.warning {
|
||||
background-color: #fffbeb;
|
||||
border: 1px solid #fed7aa;
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.error-message.info {
|
||||
background-color: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.error-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.error-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.error-close {
|
||||
flex-shrink: 0;
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.error-close:hover {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.error-close svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div class="loading-spinner" :class="{ overlay: overlay }">
|
||||
<div class="spinner">
|
||||
<div class="spinner-ring"></div>
|
||||
<div class="spinner-text" v-if="text">{{ text }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
text?: string;
|
||||
overlay?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.loading-spinner.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 9999;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.spinner-ring {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #3498db;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.spinner-text {
|
||||
color: #666;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M15 4a1 1 0 1 0 0 2V4zm0 11v-1a1 1 0 0 0-1 1h1zm0 4l-.707.707A1 1 0 0 0 16 19h-1zm-4-4l.707-.707A1 1 0 0 0 11 14v1zm-4.707-1.293a1 1 0 0 0-1.414 1.414l1.414-1.414zm-.707.707l-.707-.707.707.707zM9 11v-1a1 1 0 0 0-.707.293L9 11zm-4 0h1a1 1 0 0 0-1-1v1zm0 4H4a1 1 0 0 0 1.707.707L5 15zm10-9h2V4h-2v2zm2 0a1 1 0 0 1 1 1h2a3 3 0 0 0-3-3v2zm1 1v6h2V7h-2zm0 6a1 1 0 0 1-1 1v2a3 3 0 0 0 3-3h-2zm-1 1h-2v2h2v-2zm-3 1v4h2v-4h-2zm1.707 3.293l-4-4-1.414 1.414 4 4 1.414-1.414zM11 14H7v2h4v-2zm-4 0c-.276 0-.525-.111-.707-.293l-1.414 1.414C5.42 15.663 6.172 16 7 16v-2zm-.707 1.121l3.414-3.414-1.414-1.414-3.414 3.414 1.414 1.414zM9 12h4v-2H9v2zm4 0a3 3 0 0 0 3-3h-2a1 1 0 0 1-1 1v2zm3-3V3h-2v6h2zm0-6a3 3 0 0 0-3-3v2a1 1 0 0 1 1 1h2zm-3-3H3v2h10V0zM3 0a3 3 0 0 0-3 3h2a1 1 0 0 1 1-1V0zM0 3v6h2V3H0zm0 6a3 3 0 0 0 3 3v-2a1 1 0 0 1-1-1H0zm3 3h2v-2H3v2zm1-1v4h2v-4H4zm1.707 4.707l.586-.586-1.414-1.414-.586.586 1.414 1.414z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="17" fill="currentColor">
|
||||
<path
|
||||
d="M11 2.253a1 1 0 1 0-2 0h2zm-2 13a1 1 0 1 0 2 0H9zm.447-12.167a1 1 0 1 0 1.107-1.666L9.447 3.086zM1 2.253L.447 1.42A1 1 0 0 0 0 2.253h1zm0 13H0a1 1 0 0 0 1.553.833L1 15.253zm8.447.833a1 1 0 1 0 1.107-1.666l-1.107 1.666zm0-14.666a1 1 0 1 0 1.107 1.666L9.447 1.42zM19 2.253h1a1 1 0 0 0-.447-.833L19 2.253zm0 13l-.553.833A1 1 0 0 0 20 15.253h-1zm-9.553-.833a1 1 0 1 0 1.107 1.666L9.447 14.42zM9 2.253v13h2v-13H9zm1.553-.833C9.203.523 7.42 0 5.5 0v2c1.572 0 2.961.431 3.947 1.086l1.107-1.666zM5.5 0C3.58 0 1.797.523.447 1.42l1.107 1.666C2.539 2.431 3.928 2 5.5 2V0zM0 2.253v13h2v-13H0zm1.553 13.833C2.539 15.431 3.928 15 5.5 15v-2c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM5.5 15c1.572 0 2.961.431 3.947 1.086l1.107-1.666C9.203 13.523 7.42 13 5.5 13v2zm5.053-11.914C11.539 2.431 12.928 2 14.5 2V0c-1.92 0-3.703.523-5.053 1.42l1.107 1.666zM14.5 2c1.573 0 2.961.431 3.947 1.086l1.107-1.666C18.203.523 16.421 0 14.5 0v2zm3.5.253v13h2v-13h-2zm1.553 12.167C18.203 13.523 16.421 13 14.5 13v2c1.573 0 2.961.431 3.947 1.086l1.107-1.666zM14.5 13c-1.92 0-3.703.523-5.053 1.42l1.107 1.666C11.539 15.431 12.928 15 14.5 15v-2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M11.447 8.894a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm0 1.789a1 1 0 1 0 .894-1.789l-.894 1.789zM7.447 7.106a1 1 0 1 0-.894 1.789l.894-1.789zM10 9a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0H8zm9.447-5.606a1 1 0 1 0-.894-1.789l.894 1.789zm-2.894-.789a1 1 0 1 0 .894 1.789l-.894-1.789zm2 .789a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zM18 5a1 1 0 1 0-2 0h2zm-2 2.5a1 1 0 1 0 2 0h-2zm-5.447-4.606a1 1 0 1 0 .894-1.789l-.894 1.789zM9 1l.447-.894a1 1 0 0 0-.894 0L9 1zm-2.447.106a1 1 0 1 0 .894 1.789l-.894-1.789zm-6 3a1 1 0 1 0 .894 1.789L.553 4.106zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zm-2-.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 2.789a1 1 0 1 0 .894-1.789l-.894 1.789zM2 5a1 1 0 1 0-2 0h2zM0 7.5a1 1 0 1 0 2 0H0zm8.553 12.394a1 1 0 1 0 .894-1.789l-.894 1.789zm-1.106-2.789a1 1 0 1 0-.894 1.789l.894-1.789zm1.106 1a1 1 0 1 0 .894 1.789l-.894-1.789zm2.894.789a1 1 0 1 0-.894-1.789l.894 1.789zM8 19a1 1 0 1 0 2 0H8zm2-2.5a1 1 0 1 0-2 0h2zm-7.447.394a1 1 0 1 0 .894-1.789l-.894 1.789zM1 15H0a1 1 0 0 0 .553.894L1 15zm1-2.5a1 1 0 1 0-2 0h2zm12.553 2.606a1 1 0 1 0 .894 1.789l-.894-1.789zM17 15l.447.894A1 1 0 0 0 18 15h-1zm1-2.5a1 1 0 1 0-2 0h2zm-7.447-5.394l-2 1 .894 1.789 2-1-.894-1.789zm-1.106 1l-2-1-.894 1.789 2 1 .894-1.789zM8 9v2.5h2V9H8zm8.553-4.894l-2 1 .894 1.789 2-1-.894-1.789zm.894 0l-2-1-.894 1.789 2 1 .894-1.789zM16 5v2.5h2V5h-2zm-4.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zm-2.894-1l-2 1 .894 1.789 2-1L8.553.106zM1.447 5.894l2-1-.894-1.789-2 1 .894 1.789zm-.894 0l2 1 .894-1.789-2-1-.894 1.789zM0 5v2.5h2V5H0zm9.447 13.106l-2-1-.894 1.789 2 1 .894-1.789zm0 1.789l2-1-.894-1.789-2 1 .894 1.789zM10 19v-2.5H8V19h2zm-6.553-3.894l-2-1-.894 1.789 2 1 .894-1.789zM2 15v-2.5H0V15h2zm13.447 1.894l2-1-.894-1.789-2 1 .894 1.789zM18 15v-2.5h-2V15h2z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,7 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor">
|
||||
<path
|
||||
d="M10 3.22l-.61-.6a5.5 5.5 0 0 0-7.666.105 5.5 5.5 0 0 0-.114 7.665L10 18.78l8.39-8.4a5.5 5.5 0 0 0-.114-7.665 5.5 5.5 0 0 0-7.666-.105l-.61.61z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,19 @@
|
||||
<!-- This icon is from <https://github.com/Templarian/MaterialDesign>, distributed under Apache 2.0 (https://www.apache.org/licenses/LICENSE-2.0) license-->
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
class="iconify iconify--mdi"
|
||||
width="24"
|
||||
height="24"
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20 18v-4h-3v1h-2v-1H9v1H7v-1H4v4h16M6.33 8l-1.74 4H7v-1h2v1h6v-1h2v1h2.41l-1.74-4H6.33M9 5v1h6V5H9m12.84 7.61c.1.22.16.48.16.8V18c0 .53-.21 1-.6 1.41c-.4.4-.85.59-1.4.59H4c-.55 0-1-.19-1.4-.59C2.21 19 2 18.53 2 18v-4.59c0-.32.06-.58.16-.8L4.5 7.22C4.84 6.41 5.45 6 6.33 6H7V5c0-.55.18-1 .57-1.41C7.96 3.2 8.44 3 9 3h6c.56 0 1.04.2 1.43.59c.39.41.57.86.57 1.41v1h.67c.88 0 1.49.41 1.83 1.22l2.34 5.39z"
|
||||
fill="currentColor"
|
||||
></path>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
import './assets/main.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,63 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '../views/HomeView.vue'
|
||||
import LoginView from '../views/LoginView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: HomeView
|
||||
},
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: LoginView
|
||||
},
|
||||
{
|
||||
path: '/search',
|
||||
name: 'search',
|
||||
component: () => import('../views/SearchView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/artwork/:id',
|
||||
name: 'artwork',
|
||||
component: () => import('../views/ArtworkView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/artist/:id',
|
||||
name: 'artist',
|
||||
component: () => import('../views/ArtistView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/downloads',
|
||||
name: 'downloads',
|
||||
component: () => import('../views/DownloadsView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: '/artists',
|
||||
name: 'artists',
|
||||
component: () => import('../views/ArtistsView.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// 路由守卫
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
// 检查是否需要认证
|
||||
if (to.meta.requiresAuth) {
|
||||
// 这里可以添加认证检查逻辑
|
||||
// 暂时直接放行,后续可以集成认证状态检查
|
||||
next()
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,91 @@
|
||||
import axios, { type AxiosInstance, type AxiosRequestConfig, type AxiosResponse } from 'axios';
|
||||
import type { ApiResponse } from '@/types';
|
||||
|
||||
// API配置
|
||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3000';
|
||||
|
||||
class ApiService {
|
||||
private client: AxiosInstance;
|
||||
|
||||
constructor() {
|
||||
this.client = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// 请求拦截器
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
// 可以在这里添加认证token等
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
this.client.interceptors.response.use(
|
||||
(response: AxiosResponse<ApiResponse>) => {
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
// 统一错误处理
|
||||
if (error.response) {
|
||||
const { status, data } = error.response;
|
||||
console.error(`API Error ${status}:`, data);
|
||||
} else if (error.request) {
|
||||
console.error('Network Error:', error.request);
|
||||
} else {
|
||||
console.error('Request Error:', error.message);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET请求
|
||||
*/
|
||||
async get<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
const response = await this.client.get<ApiResponse<T>>(url, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST请求
|
||||
*/
|
||||
async post<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
const response = await this.client.post<ApiResponse<T>>(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT请求
|
||||
*/
|
||||
async put<T = any>(url: string, data?: any, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
const response = await this.client.put<ApiResponse<T>>(url, data, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE请求
|
||||
*/
|
||||
async delete<T = any>(url: string, config?: AxiosRequestConfig): Promise<ApiResponse<T>> {
|
||||
const response = await this.client.delete<ApiResponse<T>>(url, config);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 健康检查
|
||||
*/
|
||||
async healthCheck(): Promise<ApiResponse> {
|
||||
return this.get('/health');
|
||||
}
|
||||
}
|
||||
|
||||
export const apiService = new ApiService();
|
||||
export default apiService;
|
||||
@@ -0,0 +1,81 @@
|
||||
import apiService from './api';
|
||||
import type { ApiResponse, Artist, User } from '@/types';
|
||||
|
||||
export interface ArtistArtworksOptions {
|
||||
type?: 'art' | 'manga' | 'novel';
|
||||
filter?: 'for_ios' | 'for_android';
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ArtistFollowingOptions {
|
||||
restrict?: 'public' | 'private';
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ArtistFollowersOptions {
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
class ArtistService {
|
||||
/**
|
||||
* 获取作者信息
|
||||
*/
|
||||
async getArtistInfo(id: number): Promise<ApiResponse<Artist>> {
|
||||
return apiService.get<Artist>(`/api/artist/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取作者作品列表
|
||||
*/
|
||||
async getArtistArtworks(id: number, options: ArtistArtworksOptions = {}): Promise<ApiResponse<{ artworks: any[]; next_url?: string; total: number }>> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.type) params.append('type', options.type);
|
||||
if (options.filter) params.append('filter', options.filter);
|
||||
if (options.offset !== undefined) params.append('offset', options.offset.toString());
|
||||
if (options.limit !== undefined) params.append('limit', options.limit.toString());
|
||||
|
||||
const query = params.toString();
|
||||
const url = query ? `/api/artist/${id}/artworks?${query}` : `/api/artist/${id}/artworks`;
|
||||
return apiService.get<{ artworks: any[]; next_url?: string; total: number }>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取作者关注列表
|
||||
*/
|
||||
async getArtistFollowing(id: number, options: ArtistFollowingOptions = {}): Promise<ApiResponse<{ users: User[]; next_url?: string; total: number }>> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.restrict) params.append('restrict', options.restrict);
|
||||
if (options.offset !== undefined) params.append('offset', options.offset.toString());
|
||||
if (options.limit !== undefined) params.append('limit', options.limit.toString());
|
||||
|
||||
const query = params.toString();
|
||||
const url = query ? `/api/artist/${id}/following?${query}` : `/api/artist/${id}/following`;
|
||||
return apiService.get<{ users: User[]; next_url?: string; total: number }>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取作者粉丝列表
|
||||
*/
|
||||
async getArtistFollowers(id: number, options: ArtistFollowersOptions = {}): Promise<ApiResponse<{ users: User[]; next_url?: string; total: number }>> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.offset !== undefined) params.append('offset', options.offset.toString());
|
||||
if (options.limit !== undefined) params.append('limit', options.limit.toString());
|
||||
|
||||
const query = params.toString();
|
||||
const url = query ? `/api/artist/${id}/followers?${query}` : `/api/artist/${id}/followers`;
|
||||
return apiService.get<{ users: User[]; next_url?: string; total: number }>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 关注/取消关注作者
|
||||
*/
|
||||
async followArtist(id: number, action: 'follow' | 'unfollow'): Promise<ApiResponse> {
|
||||
return apiService.post(`/api/artist/${id}/follow`, { action });
|
||||
}
|
||||
}
|
||||
|
||||
export const artistService = new ArtistService();
|
||||
export default artistService;
|
||||
@@ -0,0 +1,71 @@
|
||||
import apiService from './api';
|
||||
import type { ApiResponse, Artwork, SearchParams, PaginatedResponse } from '@/types';
|
||||
|
||||
export interface ArtworkDetailOptions {
|
||||
include_user?: boolean;
|
||||
include_series?: boolean;
|
||||
}
|
||||
|
||||
export interface ArtworkImagesResponse {
|
||||
artwork_id: number;
|
||||
total_pages: number;
|
||||
images: Array<{
|
||||
page: number;
|
||||
original: string;
|
||||
large: string;
|
||||
medium: string;
|
||||
square_medium: string;
|
||||
}>;
|
||||
selected_size: string;
|
||||
}
|
||||
|
||||
class ArtworkService {
|
||||
/**
|
||||
* 获取作品详情
|
||||
*/
|
||||
async getArtworkDetail(id: number, options: ArtworkDetailOptions = {}): Promise<ApiResponse<Artwork>> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.include_user !== undefined) {
|
||||
params.append('include_user', options.include_user.toString());
|
||||
}
|
||||
if (options.include_series !== undefined) {
|
||||
params.append('include_series', options.include_series.toString());
|
||||
}
|
||||
|
||||
const query = params.toString();
|
||||
const url = query ? `/api/artwork/${id}?${query}` : `/api/artwork/${id}`;
|
||||
return apiService.get<Artwork>(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取作品预览信息
|
||||
*/
|
||||
async getArtworkPreview(id: number): Promise<ApiResponse<Artwork>> {
|
||||
return apiService.get<Artwork>(`/api/artwork/${id}/preview`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取作品图片URL
|
||||
*/
|
||||
async getArtworkImages(id: number, size: string = 'medium'): Promise<ApiResponse<ArtworkImagesResponse>> {
|
||||
return apiService.get<ArtworkImagesResponse>(`/api/artwork/${id}/images?size=${size}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索作品
|
||||
*/
|
||||
async searchArtworks(params: SearchParams): Promise<ApiResponse<{ artworks: Artwork[]; next_url?: string; total: number }>> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.append('keyword', params.keyword);
|
||||
if (params.type) queryParams.append('type', params.type);
|
||||
if (params.sort) queryParams.append('sort', params.sort);
|
||||
if (params.duration) queryParams.append('duration', params.duration);
|
||||
if (params.offset !== undefined) queryParams.append('offset', params.offset.toString());
|
||||
if (params.limit !== undefined) queryParams.append('limit', params.limit.toString());
|
||||
|
||||
return apiService.get<{ artworks: Artwork[]; next_url?: string; total: number }>(`/api/artwork/search?${queryParams.toString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const artworkService = new ArtworkService();
|
||||
export default artworkService;
|
||||
@@ -0,0 +1,59 @@
|
||||
import apiService from './api';
|
||||
import type { ApiResponse, LoginStatus } from '@/types';
|
||||
|
||||
export interface LoginUrlResponse {
|
||||
login_url: string;
|
||||
code_verifier: string;
|
||||
}
|
||||
|
||||
export interface LoginCallbackRequest {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface LoginCallbackResponse {
|
||||
user: {
|
||||
id: number;
|
||||
name: string;
|
||||
account: string;
|
||||
};
|
||||
}
|
||||
|
||||
class AuthService {
|
||||
/**
|
||||
* 获取登录状态
|
||||
*/
|
||||
async getLoginStatus(): Promise<ApiResponse<LoginStatus>> {
|
||||
return apiService.get<LoginStatus>('/api/auth/status');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取登录URL
|
||||
*/
|
||||
async getLoginUrl(): Promise<ApiResponse<LoginUrlResponse>> {
|
||||
return apiService.get<LoginUrlResponse>('/api/auth/login-url');
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理登录回调
|
||||
*/
|
||||
async handleLoginCallback(code: string): Promise<ApiResponse<LoginCallbackResponse>> {
|
||||
return apiService.post<LoginCallbackResponse>('/api/auth/callback', { code });
|
||||
}
|
||||
|
||||
/**
|
||||
* 重新登录
|
||||
*/
|
||||
async relogin(): Promise<ApiResponse> {
|
||||
return apiService.post('/api/auth/relogin');
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出
|
||||
*/
|
||||
async logout(): Promise<ApiResponse> {
|
||||
return apiService.post('/api/auth/logout');
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
export default authService;
|
||||
@@ -0,0 +1,66 @@
|
||||
import apiService from './api';
|
||||
import type { ApiResponse, DownloadTask, DownloadParams } from '@/types';
|
||||
|
||||
export interface DownloadArtworkRequest extends DownloadParams {
|
||||
size?: 'original' | 'large' | 'medium' | 'square_medium';
|
||||
quality?: 'high' | 'medium' | 'low';
|
||||
format?: 'auto' | 'jpg' | 'png';
|
||||
}
|
||||
|
||||
export interface DownloadMultipleRequest extends DownloadParams {
|
||||
artworkIds: number[];
|
||||
concurrent?: number;
|
||||
}
|
||||
|
||||
export interface DownloadArtistRequest extends DownloadParams {
|
||||
type?: 'art' | 'manga' | 'novel';
|
||||
filter?: 'for_ios' | 'for_android';
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
class DownloadService {
|
||||
/**
|
||||
* 下载单个作品
|
||||
*/
|
||||
async downloadArtwork(id: number, params: DownloadArtworkRequest = {}): Promise<ApiResponse<any>> {
|
||||
return apiService.post(`/api/download/artwork/${id}`, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量下载作品
|
||||
*/
|
||||
async downloadMultipleArtworks(params: DownloadMultipleRequest): Promise<ApiResponse<any>> {
|
||||
return apiService.post('/api/download/artworks', params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载作者作品
|
||||
*/
|
||||
async downloadArtistArtworks(id: number, params: DownloadArtistRequest = {}): Promise<ApiResponse<any>> {
|
||||
return apiService.post(`/api/download/artist/${id}`, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取下载进度
|
||||
*/
|
||||
async getDownloadProgress(taskId: string): Promise<ApiResponse<DownloadTask>> {
|
||||
return apiService.get<DownloadTask>(`/api/download/progress/${taskId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消下载任务
|
||||
*/
|
||||
async cancelDownload(taskId: string): Promise<ApiResponse> {
|
||||
return apiService.delete(`/api/download/cancel/${taskId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取下载历史
|
||||
*/
|
||||
async getDownloadHistory(offset: number = 0, limit: number = 20): Promise<ApiResponse<{ tasks: DownloadTask[]; total: number; offset: number; limit: number }>> {
|
||||
return apiService.get<{ tasks: DownloadTask[]; total: number; offset: number; limit: number }>(`/api/download/history?offset=${offset}&limit=${limit}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const downloadService = new DownloadService();
|
||||
export default downloadService;
|
||||
@@ -0,0 +1,139 @@
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import authService from '@/services/auth';
|
||||
import type { LoginStatus } from '@/types';
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
// 状态
|
||||
const loginStatus = ref<LoginStatus>({
|
||||
isLoggedIn: false
|
||||
});
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
// 计算属性
|
||||
const isLoggedIn = computed(() => loginStatus.value.isLoggedIn);
|
||||
const username = computed(() => loginStatus.value.username);
|
||||
const userId = computed(() => loginStatus.value.user_id);
|
||||
|
||||
// 获取登录状态
|
||||
const fetchLoginStatus = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
const response = await authService.getLoginStatus();
|
||||
if (response.success && response.data) {
|
||||
loginStatus.value = response.data;
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '获取登录状态失败';
|
||||
console.error('获取登录状态失败:', err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取登录URL
|
||||
const getLoginUrl = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
const response = await authService.getLoginUrl();
|
||||
if (response.success && response.data) {
|
||||
return response.data.login_url;
|
||||
}
|
||||
throw new Error(response.error || '获取登录URL失败');
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '获取登录URL失败';
|
||||
console.error('获取登录URL失败:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理登录回调
|
||||
const handleLoginCallback = async (code: string) => {
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
const response = await authService.handleLoginCallback(code);
|
||||
if (response.success) {
|
||||
await fetchLoginStatus(); // 重新获取登录状态
|
||||
return response.data;
|
||||
}
|
||||
throw new Error(response.error || '登录失败');
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '登录失败';
|
||||
console.error('登录失败:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 重新登录
|
||||
const relogin = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
const response = await authService.relogin();
|
||||
if (response.success) {
|
||||
await fetchLoginStatus(); // 重新获取登录状态
|
||||
return true;
|
||||
}
|
||||
throw new Error(response.error || '重新登录失败');
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '重新登录失败';
|
||||
console.error('重新登录失败:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 登出
|
||||
const logout = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
const response = await authService.logout();
|
||||
if (response.success) {
|
||||
loginStatus.value = { isLoggedIn: false };
|
||||
return true;
|
||||
}
|
||||
throw new Error(response.error || '登出失败');
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '登出失败';
|
||||
console.error('登出失败:', err);
|
||||
throw err;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 清除错误
|
||||
const clearError = () => {
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
return {
|
||||
// 状态
|
||||
loginStatus,
|
||||
loading,
|
||||
error,
|
||||
|
||||
// 计算属性
|
||||
isLoggedIn,
|
||||
username,
|
||||
userId,
|
||||
|
||||
// 方法
|
||||
fetchLoginStatus,
|
||||
getLoginUrl,
|
||||
handleLoginCallback,
|
||||
relogin,
|
||||
logout,
|
||||
clearError
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useCounterStore = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment }
|
||||
})
|
||||
@@ -0,0 +1,132 @@
|
||||
// 基础响应类型
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
data?: T;
|
||||
error?: string;
|
||||
code?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
// 分页响应类型
|
||||
export interface PaginatedResponse<T = any> extends ApiResponse<T[]> {
|
||||
pagination?: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
pages: number;
|
||||
};
|
||||
}
|
||||
|
||||
// 用户信息
|
||||
export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
account: string;
|
||||
profile_image_urls: {
|
||||
medium: string;
|
||||
};
|
||||
is_followed: boolean;
|
||||
}
|
||||
|
||||
// 作品信息
|
||||
export interface Artwork {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
user: User;
|
||||
image_urls: {
|
||||
square_medium: string;
|
||||
medium: string;
|
||||
large: string;
|
||||
original: string;
|
||||
};
|
||||
tags: Array<{
|
||||
name: string;
|
||||
translated_name?: string;
|
||||
}>;
|
||||
create_date: string;
|
||||
update_date: string;
|
||||
type: string;
|
||||
width: number;
|
||||
height: number;
|
||||
page_count: number;
|
||||
is_bookmarked: boolean;
|
||||
total_bookmarks: number;
|
||||
total_view: number;
|
||||
is_muted: boolean;
|
||||
meta_single_page?: {
|
||||
original_image_url?: string;
|
||||
large_image_url?: string;
|
||||
};
|
||||
meta_pages?: Array<{
|
||||
image_urls: {
|
||||
square_medium: string;
|
||||
medium: string;
|
||||
large: string;
|
||||
original: string;
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
// 作者信息
|
||||
export interface Artist {
|
||||
id: number;
|
||||
name: string;
|
||||
account: string;
|
||||
profile_image_urls: {
|
||||
medium: string;
|
||||
};
|
||||
comment: string;
|
||||
is_followed: boolean;
|
||||
total_illusts: number;
|
||||
total_manga: number;
|
||||
total_novels: number;
|
||||
total_bookmarked_illust: number;
|
||||
total_following: number;
|
||||
total_followers: number;
|
||||
}
|
||||
|
||||
// 登录状态
|
||||
export interface LoginStatus {
|
||||
isLoggedIn: boolean;
|
||||
username?: string;
|
||||
user_id?: number;
|
||||
}
|
||||
|
||||
// 下载任务
|
||||
export interface DownloadTask {
|
||||
id: string;
|
||||
type: 'artwork' | 'batch' | 'artist';
|
||||
status: 'downloading' | 'completed' | 'failed' | 'partial' | 'cancelled';
|
||||
progress: number;
|
||||
total: number;
|
||||
completed: number;
|
||||
failed: number;
|
||||
start_time: string;
|
||||
end_time?: string;
|
||||
files?: Array<{
|
||||
path: string;
|
||||
url: string;
|
||||
size: string;
|
||||
}>;
|
||||
results?: any[];
|
||||
}
|
||||
|
||||
// 搜索参数
|
||||
export interface SearchParams {
|
||||
keyword: string;
|
||||
type?: 'all' | 'art' | 'manga' | 'novel';
|
||||
sort?: 'date_desc' | 'date_asc' | 'popular_desc';
|
||||
duration?: 'all' | 'within_last_day' | 'within_last_week' | 'within_last_month';
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
// 下载参数
|
||||
export interface DownloadParams {
|
||||
size?: 'original' | 'large' | 'medium' | 'square_medium';
|
||||
quality?: 'high' | 'medium' | 'low';
|
||||
format?: 'auto' | 'jpg' | 'png';
|
||||
concurrent?: number;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<div class="about">
|
||||
<h1>This is an about page</h1>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@media (min-width: 1024px) {
|
||||
.about {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,490 @@
|
||||
<template>
|
||||
<div class="artist-page">
|
||||
<div class="container">
|
||||
<div v-if="loading" class="loading-section">
|
||||
<LoadingSpinner text="加载中..." />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-section">
|
||||
<ErrorMessage :error="error" @dismiss="clearError" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="artist" class="artist-content">
|
||||
<!-- 作者信息卡片 -->
|
||||
<div class="artist-header">
|
||||
<div class="artist-profile">
|
||||
<img
|
||||
:src="artist.profile_image_urls.medium"
|
||||
:alt="artist.name"
|
||||
class="artist-avatar"
|
||||
/>
|
||||
<div class="artist-info">
|
||||
<h1 class="artist-name">{{ artist.name }}</h1>
|
||||
<p class="artist-account">@{{ artist.account }}</p>
|
||||
<p v-if="artist.comment" class="artist-comment">{{ artist.comment }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="artist-actions">
|
||||
<button @click="handleFollow" class="btn btn-primary">
|
||||
{{ artist.is_followed ? '取消关注' : '关注' }}
|
||||
</button>
|
||||
<button @click="handleDownloadAll" class="btn btn-secondary" :disabled="downloading">
|
||||
{{ downloading ? '下载中...' : '下载所有作品' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 作者统计 -->
|
||||
<div class="artist-stats">
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{ artist.total_illusts }}</div>
|
||||
<div class="stat-label">插画</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{ artist.total_manga }}</div>
|
||||
<div class="stat-label">漫画</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{ artist.total_novels }}</div>
|
||||
<div class="stat-label">小说</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{ artist.total_followers }}</div>
|
||||
<div class="stat-label">粉丝</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-number">{{ artist.total_following }}</div>
|
||||
<div class="stat-label">关注</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 作品列表 -->
|
||||
<div class="artworks-section">
|
||||
<div class="section-header">
|
||||
<h2>作品列表</h2>
|
||||
<div class="artwork-filters">
|
||||
<select v-model="artworkType" @change="fetchArtworks" class="filter-select">
|
||||
<option value="art">插画</option>
|
||||
<option value="manga">漫画</option>
|
||||
<option value="novel">小说</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="artworksLoading" class="loading-section">
|
||||
<LoadingSpinner text="加载作品中..." />
|
||||
</div>
|
||||
|
||||
<div v-else-if="artworks.length > 0" class="artworks-grid">
|
||||
<ArtworkCard
|
||||
v-for="artwork in artworks"
|
||||
:key="artwork.id"
|
||||
:artwork="artwork"
|
||||
@click="handleArtworkClick"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-section">
|
||||
<p>暂无作品</p>
|
||||
</div>
|
||||
|
||||
<div v-if="hasMore" class="load-more">
|
||||
<button @click="loadMore" class="btn btn-secondary" :disabled="loadingMore">
|
||||
{{ loadingMore ? '加载中...' : '加载更多' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import artistService from '@/services/artist';
|
||||
import downloadService from '@/services/download';
|
||||
import type { Artist, Artwork } from '@/types';
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
|
||||
import ErrorMessage from '@/components/common/ErrorMessage.vue';
|
||||
import ArtworkCard from '@/components/artwork/ArtworkCard.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// 状态
|
||||
const artist = ref<Artist | null>(null);
|
||||
const artworks = ref<Artwork[]>([]);
|
||||
const loading = ref(false);
|
||||
const artworksLoading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const downloading = ref(false);
|
||||
|
||||
// 筛选状态
|
||||
const artworkType = ref<'art' | 'manga' | 'novel'>('art');
|
||||
const offset = ref(0);
|
||||
const hasMore = ref(true);
|
||||
|
||||
// 获取作者信息
|
||||
const fetchArtistInfo = async () => {
|
||||
const artistId = parseInt(route.params.id as string);
|
||||
if (isNaN(artistId)) {
|
||||
error.value = '无效的作者ID';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
const response = await artistService.getArtistInfo(artistId);
|
||||
|
||||
if (response.success && response.data) {
|
||||
artist.value = response.data;
|
||||
} else {
|
||||
throw new Error(response.error || '获取作者信息失败');
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '获取作者信息失败';
|
||||
console.error('获取作者信息失败:', err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取作者作品
|
||||
const fetchArtworks = async (reset = true) => {
|
||||
if (!artist.value) return;
|
||||
|
||||
try {
|
||||
artworksLoading.value = true;
|
||||
if (reset) {
|
||||
offset.value = 0;
|
||||
artworks.value = [];
|
||||
}
|
||||
|
||||
const response = await artistService.getArtistArtworks(artist.value.id, {
|
||||
type: artworkType.value,
|
||||
offset: offset.value,
|
||||
limit: 30
|
||||
});
|
||||
|
||||
if (response.success && response.data) {
|
||||
if (reset) {
|
||||
artworks.value = response.data.artworks;
|
||||
} else {
|
||||
artworks.value.push(...response.data.artworks);
|
||||
}
|
||||
hasMore.value = response.data.artworks.length === 30;
|
||||
offset.value += response.data.artworks.length;
|
||||
} else {
|
||||
throw new Error(response.error || '获取作品列表失败');
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '获取作品列表失败';
|
||||
console.error('获取作品列表失败:', err);
|
||||
} finally {
|
||||
artworksLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 加载更多
|
||||
const loadMore = async () => {
|
||||
if (loadingMore.value || !hasMore.value) return;
|
||||
|
||||
loadingMore.value = true;
|
||||
await fetchArtworks(false);
|
||||
loadingMore.value = false;
|
||||
};
|
||||
|
||||
// 关注/取消关注
|
||||
const handleFollow = async () => {
|
||||
if (!artist.value) return;
|
||||
|
||||
try {
|
||||
const action = artist.value.is_followed ? 'unfollow' : 'follow';
|
||||
const response = await artistService.followArtist(artist.value.id, action);
|
||||
|
||||
if (response.success) {
|
||||
artist.value.is_followed = !artist.value.is_followed;
|
||||
} else {
|
||||
throw new Error(response.error || '操作失败');
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '操作失败';
|
||||
console.error('关注操作失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 下载所有作品
|
||||
const handleDownloadAll = async () => {
|
||||
if (!artist.value) return;
|
||||
|
||||
try {
|
||||
downloading.value = true;
|
||||
const response = await downloadService.downloadArtistArtworks(artist.value.id, {
|
||||
type: artworkType.value,
|
||||
limit: 50
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
console.log('下载任务已创建:', response.data);
|
||||
} else {
|
||||
throw new Error(response.error || '下载失败');
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '下载失败';
|
||||
console.error('下载失败:', err);
|
||||
} finally {
|
||||
downloading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 点击作品
|
||||
const handleArtworkClick = (artwork: Artwork) => {
|
||||
router.push(`/artwork/${artwork.id}`);
|
||||
};
|
||||
|
||||
// 清除错误
|
||||
const clearError = () => {
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchArtistInfo();
|
||||
await fetchArtworks();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.artist-page {
|
||||
min-height: 100vh;
|
||||
background: #f8fafc;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.loading-section,
|
||||
.error-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.artist-header {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.artist-profile {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.artist-avatar {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.artist-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.artist-name {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.artist-account {
|
||||
color: #6b7280;
|
||||
font-size: 1.125rem;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.artist-comment {
|
||||
color: #374151;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.artist-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.artist-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #3b82f6;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.artworks-section {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.section-header h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.artwork-filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
background: white;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.artworks-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.empty-section {
|
||||
text-align: center;
|
||||
padding: 4rem 0;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.load-more {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.artist-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.artist-profile {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.artist-actions {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.artworks-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,588 @@
|
||||
<template>
|
||||
<div class="artists-page">
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">作者管理</h1>
|
||||
<div class="header-actions">
|
||||
<div class="search-box">
|
||||
<input
|
||||
v-model="searchKeyword"
|
||||
type="text"
|
||||
placeholder="搜索作者..."
|
||||
class="search-input"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<button @click="handleSearch" class="search-btn">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-section">
|
||||
<ErrorMessage :error="error" @dismiss="clearError" />
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-section">
|
||||
<LoadingSpinner text="加载中..." />
|
||||
</div>
|
||||
|
||||
<div v-else class="artists-content">
|
||||
<!-- 关注列表 -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">关注的作者</h2>
|
||||
|
||||
<div v-if="followingArtists.length > 0" class="artists-grid">
|
||||
<div
|
||||
v-for="artist in followingArtists"
|
||||
:key="artist.id"
|
||||
class="artist-card"
|
||||
>
|
||||
<div class="artist-header">
|
||||
<img
|
||||
:src="artist.profile_image_urls.medium"
|
||||
:alt="artist.name"
|
||||
class="artist-avatar"
|
||||
/>
|
||||
<div class="artist-info">
|
||||
<h3 class="artist-name">{{ artist.name }}</h3>
|
||||
<p class="artist-account">@{{ artist.account }}</p>
|
||||
</div>
|
||||
<div class="artist-actions">
|
||||
<button @click="handleUnfollow(artist.id)" class="btn btn-danger btn-small">
|
||||
取消关注
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="artist-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-number">{{ artist.total_illusts }}</span>
|
||||
<span class="stat-label">插画</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-number">{{ artist.total_manga }}</span>
|
||||
<span class="stat-label">漫画</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-number">{{ artist.total_followers }}</span>
|
||||
<span class="stat-label">粉丝</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="artist-actions-bottom">
|
||||
<router-link :to="`/artist/${artist.id}`" class="btn btn-primary btn-small">
|
||||
查看作品
|
||||
</router-link>
|
||||
<button @click="handleDownloadArtist(artist.id)" class="btn btn-secondary btn-small">
|
||||
下载作品
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-section">
|
||||
<div class="empty-content">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="empty-icon">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||
</svg>
|
||||
<h3>暂无关注的作者</h3>
|
||||
<p>关注喜欢的作者,在这里管理他们</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索建议 -->
|
||||
<div v-if="searchResults.length > 0" class="section">
|
||||
<h2 class="section-title">搜索结果</h2>
|
||||
<div class="artists-grid">
|
||||
<div
|
||||
v-for="artist in searchResults"
|
||||
:key="artist.id"
|
||||
class="artist-card"
|
||||
>
|
||||
<div class="artist-header">
|
||||
<img
|
||||
:src="artist.profile_image_urls.medium"
|
||||
:alt="artist.name"
|
||||
class="artist-avatar"
|
||||
/>
|
||||
<div class="artist-info">
|
||||
<h3 class="artist-name">{{ artist.name }}</h3>
|
||||
<p class="artist-account">@{{ artist.account }}</p>
|
||||
</div>
|
||||
<div class="artist-actions">
|
||||
<button
|
||||
@click="handleFollow(artist.id)"
|
||||
class="btn btn-primary btn-small"
|
||||
:disabled="artist.is_followed"
|
||||
>
|
||||
{{ artist.is_followed ? '已关注' : '关注' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="artist-stats">
|
||||
<div class="stat">
|
||||
<span class="stat-number">{{ artist.total_illusts }}</span>
|
||||
<span class="stat-label">插画</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-number">{{ artist.total_manga }}</span>
|
||||
<span class="stat-label">漫画</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-number">{{ artist.total_followers }}</span>
|
||||
<span class="stat-label">粉丝</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="artist-actions-bottom">
|
||||
<router-link :to="`/artist/${artist.id}`" class="btn btn-primary btn-small">
|
||||
查看作品
|
||||
</router-link>
|
||||
<button @click="handleDownloadArtist(artist.id)" class="btn btn-secondary btn-small">
|
||||
下载作品
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import artistService from '@/services/artist';
|
||||
import downloadService from '@/services/download';
|
||||
import type { Artist } from '@/types';
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
|
||||
import ErrorMessage from '@/components/common/ErrorMessage.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// 状态
|
||||
const followingArtists = ref<Artist[]>([]);
|
||||
const searchResults = ref<Artist[]>([]);
|
||||
const searchKeyword = ref('');
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
// 获取关注的作者
|
||||
const fetchFollowingArtists = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
// 这里需要根据实际API调整
|
||||
// const response = await artistService.getFollowingArtists();
|
||||
// if (response.success && response.data) {
|
||||
// followingArtists.value = response.data.artists;
|
||||
// }
|
||||
|
||||
// 暂时使用模拟数据
|
||||
followingArtists.value = [];
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '获取关注列表失败';
|
||||
console.error('获取关注列表失败:', err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 搜索作者
|
||||
const handleSearch = async () => {
|
||||
if (!searchKeyword.value.trim()) {
|
||||
searchResults.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 这里需要根据实际API调整
|
||||
// const response = await artistService.searchArtists({ keyword: searchKeyword.value });
|
||||
// if (response.success && response.data) {
|
||||
// searchResults.value = response.data.artists;
|
||||
// }
|
||||
|
||||
// 暂时使用模拟数据
|
||||
searchResults.value = [];
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '搜索失败';
|
||||
console.error('搜索失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 关注作者
|
||||
const handleFollow = async (artistId: number) => {
|
||||
try {
|
||||
const response = await artistService.followArtist(artistId, 'follow');
|
||||
|
||||
if (response.success) {
|
||||
// 更新搜索结果的关注状态
|
||||
const artist = searchResults.value.find(a => a.id === artistId);
|
||||
if (artist) {
|
||||
artist.is_followed = true;
|
||||
}
|
||||
|
||||
// 添加到关注列表
|
||||
const artistToAdd = searchResults.value.find(a => a.id === artistId);
|
||||
if (artistToAdd) {
|
||||
followingArtists.value.push(artistToAdd);
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.error || '关注失败');
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '关注失败';
|
||||
console.error('关注失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 取消关注
|
||||
const handleUnfollow = async (artistId: number) => {
|
||||
try {
|
||||
const response = await artistService.followArtist(artistId, 'unfollow');
|
||||
|
||||
if (response.success) {
|
||||
// 从关注列表中移除
|
||||
followingArtists.value = followingArtists.value.filter(a => a.id !== artistId);
|
||||
|
||||
// 更新搜索结果的关注状态
|
||||
const artist = searchResults.value.find(a => a.id === artistId);
|
||||
if (artist) {
|
||||
artist.is_followed = false;
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.error || '取消关注失败');
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '取消关注失败';
|
||||
console.error('取消关注失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 下载作者作品
|
||||
const handleDownloadArtist = async (artistId: number) => {
|
||||
try {
|
||||
const response = await downloadService.downloadArtistArtworks(artistId, {
|
||||
limit: 50
|
||||
});
|
||||
|
||||
if (response.success) {
|
||||
console.log('下载任务已创建:', response.data);
|
||||
router.push('/downloads');
|
||||
} else {
|
||||
throw new Error(response.error || '下载失败');
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '下载失败';
|
||||
console.error('下载失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 清除错误
|
||||
const clearError = () => {
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchFollowingArtists();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.artists-page {
|
||||
min-height: 100vh;
|
||||
background: #f8fafc;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: white;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding: 0.75rem 1rem;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 1rem;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
padding: 0.75rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.search-btn:hover {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.search-btn svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.error-section,
|
||||
.loading-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.loading-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 1.5rem 0;
|
||||
}
|
||||
|
||||
.artists-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.artist-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.artist-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.artist-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.artist-avatar {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.artist-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.artist-name {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.artist-account {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.artist-actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.artist-stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin-bottom: 1rem;
|
||||
padding: 1rem 0;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.artist-actions-bottom {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.empty-section {
|
||||
text-align: center;
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-content h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-content p {
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
min-width: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.artists-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.artist-header {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.artist-actions-bottom {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,554 @@
|
||||
<template>
|
||||
<div class="artwork-page">
|
||||
<div class="container">
|
||||
<div v-if="loading" class="loading-section">
|
||||
<LoadingSpinner text="加载中..." />
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-section">
|
||||
<ErrorMessage :error="error" @dismiss="clearError" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="artwork" class="artwork-content">
|
||||
<!-- 作品图片 -->
|
||||
<div class="artwork-gallery">
|
||||
<div class="main-image">
|
||||
<img
|
||||
:src="currentImageUrl"
|
||||
:alt="artwork.title"
|
||||
@load="imageLoaded = true"
|
||||
@error="imageError = true"
|
||||
:class="{ loaded: imageLoaded, error: imageError }"
|
||||
/>
|
||||
<div v-if="!imageLoaded && !imageError" class="image-placeholder">
|
||||
<LoadingSpinner text="加载中..." />
|
||||
</div>
|
||||
<div v-if="imageError" class="image-error">
|
||||
<span>图片加载失败</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 多页作品缩略图 -->
|
||||
<div v-if="artwork.page_count > 1" class="thumbnails">
|
||||
<button
|
||||
v-for="(page, index) in artwork.meta_pages"
|
||||
:key="index"
|
||||
@click="currentPage = index"
|
||||
class="thumbnail"
|
||||
:class="{ active: currentPage === index }"
|
||||
>
|
||||
<img :src="page.image_urls.square_medium" :alt="`第 ${index + 1} 页`" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 作品信息 -->
|
||||
<div class="artwork-info">
|
||||
<div class="artwork-header">
|
||||
<h1 class="artwork-title">{{ artwork.title }}</h1>
|
||||
<div class="artwork-actions">
|
||||
<button @click="handleDownload" class="btn btn-primary" :disabled="downloading">
|
||||
{{ downloading ? '下载中...' : '下载' }}
|
||||
</button>
|
||||
<button @click="handleBookmark" class="btn btn-secondary">
|
||||
{{ artwork.is_bookmarked ? '取消收藏' : '收藏' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 作者信息 -->
|
||||
<div class="artist-info">
|
||||
<img
|
||||
:src="artwork.user.profile_image_urls.medium"
|
||||
:alt="artwork.user.name"
|
||||
class="artist-avatar"
|
||||
/>
|
||||
<div class="artist-details">
|
||||
<h3 class="artist-name">{{ artwork.user.name }}</h3>
|
||||
<p class="artist-account">@{{ artwork.user.account }}</p>
|
||||
</div>
|
||||
<router-link :to="`/artist/${artwork.user.id}`" class="btn btn-text">
|
||||
查看作者
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 作品统计 -->
|
||||
<div class="artwork-stats">
|
||||
<div class="stat">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/>
|
||||
</svg>
|
||||
<span>{{ artwork.total_bookmarks }}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/>
|
||||
</svg>
|
||||
<span>{{ artwork.total_view }}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14z"/>
|
||||
</svg>
|
||||
<span>{{ artwork.width }} × {{ artwork.height }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 标签 -->
|
||||
<div class="artwork-tags">
|
||||
<h3>标签</h3>
|
||||
<div class="tags-list">
|
||||
<span
|
||||
v-for="tag in artwork.tags"
|
||||
:key="tag.name"
|
||||
class="tag"
|
||||
>
|
||||
{{ tag.name }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 描述 -->
|
||||
<div v-if="artwork.description" class="artwork-description">
|
||||
<h3>描述</h3>
|
||||
<div class="description-content" v-html="artwork.description"></div>
|
||||
</div>
|
||||
|
||||
<!-- 创建时间 -->
|
||||
<div class="artwork-meta">
|
||||
<p>创建时间: {{ formatDate(artwork.create_date) }}</p>
|
||||
<p v-if="artwork.update_date !== artwork.create_date">
|
||||
更新时间: {{ formatDate(artwork.update_date) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import artworkService from '@/services/artwork';
|
||||
import downloadService from '@/services/download';
|
||||
import type { Artwork } from '@/types';
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
|
||||
import ErrorMessage from '@/components/common/ErrorMessage.vue';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// 状态
|
||||
const artwork = ref<Artwork | null>(null);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const currentPage = ref(0);
|
||||
const imageLoaded = ref(false);
|
||||
const imageError = ref(false);
|
||||
const downloading = ref(false);
|
||||
|
||||
// 计算属性
|
||||
const currentImageUrl = computed(() => {
|
||||
if (!artwork.value) return '';
|
||||
|
||||
if (artwork.value.page_count === 1) {
|
||||
return artwork.value.image_urls.large;
|
||||
} else if (artwork.value.meta_pages && artwork.value.meta_pages[currentPage.value]) {
|
||||
return artwork.value.meta_pages[currentPage.value].image_urls.large;
|
||||
}
|
||||
|
||||
return artwork.value.image_urls.large;
|
||||
});
|
||||
|
||||
// 获取作品详情
|
||||
const fetchArtworkDetail = async () => {
|
||||
const artworkId = parseInt(route.params.id as string);
|
||||
if (isNaN(artworkId)) {
|
||||
error.value = '无效的作品ID';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
const response = await artworkService.getArtworkDetail(artworkId);
|
||||
|
||||
if (response.success && response.data) {
|
||||
artwork.value = response.data;
|
||||
} else {
|
||||
throw new Error(response.error || '获取作品详情失败');
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '获取作品详情失败';
|
||||
console.error('获取作品详情失败:', err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 下载作品
|
||||
const handleDownload = async () => {
|
||||
if (!artwork.value) return;
|
||||
|
||||
try {
|
||||
downloading.value = true;
|
||||
const response = await downloadService.downloadArtwork(artwork.value.id);
|
||||
|
||||
if (response.success) {
|
||||
// 可以显示下载成功提示
|
||||
console.log('下载任务已创建:', response.data);
|
||||
} else {
|
||||
throw new Error(response.error || '下载失败');
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '下载失败';
|
||||
console.error('下载失败:', err);
|
||||
} finally {
|
||||
downloading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 收藏/取消收藏
|
||||
const handleBookmark = () => {
|
||||
// 这里可以添加收藏功能
|
||||
console.log('收藏功能待实现');
|
||||
};
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('zh-CN');
|
||||
};
|
||||
|
||||
// 清除错误
|
||||
const clearError = () => {
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchArtworkDetail();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.artwork-page {
|
||||
min-height: 100vh;
|
||||
background: #f8fafc;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.loading-section,
|
||||
.error-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 400px;
|
||||
}
|
||||
|
||||
.artwork-content {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 400px;
|
||||
gap: 3rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.artwork-gallery {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.main-image {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
background: #f3f4f6;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.main-image img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.main-image img.loaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.main-image img.error {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.image-placeholder,
|
||||
.image-error {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.image-error {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.thumbnails {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
flex-shrink: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.thumbnail.active {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.thumbnail img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.artwork-info {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
padding: 2rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.artwork-header {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.artwork-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.artwork-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: none;
|
||||
color: #3b82f6;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.btn-text:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.artist-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
background: #f8fafc;
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.artist-avatar {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.artist-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.artist-name {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.artist-account {
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.artwork-stats {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.stat svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.artwork-tags {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.artwork-tags h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.tags-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tag {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.artwork-description {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.artwork-description h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.description-content {
|
||||
color: #374151;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.artwork-meta {
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.artwork-meta p {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.artwork-content {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.artwork-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.artwork-stats {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.thumbnails {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.thumbnail {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,607 @@
|
||||
<template>
|
||||
<div class="downloads-page">
|
||||
<div class="container">
|
||||
<div class="page-header">
|
||||
<h1 class="page-title">下载管理</h1>
|
||||
<div class="header-actions">
|
||||
<button @click="refreshHistory" class="btn btn-secondary" :disabled="loading">
|
||||
{{ loading ? '刷新中...' : '刷新' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-section">
|
||||
<ErrorMessage :error="error" @dismiss="clearError" />
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-section">
|
||||
<LoadingSpinner text="加载中..." />
|
||||
</div>
|
||||
|
||||
<div v-else class="downloads-content">
|
||||
<!-- 活跃任务 -->
|
||||
<div v-if="activeTasks.length > 0" class="section">
|
||||
<h2 class="section-title">活跃任务</h2>
|
||||
<div class="tasks-grid">
|
||||
<div
|
||||
v-for="task in activeTasks"
|
||||
:key="task.id"
|
||||
class="task-card active"
|
||||
>
|
||||
<div class="task-header">
|
||||
<div class="task-info">
|
||||
<h3 class="task-title">{{ getTaskTitle(task) }}</h3>
|
||||
<p class="task-type">{{ getTaskTypeLabel(task.type) }}</p>
|
||||
</div>
|
||||
<div class="task-status">
|
||||
<span class="status-badge downloading">下载中</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-progress">
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-fill"
|
||||
:style="{ width: `${task.progress}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="progress-text">
|
||||
{{ task.completed }}/{{ task.total }} ({{ task.progress }}%)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-actions">
|
||||
<button @click="cancelTask(task.id)" class="btn btn-danger">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 历史记录 -->
|
||||
<div class="section">
|
||||
<h2 class="section-title">下载历史</h2>
|
||||
|
||||
<div v-if="completedTasks.length > 0" class="tasks-grid">
|
||||
<div
|
||||
v-for="task in completedTasks"
|
||||
:key="task.id"
|
||||
class="task-card completed"
|
||||
>
|
||||
<div class="task-header">
|
||||
<div class="task-info">
|
||||
<h3 class="task-title">{{ getTaskTitle(task) }}</h3>
|
||||
<p class="task-type">{{ getTaskTypeLabel(task.type) }}</p>
|
||||
</div>
|
||||
<div class="task-status">
|
||||
<span class="status-badge" :class="task.status">
|
||||
{{ getStatusLabel(task.status) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-details">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">完成时间:</span>
|
||||
<span class="detail-value">{{ formatDate(task.end_time) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">文件数量:</span>
|
||||
<span class="detail-value">{{ task.completed }}/{{ task.total }}</span>
|
||||
</div>
|
||||
<div v-if="task.failed > 0" class="detail-item">
|
||||
<span class="detail-label">失败数量:</span>
|
||||
<span class="detail-value error">{{ task.failed }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="task.files && task.files.length > 0" class="task-files">
|
||||
<h4>下载的文件:</h4>
|
||||
<div class="files-list">
|
||||
<div
|
||||
v-for="file in task.files.slice(0, 3)"
|
||||
:key="file.path"
|
||||
class="file-item"
|
||||
>
|
||||
<span class="file-name">{{ getFileName(file.path) }}</span>
|
||||
<span class="file-size">{{ file.size }}</span>
|
||||
</div>
|
||||
<div v-if="task.files.length > 3" class="file-more">
|
||||
还有 {{ task.files.length - 3 }} 个文件...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-section">
|
||||
<div class="empty-content">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="empty-icon">
|
||||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
||||
</svg>
|
||||
<h3>暂无下载记录</h3>
|
||||
<p>开始下载作品后,这里会显示下载历史</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import downloadService from '@/services/download';
|
||||
import type { DownloadTask } from '@/types';
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
|
||||
import ErrorMessage from '@/components/common/ErrorMessage.vue';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// 状态
|
||||
const tasks = ref<DownloadTask[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
// 计算属性
|
||||
const activeTasks = computed(() =>
|
||||
tasks.value.filter(task => task.status === 'downloading')
|
||||
);
|
||||
|
||||
const completedTasks = computed(() =>
|
||||
tasks.value.filter(task =>
|
||||
task.status === 'completed' ||
|
||||
task.status === 'partial' ||
|
||||
task.status === 'failed' ||
|
||||
task.status === 'cancelled'
|
||||
).sort((a, b) => new Date(b.end_time || '').getTime() - new Date(a.end_time || '').getTime())
|
||||
);
|
||||
|
||||
// 获取下载历史
|
||||
const fetchDownloadHistory = async () => {
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
|
||||
const response = await downloadService.getDownloadHistory();
|
||||
|
||||
if (response.success && response.data) {
|
||||
tasks.value = response.data.tasks;
|
||||
} else {
|
||||
throw new Error(response.error || '获取下载历史失败');
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '获取下载历史失败';
|
||||
console.error('获取下载历史失败:', err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
// 取消任务
|
||||
const cancelTask = async (taskId: string) => {
|
||||
try {
|
||||
const response = await downloadService.cancelDownload(taskId);
|
||||
|
||||
if (response.success) {
|
||||
// 更新任务状态
|
||||
const task = tasks.value.find(t => t.id === taskId);
|
||||
if (task) {
|
||||
task.status = 'cancelled';
|
||||
task.end_time = new Date().toISOString();
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.error || '取消任务失败');
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '取消任务失败';
|
||||
console.error('取消任务失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 刷新历史
|
||||
const refreshHistory = async () => {
|
||||
await fetchDownloadHistory();
|
||||
};
|
||||
|
||||
// 获取任务标题
|
||||
const getTaskTitle = (task: DownloadTask) => {
|
||||
switch (task.type) {
|
||||
case 'artwork':
|
||||
return `作品 #${task.artwork_id}`;
|
||||
case 'artist':
|
||||
return `作者作品`;
|
||||
case 'batch':
|
||||
return `批量下载 (${task.total} 个作品)`;
|
||||
default:
|
||||
return '下载任务';
|
||||
}
|
||||
};
|
||||
|
||||
// 获取任务类型标签
|
||||
const getTaskTypeLabel = (type: string) => {
|
||||
switch (type) {
|
||||
case 'artwork':
|
||||
return '单个作品';
|
||||
case 'artist':
|
||||
return '作者作品';
|
||||
case 'batch':
|
||||
return '批量下载';
|
||||
default:
|
||||
return type;
|
||||
}
|
||||
};
|
||||
|
||||
// 获取状态标签
|
||||
const getStatusLabel = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return '已完成';
|
||||
case 'partial':
|
||||
return '部分完成';
|
||||
case 'failed':
|
||||
return '失败';
|
||||
case 'cancelled':
|
||||
return '已取消';
|
||||
default:
|
||||
return status;
|
||||
}
|
||||
};
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleString('zh-CN');
|
||||
};
|
||||
|
||||
// 获取文件名
|
||||
const getFileName = (filePath: string) => {
|
||||
return filePath.split('/').pop() || filePath;
|
||||
};
|
||||
|
||||
// 清除错误
|
||||
const clearError = () => {
|
||||
error.value = null;
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchDownloadHistory();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.downloads-page {
|
||||
min-height: 100vh;
|
||||
background: #f8fafc;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
|
||||
.error-section,
|
||||
.loading-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.loading-section {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 1.5rem 0;
|
||||
}
|
||||
|
||||
.tasks-grid {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
padding: 1.5rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
border-left: 4px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.task-card.active {
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
|
||||
.task-card.completed {
|
||||
border-left-color: #10b981;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.task-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.task-type {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-status {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 1rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.status-badge.downloading {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
.status-badge.completed {
|
||||
background: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.status-badge.partial {
|
||||
background: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.status-badge.failed {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.status-badge.cancelled {
|
||||
background: #f3f4f6;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.task-progress {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 0.5rem;
|
||||
background: #e5e7eb;
|
||||
border-radius: 0.25rem;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #3b82f6;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.task-details {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.detail-value {
|
||||
color: #374151;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.detail-value.error {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.task-files {
|
||||
border-top: 1px solid #e5e7eb;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.task-files h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.files-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.file-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
flex-shrink: 0;
|
||||
margin-left: 1rem;
|
||||
}
|
||||
|
||||
.file-more {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
text-align: center;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.empty-section {
|
||||
text-align: center;
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-content h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-content p {
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,365 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const isLoggedIn = computed(() => authStore.isLoggedIn);
|
||||
|
||||
onMounted(async () => {
|
||||
await authStore.fetchLoginStatus();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="home">
|
||||
<div class="hero-section">
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title">Pixiv 作品管理器</h1>
|
||||
<p class="hero-subtitle">
|
||||
发现、收藏、下载你喜欢的 Pixiv 作品
|
||||
</p>
|
||||
|
||||
<div class="hero-actions">
|
||||
<router-link
|
||||
v-if="!isLoggedIn"
|
||||
to="/login"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
立即登录
|
||||
</router-link>
|
||||
<router-link
|
||||
v-else
|
||||
to="/search"
|
||||
class="btn btn-primary"
|
||||
>
|
||||
开始搜索
|
||||
</router-link>
|
||||
|
||||
<router-link
|
||||
to="/downloads"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
下载管理
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="features-section">
|
||||
<div class="container">
|
||||
<h2 class="section-title">主要功能</h2>
|
||||
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="feature-title">作品搜索</h3>
|
||||
<p class="feature-description">
|
||||
通过关键词、标签、作者等多种方式搜索作品
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="feature-title">一键下载</h3>
|
||||
<p class="feature-description">
|
||||
支持单个作品、批量作品、作者作品下载
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="feature-title">作者管理</h3>
|
||||
<p class="feature-description">
|
||||
关注喜欢的作者,查看作品列表和统计信息
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm0 16H5V5h14v14z"/>
|
||||
<path d="M7 12h2v5H7zm4-3h2v8h-2zm4-3h2v11h-2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="feature-title">下载管理</h3>
|
||||
<p class="feature-description">
|
||||
实时查看下载进度,管理下载历史和任务
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoggedIn" class="quick-actions">
|
||||
<div class="container">
|
||||
<h2 class="section-title">快速操作</h2>
|
||||
|
||||
<div class="quick-actions-grid">
|
||||
<router-link to="/search" class="quick-action-card">
|
||||
<div class="quick-action-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span>搜索作品</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/downloads" class="quick-action-card">
|
||||
<div class="quick-action-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span>下载管理</span>
|
||||
</router-link>
|
||||
|
||||
<router-link to="/artists" class="quick-action-card">
|
||||
<div class="quick-action-icon">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<span>作者管理</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.hero-section {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
padding: 4rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 3rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.25rem;
|
||||
margin-bottom: 2rem;
|
||||
opacity: 0.9;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: white;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.features-section {
|
||||
padding: 4rem 0;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
text-align: center;
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 3rem;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.feature-card {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 1rem;
|
||||
text-align: center;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.feature-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
margin: 0 auto 1.5rem;
|
||||
background: #3b82f6;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.feature-icon svg {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.feature-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 1rem;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.feature-description {
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.quick-actions-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1.5rem;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.quick-action-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
text-decoration: none;
|
||||
color: #374151;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.quick-action-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.quick-action-icon {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
background: #f3f4f6;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6b7280;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.quick-action-card:hover .quick-action-icon {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.quick-action-icon svg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.hero-actions {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.features-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.quick-actions-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,337 @@
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<div class="login-container">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<h1 class="login-title">登录 Pixiv</h1>
|
||||
<p class="login-subtitle">
|
||||
通过 Pixiv 账号登录以使用所有功能
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-section">
|
||||
<ErrorMessage
|
||||
:error="error"
|
||||
title="登录失败"
|
||||
dismissible
|
||||
@dismiss="clearError"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="login-status" v-if="isLoggedIn">
|
||||
<div class="status-success">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-2 15l-5-5 1.41-1.41L10 14.17l7.59-7.59L19 8l-9 9z"/>
|
||||
</svg>
|
||||
<div class="status-content">
|
||||
<h3>已登录</h3>
|
||||
<p>欢迎回来,{{ username }}!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="login-actions">
|
||||
<router-link to="/" class="btn btn-primary">
|
||||
返回首页
|
||||
</router-link>
|
||||
<button @click="handleLogout" class="btn btn-secondary" :disabled="loading">
|
||||
{{ loading ? '登出中...' : '登出' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="login-form">
|
||||
<div class="login-info">
|
||||
<p>点击下方按钮将跳转到 Pixiv 官方登录页面</p>
|
||||
<ul class="login-features">
|
||||
<li>安全可靠的官方登录</li>
|
||||
<li>支持所有 Pixiv 功能</li>
|
||||
<li>自动保存登录状态</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="login-actions">
|
||||
<button
|
||||
@click="handleLogin"
|
||||
class="btn btn-primary btn-large"
|
||||
:disabled="loading"
|
||||
>
|
||||
<LoadingSpinner v-if="loading" text="获取登录链接..." />
|
||||
<span v-else>开始登录</span>
|
||||
</button>
|
||||
|
||||
<router-link to="/" class="btn btn-text">
|
||||
返回首页
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="login-footer">
|
||||
<p class="footer-text">
|
||||
登录即表示您同意我们的
|
||||
<a href="#" class="footer-link">服务条款</a>
|
||||
和
|
||||
<a href="#" class="footer-link">隐私政策</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
|
||||
import ErrorMessage from '@/components/common/ErrorMessage.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const isLoggedIn = computed(() => authStore.isLoggedIn);
|
||||
const username = computed(() => authStore.username);
|
||||
const loading = computed(() => authStore.loading);
|
||||
const error = computed(() => authStore.error);
|
||||
|
||||
onMounted(async () => {
|
||||
await authStore.fetchLoginStatus();
|
||||
});
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
const loginUrl = await authStore.getLoginUrl();
|
||||
window.open(loginUrl, '_blank');
|
||||
} catch (err) {
|
||||
console.error('获取登录URL失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLogout = async () => {
|
||||
try {
|
||||
await authStore.logout();
|
||||
router.push('/');
|
||||
} catch (err) {
|
||||
console.error('登出失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const clearError = () => {
|
||||
authStore.clearError();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-container {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: white;
|
||||
border-radius: 1rem;
|
||||
padding: 2.5rem;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.login-subtitle {
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.error-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.login-status {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
color: #166534;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.status-success svg {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-content h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.status-content p {
|
||||
margin: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.login-info {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.login-info p {
|
||||
color: #6b7280;
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.login-features {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.login-features li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: #374151;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.login-features li::before {
|
||||
content: "✓";
|
||||
color: #10b981;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.login-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1rem;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: none;
|
||||
color: #6b7280;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.btn-text:hover {
|
||||
color: #374151;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
padding: 1rem 2rem;
|
||||
font-size: 1.125rem;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
text-align: center;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.footer-text {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.login-page {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.login-title {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
min-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,408 @@
|
||||
<template>
|
||||
<div class="search-page">
|
||||
<div class="search-header">
|
||||
<div class="container">
|
||||
<h1 class="page-title">搜索作品</h1>
|
||||
|
||||
<div class="search-form">
|
||||
<div class="search-input-group">
|
||||
<input
|
||||
v-model="searchKeyword"
|
||||
type="text"
|
||||
placeholder="输入关键词搜索作品..."
|
||||
class="search-input"
|
||||
@keyup.enter="handleSearch"
|
||||
/>
|
||||
<button @click="handleSearch" class="search-btn" :disabled="loading">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="search-filters">
|
||||
<select v-model="searchType" class="filter-select">
|
||||
<option value="all">全部类型</option>
|
||||
<option value="art">插画</option>
|
||||
<option value="manga">漫画</option>
|
||||
<option value="novel">小说</option>
|
||||
</select>
|
||||
|
||||
<select v-model="searchSort" class="filter-select">
|
||||
<option value="date_desc">最新</option>
|
||||
<option value="date_asc">最旧</option>
|
||||
<option value="popular_desc">最受欢迎</option>
|
||||
</select>
|
||||
|
||||
<select v-model="searchDuration" class="filter-select">
|
||||
<option value="all">全部时间</option>
|
||||
<option value="within_last_day">最近一天</option>
|
||||
<option value="within_last_week">最近一周</option>
|
||||
<option value="within_last_month">最近一月</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="search-content">
|
||||
<div class="container">
|
||||
<div v-if="error" class="error-section">
|
||||
<ErrorMessage :error="error" @dismiss="clearError" />
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-section">
|
||||
<LoadingSpinner text="搜索中..." />
|
||||
</div>
|
||||
|
||||
<div v-else-if="searchResults.length > 0" class="results-section">
|
||||
<div class="results-header">
|
||||
<h2>搜索结果 ({{ totalResults }})</h2>
|
||||
<div class="results-actions">
|
||||
<button @click="loadMore" class="btn btn-secondary" :disabled="loadingMore">
|
||||
{{ loadingMore ? '加载中...' : '加载更多' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="artworks-grid">
|
||||
<ArtworkCard
|
||||
v-for="artwork in searchResults"
|
||||
:key="artwork.id"
|
||||
:artwork="artwork"
|
||||
@click="handleArtworkClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="hasSearched" class="empty-section">
|
||||
<div class="empty-content">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor" class="empty-icon">
|
||||
<path d="M15.5 14h-.79l-.28-.27C15.41 12.59 16 11.11 16 9.5 16 5.91 13.09 3 9.5 3S3 5.91 3 9.5 5.91 16 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/>
|
||||
</svg>
|
||||
<h3>未找到相关作品</h3>
|
||||
<p>尝试使用不同的关键词或调整搜索条件</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="welcome-section">
|
||||
<div class="welcome-content">
|
||||
<h2>开始搜索</h2>
|
||||
<p>输入关键词来搜索你喜欢的作品</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import artworkService from '@/services/artwork';
|
||||
import type { Artwork, SearchParams } from '@/types';
|
||||
import LoadingSpinner from '@/components/common/LoadingSpinner.vue';
|
||||
import ErrorMessage from '@/components/common/ErrorMessage.vue';
|
||||
import ArtworkCard from '@/components/artwork/ArtworkCard.vue';
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
// 搜索状态
|
||||
const searchKeyword = ref('');
|
||||
const searchType = ref<'all' | 'art' | 'manga' | 'novel'>('all');
|
||||
const searchSort = ref<'date_desc' | 'date_asc' | 'popular_desc'>('date_desc');
|
||||
const searchDuration = ref<'all' | 'within_last_day' | 'within_last_week' | 'within_last_month'>('all');
|
||||
|
||||
// 结果状态
|
||||
const searchResults = ref<Artwork[]>([]);
|
||||
const totalResults = ref(0);
|
||||
const loading = ref(false);
|
||||
const loadingMore = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
const hasSearched = ref(false);
|
||||
const offset = ref(0);
|
||||
|
||||
const handleSearch = async () => {
|
||||
if (!searchKeyword.value.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true;
|
||||
error.value = null;
|
||||
offset.value = 0;
|
||||
hasSearched.value = true;
|
||||
|
||||
const params: SearchParams = {
|
||||
keyword: searchKeyword.value.trim(),
|
||||
type: searchType.value,
|
||||
sort: searchSort.value,
|
||||
duration: searchDuration.value,
|
||||
offset: 0,
|
||||
limit: 30
|
||||
};
|
||||
|
||||
const response = await artworkService.searchArtworks(params);
|
||||
|
||||
if (response.success && response.data) {
|
||||
searchResults.value = response.data.artworks;
|
||||
totalResults.value = response.data.total;
|
||||
} else {
|
||||
throw new Error(response.error || '搜索失败');
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '搜索失败';
|
||||
console.error('搜索失败:', err);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!searchKeyword.value.trim() || loadingMore.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
loadingMore.value = true;
|
||||
offset.value += 30;
|
||||
|
||||
const params: SearchParams = {
|
||||
keyword: searchKeyword.value.trim(),
|
||||
type: searchType.value,
|
||||
sort: searchSort.value,
|
||||
duration: searchDuration.value,
|
||||
offset: offset.value,
|
||||
limit: 30
|
||||
};
|
||||
|
||||
const response = await artworkService.searchArtworks(params);
|
||||
|
||||
if (response.success && response.data) {
|
||||
searchResults.value.push(...response.data.artworks);
|
||||
} else {
|
||||
throw new Error(response.error || '加载更多失败');
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : '加载更多失败';
|
||||
console.error('加载更多失败:', err);
|
||||
} finally {
|
||||
loadingMore.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleArtworkClick = (artwork: Artwork) => {
|
||||
router.push(`/artwork/${artwork.id}`);
|
||||
};
|
||||
|
||||
const clearError = () => {
|
||||
error.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.search-page {
|
||||
min-height: 100vh;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.search-header {
|
||||
background: white;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.search-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.search-input-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.search-btn {
|
||||
padding: 0.75rem 1rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.search-btn:hover:not(:disabled) {
|
||||
background: #2563eb;
|
||||
}
|
||||
|
||||
.search-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.search-btn svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.search-filters {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
background: white;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.search-content {
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.error-section,
|
||||
.loading-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.results-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.results-header h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.btn-secondary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.artworks-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.empty-section,
|
||||
.welcome-section {
|
||||
text-align: center;
|
||||
padding: 4rem 0;
|
||||
}
|
||||
|
||||
.empty-content,
|
||||
.welcome-content {
|
||||
max-width: 400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
color: #9ca3af;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-content h3,
|
||||
.welcome-content h2 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.empty-content p,
|
||||
.welcome-content p {
|
||||
color: #6b7280;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.search-filters {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.results-header {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.artworks-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "@tsconfig/node22/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
vueDevTools(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user