初始化

This commit is contained in:
2025-08-21 10:43:04 +08:00
commit 29a79b1c6b
68 changed files with 13314 additions and 0 deletions
+25
View File
@@ -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
View File
@@ -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/
+9
View File
@@ -0,0 +1,9 @@
{
"printWidth": 200,
"singleQuote": true,
"trailingComma": "es5",
"arrowParens": "avoid",
"useTabs": false,
"tabWidth": 2,
"semi": true
}
+217
View File
@@ -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
View File
@@ -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;
+36
View File
@@ -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
View File
@@ -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;
+50
View File
@@ -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
};
+62
View File
@@ -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 };
+225
View File
@@ -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;
+172
View File
@@ -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;
+124
View File
@@ -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;
+270
View File
@@ -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;
+155
View File
@@ -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;
+344
View File
@@ -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;
+307
View File
@@ -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;
+444
View File
@@ -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;
+47
View File
@@ -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);
});
+198
View File
@@ -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
};
+60
View File
@@ -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;
+41
View File
@@ -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"
}
}
+1047
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
* text=auto eol=lf
+30
View File
@@ -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
+7
View File
@@ -0,0 +1,7 @@
{
"recommendations": [
"Vue.volar",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig"
]
}
+182
View File
@@ -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
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+20
View File
@@ -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,
)
+13
View File
@@ -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>
+38
View File
@@ -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"
}
}
+3296
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

+225
View File
@@ -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>&copy; 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>
+1
View File
@@ -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

+281
View File
@@ -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;
}
+94
View File
@@ -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>
Vues
<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>
+87
View File
@@ -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>
+227
View File
@@ -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>
+110
View File
@@ -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>
+7
View File
@@ -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>
+19
View File
@@ -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>
+14
View File
@@ -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')
+63
View File
@@ -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
+91
View File
@@ -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;
+81
View File
@@ -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;
+71
View File
@@ -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;
+59
View File
@@ -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;
+66
View File
@@ -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;
+139
View File
@@ -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
};
});
+12
View File
@@ -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 }
})
+132
View File
@@ -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;
}
+15
View File
@@ -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>
+490
View File
@@ -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>
+588
View File
@@ -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>
+554
View File
@@ -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>
+607
View File
@@ -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>
+365
View File
@@ -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>
+337
View File
@@ -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>
+408
View File
@@ -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>
+12
View File
@@ -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/*"]
}
}
}
+11
View File
@@ -0,0 +1,11 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
}
]
}
+19
View File
@@ -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"]
}
}
+18
View File
@@ -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))
},
},
})