├── .gitignore ├── .npmignore ├── tcb ├── public │ ├── favicon.ico │ └── index.html ├── babel.config.js ├── src │ ├── main.js │ ├── App.vue │ ├── Homepage.vue │ └── components │ │ └── Luogu.vue ├── .gitignore ├── functions │ ├── luogu │ │ ├── test.js │ │ ├── package.json │ │ ├── fetchStats.js │ │ ├── route │ │ │ ├── practice.js │ │ │ └── guzhi.js │ │ ├── cache.js │ │ └── index.js │ └── index │ │ ├── index.js │ │ └── package.json ├── package.json └── cloudbaserc.json ├── index.js ├── package.json ├── vercel.json ├── LICENSE ├── api ├── index.js └── guzhi.js ├── src ├── guzhi-card.js ├── stats-card.js └── common.js └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | test.svg 3 | node_modules 4 | .vercel -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | package-lock.json 2 | test.svg 3 | node_modules 4 | .vercel 5 | api 6 | tcb 7 | vercel.json -------------------------------------------------------------------------------- /tcb/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wao3/luogu-stats-card/HEAD/tcb/public/favicon.ico -------------------------------------------------------------------------------- /tcb/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | ...require("./src/guzhi-card"), 3 | ...require("./src/stats-card"), 4 | ...require("./src/common.js"), 5 | } -------------------------------------------------------------------------------- /tcb/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | import Antd from 'ant-design-vue'; 4 | import 'ant-design-vue/dist/antd.css'; 5 | 6 | Vue.config.productionTip = false 7 | 8 | Vue.use(Antd); 9 | 10 | new Vue({ 11 | render: h => h(App), 12 | }).$mount('#app') 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "luogu-stats-card", 3 | "version": "1.0.1", 4 | "description": "洛谷数据渲染组件", 5 | "main": "index.js", 6 | "scripts": {}, 7 | "keywords": [], 8 | "author": "wao", 9 | "license": "MIT", 10 | "dependencies": { 11 | "anafanafo": "^1.0.0", 12 | "axios": "^0.21.1" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tcb/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | -------------------------------------------------------------------------------- /vercel.json: -------------------------------------------------------------------------------- 1 | { 2 | "builds": [ 3 | { "src": "api/*.js", "use": "@vercel/node" } 4 | ], 5 | "routes": [ 6 | { "src": "/practice", "dest": "/api/index.js" }, 7 | { "src": "/guzhi", "dest": "/api/guzhi.js" }, 8 | { "src": "/", "status": 301, "headers": { "Location": "https://luogu.wao3.cn" } } 9 | ], 10 | "regions": ["hkg1"] 11 | } 12 | -------------------------------------------------------------------------------- /tcb/functions/luogu/test.js: -------------------------------------------------------------------------------- 1 | // const { main } = require('./index'); 2 | 3 | // main({ 4 | // queryStringParameters: { 5 | // id: 313209, 6 | // }, 7 | // path: '/practice', 8 | // }).then(res => console.log(res)); 9 | 10 | /////// 11 | 12 | // const fetchStats = require('./fetchStats'); 13 | 14 | // fetchStats(313209).then(res => console.log(res)); 15 | -------------------------------------------------------------------------------- /tcb/functions/luogu/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "luogu-tcb", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "wao", 10 | "license": "MIT", 11 | "dependencies": { 12 | "@cloudbase/node-sdk": "^2.7.1", 13 | "luogu-stats-card": "^1.0.1" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /tcb/functions/index/index.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | 3 | module.exports.main = async function (event, context) { 4 | const baseUrl = "https://www.wao3.cn"; 5 | if (event.path === "/") event.path = "/luogu/index.html" 6 | const res = await axios.get(baseUrl + event.path); 7 | return { 8 | statusCode: res.status, 9 | headers: res.headers, 10 | body: res.data, 11 | } 12 | }; -------------------------------------------------------------------------------- /tcb/functions/index/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "luogu-index", 3 | "version": "1.0.0", 4 | "description": "a proxy to https://wao3.cn/luogu", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "wao", 10 | "license": "MIT", 11 | "dependencies": { 12 | "axios": "^0.21.1" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /tcb/functions/luogu/fetchStats.js: -------------------------------------------------------------------------------- 1 | const { fetchStats } = require('luogu-stats-card'); 2 | const cache = require('./cache'); 3 | 4 | module.exports = async id => { 5 | const cacheKey = 'uid:' + id; 6 | let stats = await cache.get(cacheKey); 7 | if (!stats) { 8 | stats = await fetchStats(id); 9 | if (stats) { 10 | await cache.put(cacheKey, stats); 11 | } 12 | } 13 | return stats; 14 | } -------------------------------------------------------------------------------- /tcb/functions/luogu/route/practice.js: -------------------------------------------------------------------------------- 1 | const { renderSVG } = require('luogu-stats-card'); 2 | const fetchStats = require('../fetchStats'); 3 | 4 | module.exports = async function (event) { 5 | const { 6 | id, 7 | hide_title, 8 | dark_mode, 9 | card_width = 500, 10 | } = event.queryStringParameters; 11 | 12 | const stats = await fetchStats(id); 13 | 14 | return renderSVG(stats, { 15 | hideTitle: hide_title, 16 | darkMode: dark_mode, 17 | cardWidth: card_width, 18 | }); 19 | }; -------------------------------------------------------------------------------- /tcb/src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | 18 | 30 | -------------------------------------------------------------------------------- /tcb/functions/luogu/route/guzhi.js: -------------------------------------------------------------------------------- 1 | const { renderGuzhiCard } = require('luogu-stats-card'); 2 | const fetchStats = require('../fetchStats'); 3 | 4 | module.exports = async function (event) { 5 | const { 6 | id, 7 | scores, 8 | hide_title, 9 | dark_mode, 10 | card_width = 500, 11 | } = event.queryStringParameters; 12 | 13 | const stats = await fetchStats(id); 14 | 15 | return renderGuzhiCard(stats, scores, { 16 | hideTitle: stats === null ? true : hide_title, 17 | darkMode: dark_mode, 18 | cardWidth: card_width, 19 | }); 20 | }; -------------------------------------------------------------------------------- /tcb/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 洛谷个人练习数据卡片 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Anurag Hazra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /api/index.js: -------------------------------------------------------------------------------- 1 | const { fetchStats, renderSVG } = require("../src/stats-card"); 2 | const { renderError } = require("../src/common.js") 3 | 4 | module.exports = async (req, res) => { 5 | const { 6 | id, 7 | hide_title, 8 | dark_mode, 9 | card_width = 500, 10 | } = req.query; 11 | 12 | res.setHeader("Content-Type", "image/svg+xml"); 13 | // res.setHeader("Cache-Control", "public, max-age=43200"); // 43200s(12h) cache 14 | 15 | return res.send( 16 | renderError(`访问 https://luogu.wao3.cn 更换域名,造成不便敬请谅解`, { darkMode: dark_mode }) 17 | ); 18 | 19 | const validId = /^[1-9]\d*$/; 20 | const clamp = (min, max, n) => Math.max(min, Math.min(max, n)); 21 | 22 | if(!validId.test(id)) { 23 | return res.send(renderError(`"${id}"不是一个合法uid`, {darkMode: dark_mode})); 24 | } 25 | if(!validId.test(card_width)) { 26 | return res.send(renderError(`卡片宽度"${card_width}"不合法`, {darkMode: dark_mode})); 27 | } 28 | 29 | const stats = await fetchStats(id, true); 30 | return res.send(renderSVG(stats, { 31 | hideTitle: hide_title, 32 | darkMode: dark_mode, 33 | cardWidth: clamp(500, 1920, card_width), 34 | })); 35 | }; 36 | -------------------------------------------------------------------------------- /tcb/functions/luogu/cache.js: -------------------------------------------------------------------------------- 1 | const cloudbase = require('@cloudbase/node-sdk') 2 | 3 | const app = cloudbase.init({ 4 | env: cloudbase.SYMBOL_CURRENT_ENV, 5 | }) 6 | const db = app.database(); 7 | 8 | // 12 hour 9 | const EXPIRE_TIME_MILLISECOND = 1000 * 60 * 60 * 12; 10 | const CACHE_NAME = 'cache'; 11 | 12 | const cache = { 13 | get: async (key) => { 14 | const res = await db.collection(CACHE_NAME) 15 | .doc(key) 16 | .get(); 17 | if (res.data === null || res.data.length === 0) { 18 | return null; 19 | } 20 | const data = res.data[0]; 21 | if (!data || !data.updateTime) { 22 | return null; 23 | } 24 | const updateTime = new Date(data.updateTime); 25 | const now = new Date(); 26 | if (now.valueOf() - updateTime.valueOf() > EXPIRE_TIME_MILLISECOND) { 27 | return null; 28 | } 29 | return data.value; 30 | }, 31 | put: async (key, value) => { 32 | if (key == null) { 33 | return null; 34 | } 35 | const res = await db 36 | .collection(CACHE_NAME) 37 | .doc(key) 38 | .set({ 39 | value, 40 | updateTime: new Date(), 41 | }); 42 | if (!res.updated || !res.upsertedId) { 43 | return value; 44 | } 45 | return null; 46 | } 47 | } 48 | 49 | module.exports = cache; -------------------------------------------------------------------------------- /tcb/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "guide", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "SET NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service serve", 7 | "build": "SET NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service build", 8 | "lint": "SET NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service lint" 9 | }, 10 | "dependencies": { 11 | "ant-design-vue": "^1.7.7", 12 | "copy-to-clipboard": "^3.3.1", 13 | "core-js": "^3.6.5", 14 | "debounce": "^1.2.1", 15 | "vue": "^2.6.11" 16 | }, 17 | "devDependencies": { 18 | "@vue/cli-plugin-babel": "~4.5.0", 19 | "@vue/cli-plugin-eslint": "~4.5.0", 20 | "@vue/cli-service": "~4.5.0", 21 | "babel-eslint": "^10.1.0", 22 | "eslint": "^6.7.2", 23 | "eslint-plugin-vue": "^6.2.2", 24 | "vue-template-compiler": "^2.6.11", 25 | "babel": "^6.23.0" 26 | }, 27 | "eslintConfig": { 28 | "root": true, 29 | "env": { 30 | "node": true 31 | }, 32 | "extends": [ 33 | "plugin:vue/essential", 34 | "eslint:recommended" 35 | ], 36 | "parserOptions": { 37 | "parser": "babel-eslint" 38 | }, 39 | "rules": {} 40 | }, 41 | "browserslist": [ 42 | "> 1%", 43 | "last 2 versions", 44 | "not dead" 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /api/guzhi.js: -------------------------------------------------------------------------------- 1 | const { renderGuzhiCard } = require("../src/guzhi-card"); 2 | const { fetchStats } = require("../src/stats-card"); 3 | const { renderError } = require("../src/common.js") 4 | 5 | module.exports = async (req, res) => { 6 | const { id, scores, hide_title, dark_mode, card_width = 500 } = req.query; 7 | 8 | res.setHeader("Content-Type", "image/svg+xml"); 9 | // res.setHeader("Cache-Control", "public, max-age=43200"); // 43200s(12h) cache 10 | 11 | return res.send( 12 | renderError(`访问 https://luogu.wao3.cn 更换域名,造成不便敬请谅解`, { darkMode: dark_mode }) 13 | ); 14 | 15 | const regNum = /^[1-9]\d*$/; 16 | const clamp = (min, max, n) => Math.max(min, Math.min(max, n)); 17 | 18 | if (!regNum.test(card_width)) { 19 | return res.send( 20 | renderError(`卡片宽度"${card_width}"不合法`, { darkMode: dark_mode }) 21 | ); 22 | } 23 | if(id != undefined && !regNum.test(id)) { 24 | return res.send(renderError(`"${id}"不是一个合法uid`, {darkMode: dark_mode})); 25 | } 26 | 27 | let stats = null; 28 | 29 | if(id != undefined) { 30 | stats = await fetchStats(id, true); 31 | } 32 | 33 | return res.send( 34 | renderGuzhiCard(stats, scores, { 35 | hideTitle: stats === null ? true : hide_title, 36 | darkMode: dark_mode, 37 | cardWidth: clamp(500, 1920, card_width), 38 | }) 39 | ); 40 | }; 41 | -------------------------------------------------------------------------------- /tcb/functions/luogu/index.js: -------------------------------------------------------------------------------- 1 | const axios = require('axios'); 2 | const { renderError } = require('luogu-stats-card'); 3 | const practice = require('./route/practice'); 4 | const guzhi = require('./route/guzhi'); 5 | 6 | module.exports.main = async function (event, context) { 7 | checkParam(event.queryStringParameters); 8 | let result = null; 9 | if (event.path.startsWith("/practice")) { 10 | result = await practice(event); 11 | } else if (event.path.startsWith("/guzhi")) { 12 | result = await guzhi(event); 13 | } else { 14 | result = renderError(`路径错误:${event.path}`, { darkMode: dark_mode }); 15 | } 16 | return { 17 | statusCode: 200, 18 | headers: { 19 | "content-type": "image/svg+xml; charset=utf-8", 20 | "Cache-Control": "public, max-age=43200", 21 | }, 22 | body: result 23 | }; 24 | }; 25 | 26 | function checkParam(queryParam) { 27 | const { id, dark_mode, card_width = 500 } = queryParam; 28 | 29 | const regNum = /^[1-9]\d*$/; 30 | const clamp = (min, max, n) => Math.max(min, Math.min(max, n)); 31 | 32 | if (!regNum.test(card_width)) { 33 | return renderError(`卡片宽度"${card_width}"不合法`, { darkMode: dark_mode }); 34 | } 35 | 36 | if(id != undefined && !regNum.test(id)) { 37 | return renderError(`卡片宽度"${card_width}"不合法`, { darkMode: dark_mode }); 38 | } 39 | 40 | queryParam.card_width = clamp(500, 1920, card_width); 41 | } -------------------------------------------------------------------------------- /tcb/cloudbaserc.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "envId": "cloudbase-baas-5g6v8dai30476fe3", 4 | "$schema": "https://framework-1258016615.tcloudbaseapp.com/schema/latest.json", 5 | "functionRoot": "./functions", 6 | "functions": [ 7 | { 8 | "name": "index", 9 | "timeout": 10, 10 | "envVariables": {}, 11 | "runtime": "Nodejs12.16", 12 | "memorySize": 128, 13 | "handler": "index.main" 14 | }, 15 | { 16 | "name": "luogu", 17 | "timeout": 10, 18 | "envVariables": {}, 19 | "runtime": "Nodejs12.16", 20 | "memorySize": 128, 21 | "handler": "index.main" 22 | } 23 | ], 24 | "framework": { 25 | "name": "luogu-stats-card", 26 | "plugins": { 27 | "function": { 28 | "use": "@cloudbase/framework-plugin-function", 29 | "inputs": {} 30 | }, 31 | "client": { 32 | "use": "@cloudbase/framework-plugin-database", 33 | "inputs": { 34 | "collections": [ 35 | { 36 | "collectionName": "cache" 37 | } 38 | ] 39 | } 40 | }, 41 | "homepage": { 42 | "use": "@cloudbase/framework-plugin-website", 43 | "inputs": { 44 | "installCommand": "npm install", 45 | "buildCommand": "npm run build", 46 | "outputPath": "dist", 47 | "ignore": [ 48 | ".git", 49 | ".github", 50 | "node_modules", 51 | "cloudbaserc.js" 52 | ] 53 | } 54 | } 55 | } 56 | }, 57 | "region": "ap-shanghai" 58 | } 59 | -------------------------------------------------------------------------------- /src/guzhi-card.js: -------------------------------------------------------------------------------- 1 | const { 2 | Card, 3 | renderError, 4 | renderChart, 5 | renderNameTitle, 6 | } = require("./common.js"); 7 | 8 | const renderGuzhiCard = (userInfo, scores, options) => { 9 | const regNum = /^\d*$/; 10 | if(!scores || typeof scores !== 'string') { 11 | return renderError('咕值信息不能为空', {width: 400}); 12 | } 13 | let sp = ','; 14 | if(scores.indexOf(',') >= 0) { 15 | sp = ','; 16 | } 17 | const scoreArray = scores.split(sp).filter(x => regNum.test(x)).map(x => parseInt(x)).filter(x => x >= 0 && x <= 100); 18 | if(scoreArray.length != 5) { 19 | return renderError(`咕值信息"${scores}"不合法`, {width: 400}); 20 | } 21 | const scoreSum = scoreArray.reduce((a, b) => a+b); 22 | 23 | const { 24 | name, 25 | color, 26 | ccfLevel, 27 | } = userInfo || {}; 28 | 29 | const { 30 | hideTitle, 31 | darkMode, 32 | cardWidth = 500, 33 | } = options || {}; 34 | 35 | const paddingX = 25; 36 | const labelWidth = 90; //柱状图头部文字长度 37 | const progressWidth = cardWidth - 2*paddingX - labelWidth - 60; //500 - 25*2(padding) - 90(头部文字长度) - 60(预留尾部文字长度),暂时固定,后序提供自定义选项; 38 | 39 | const getScoreColor = (score) => { 40 | if (score >= 80) return "#52c41a"; 41 | if (score >= 60) return "#fadb14"; 42 | if (score >= 30) return "#f39c11"; 43 | return "#e74c3c"; 44 | } 45 | const datas = [ 46 | {label: "基础信用", data: scoreArray[0], color: getScoreColor(scoreArray[0])}, 47 | {label: "练习情况", data: scoreArray[1], color: getScoreColor(scoreArray[1])}, 48 | {label: "社区贡献", data: scoreArray[2], color: getScoreColor(scoreArray[2])}, 49 | {label: "比赛情况", data: scoreArray[3], color: getScoreColor(scoreArray[3])}, 50 | {label: "获得成就", data: scoreArray[4], color: getScoreColor(scoreArray[4])}, 51 | ] 52 | 53 | const title = userInfo != null ? renderNameTitle(name, color, ccfLevel, "的咕值信息", cardWidth, `总咕值: ${scoreSum}分`) : ""; 54 | 55 | const body = renderChart(datas, labelWidth, progressWidth, "分"); 56 | 57 | return new Card({ 58 | width: cardWidth - 2*paddingX, 59 | height: datas.length*30 + 10, 60 | hideTitle, 61 | darkMode, 62 | title, 63 | body, 64 | }).render(); 65 | } 66 | 67 | module.exports = { renderGuzhiCard } -------------------------------------------------------------------------------- /src/stats-card.js: -------------------------------------------------------------------------------- 1 | const axios = require("axios"); 2 | const { 3 | Card, 4 | renderError, 5 | renderChart, 6 | renderNameTitle, 7 | } = require("./common.js"); 8 | 9 | /** 10 | * 11 | * @param {number} id 用户id 12 | * @param {boolean} useProxy 使用代理 13 | * @returns {Object} 获取的用户数据 {name, color, ccfLevel, passed, hideInfo} 14 | */ 15 | async function fetchStats(id, useProxy) { 16 | //debug 测试请求 17 | let reqUrl = `https://www.luogu.com.cn/user/${id}?_contentOnly`; 18 | if (useProxy) { 19 | reqUrl = `https://a-1c37c2-1300876583.ap-shanghai.service.tcloudbase.com/luogu?id=${id}`; 20 | } 21 | const res = await axios.get(reqUrl); 22 | 23 | const stats = { 24 | name: "NULL", 25 | color: "Gray", 26 | ccfLevel: 0, 27 | passed: [0,0,0,0,0,0,0,0], 28 | hideInfo: false 29 | } 30 | if(res.data.code !== 200) { 31 | return stats; 32 | } 33 | 34 | const user = res.data.currentData.user; 35 | const passed = res.data.currentData.passedProblems; 36 | 37 | stats.name = user.name; 38 | stats.color = user.color; 39 | stats.ccfLevel = user.ccfLevel; 40 | 41 | if(!passed) { 42 | stats.hideInfo = true; 43 | return stats; 44 | } 45 | 46 | for(let i of passed) { 47 | stats.passed[i.difficulty]++; 48 | } 49 | 50 | return stats; 51 | } 52 | 53 | const renderSVG = (stats, options) => { 54 | const { 55 | name, 56 | color, 57 | ccfLevel, 58 | passed, 59 | hideInfo 60 | } = stats; 61 | 62 | const { 63 | hideTitle, 64 | darkMode, 65 | cardWidth = 500, 66 | } = options || {}; 67 | 68 | if(hideInfo) { 69 | return renderError("用户开启了“完全隐私保护”,获取数据失败"); 70 | } 71 | 72 | const paddingX = 25; 73 | const labelWidth = 90; //柱状图头部文字长度 74 | const progressWidth = cardWidth - 2*paddingX - labelWidth - 60; //500 - 25*2(padding) - 90(头部文字长度) - 60(预留尾部文字长度),暂时固定,后序提供自定义选项; 75 | 76 | const datas = [ 77 | {label: "未评定", color:"#bfbfbf", data: passed[0]}, 78 | {label: "入门", color:"#fe4c61", data: passed[1]}, 79 | {label: "普及-", color:"#f39c11", data: passed[2]}, 80 | {label: "普及/提高-", color:"#ffc116", data: passed[3]}, 81 | {label: "普及+/提高", color:"#52c41a", data: passed[4]}, 82 | {label: "提高+/省选-", color:"#3498db", data: passed[5]}, 83 | {label: "省选/NOI-", color:"#9d3dcf", data: passed[6]}, 84 | {label: "NOI/NOI+/CTSC", color:"#0e1d69", data: passed[7]} 85 | ] 86 | const passedSum = passed.reduce((a, b) => a + b); 87 | const body = renderChart(datas, labelWidth, progressWidth, "题"); 88 | 89 | const title = renderNameTitle(name, color, ccfLevel, "的练习情况", cardWidth, `已通过: ${passedSum}题`); 90 | 91 | return new Card({ 92 | width: cardWidth - 2*paddingX, 93 | height: datas.length*30 + 10, 94 | hideTitle, 95 | darkMode, 96 | title, 97 | body, 98 | }).render(); 99 | } 100 | 101 | module.exports = { fetchStats, renderSVG } 102 | -------------------------------------------------------------------------------- /tcb/src/Homepage.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 68 | 69 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > 注意:为了不滥用洛谷服务器流量,本项目将缓存 12 小时用户数据,即同一个用户卡片 **24 小时内最多只会向洛谷服务器请求 2 次数据**,并且只有在用户访问卡片时才会请求数据。 2 | ## 简介 3 | 4 | ![stars](https://badgen.net/github/stars/wao3/luogu-stats-card?cache=600) 5 | ![forks](https://badgen.net/github/forks/wao3/luogu-stats-card?cache=600) 6 | ![visitor](https://visitor-badge.laobi.icu/badge?page_id=luogu-stats-card) 7 | ![last commit](https://badgen.net/github/last-commit/wao3/luogu-stats-card?cache=600) 8 | ![top language](https://img.shields.io/github/languages/top/wao3/luogu-stats-card) 9 | 10 | `luogu-stats-card`是一个动态生成洛谷用户练习数据卡片的工具,可以展示自己的做题情况。可以用于个人主页、博客、github 等可以插入图片的地方。 11 | 12 | ## TODO 13 | 14 | - [x] ~~修复获取数据错误和用户设置数据不可见的 bug~~ 15 | - [x] ~~增加黑暗模式~~ 16 | - [x] ~~增加咕值卡片~~ 17 | - [ ] 增加用户 tag 18 | - [ ] 缓存 top 500 用户咕值,使高估值用户可自动获取估值信息 19 | 20 | ## 效果预览 21 | 22 | ![wangao 的练习情况](https://luogu.wao3.cn/api/practice?id=313209) 23 | 24 | ![wangao 的咕值信息](https://luogu.wao3.cn/api/guzhi?id=313209&scores=100,65,45,15,0) 25 | 26 | *(上面的咕值仅为展示效果,本人咕值并没有这么高)* 27 | 28 | ## 如何使用 29 | 30 | ### 练习情况 31 | 32 | 练习情况可以自动获取用户的数据,但是前提是没有开启“完全隐私保护”,具体使用方法如下: 33 | 34 | 1. Markdown:复制以下内容到任意 markdown 编辑器中,并将`?id=`后面的数字更改为自己的 id 即可(id 是洛谷个人主页地址的一串数字)。 35 | 36 | ```markdown 37 | ![我的练习情况](https://luogu.wao3.cn/api/practice?id=313209) 38 | ``` 39 | 40 | 2. HTML:复制以下内容到 HTML 代码中,并将`?id=`后面的数字更改为自己的 id 即可(id 是洛谷个人主页地址的一串数字)。 41 | 42 | ```html 43 | 我的练习情况 44 | ``` 45 | 46 | ### 咕值信息 47 | 48 | 咕值信息无法自动获取数据,如果需要必须要提供 cookie ,但是 这种方法十分不安全,并且不方便,所以获取咕值卡片需要手动输入咕值信息,具体使用方法如下。 49 | 50 | 复制以下内容到任意 markdown 编辑器中,并将 `?id=`后面的数字更改为自己的 id,将`scores=`后面更换为自己的咕值信息,一共 5 个数字,用逗号分隔。 51 | 52 | 1. Markdown:复制以下内容到任意 markdown 编辑器中,并将 `?id=`后面的数字更改为自己的 id,将`scores=`后面更换为自己的咕值信息,一共 5 个数字,用逗号分隔。 53 | 54 | ```markdown 55 | ![我的咕值信息](http://luogu.wao3.cn/api/guzhi?id=313209&scores=100,65,45,15,0) 56 | ``` 57 | 58 | 2. HTML:复制以下内容到 HTML 代码中,并将 `?id=`后面的数字更改为自己的 id,将`scores=`后面更换为自己的咕值信息,一共 5 个数字,用逗号分隔。 59 | ```html 60 | 我的练习情况 61 | ``` 62 | 63 | 64 | 65 | ### 自定义选项 66 | 67 | 使用卡片时,支持设定自定义效果选项,可以组合使用。 68 | 69 | 1. **隐藏标题**,只需在链接最后带上`&hide_title=true`即可,例如: 70 | 71 | ```markdown 72 | ![wangao 的练习情况](https://luogu.wao3.cn/api/practice?id=313209&hide_title=true) 73 | ``` 74 | 75 | 效果: 76 | 77 | ![wangao 的练习情况](https://luogu.wao3.cn/api/practice?id=313209&hide_title=1) 78 | 79 | 2. **黑暗模式**,只需在链接最后带上`&dark_mode=true`即可,例如: 80 | 81 | ```markdown 82 | ![wangao 的练习情况](https://luogu.wao3.cn/api/practice?id=313209&dark_mode=true) 83 | ``` 84 | 85 | 效果: 86 | 87 | ![wangao 的练习情况](https://luogu.wao3.cn/api/practice?id=313209&dark_mode=1) 88 | 3. **自定义宽度**,默认 500,限制宽度在 500 到 1920 之间,只需在链接最后带上`&card_width=需要的宽度`即可,例如: 89 | 90 | ```markdown 91 | ![wangao 的练习情况](https://luogu.wao3.cn/api/practice?id=313209&card_width=750) 92 | ``` 93 | 94 | 效果: 95 | 96 | ![wangao 的练习情况](https://luogu.wao3.cn/api/practice?id=313209&card_width=750) 97 | 98 | 99 | ## 如何参与贡献 100 | 101 | #### 提供 bug 反馈或建议 102 | 103 | 使用 [issue](https://github.com/wao3/luogu-stats-card/issues) 反馈 bug 时,尽可能详细描述 bug 及其复现步骤 104 | 105 | #### 贡献代码的步骤 106 | 107 | 1. fork 项目到自己的 repo 108 | 2. 把 fork 过去的项目也就是你的项目 clone 到你的本地 109 | 3. 修改代码 110 | 4. commit 后 push 到自己的库 111 | 5. 在 Github 首页可以看到一个 pull request 按钮,点击它,填写一些说明信息,然后提交即可。 112 | 6. 等待作者合并 113 | 114 | ## 其他 115 | 116 | 如果对你有所帮助的话,希望能在右上角点一个 star (★ ω ★) 117 | 118 | ## LICENSE 119 | 120 | [![MIT License](https://badgen.net/github/license/wao3/luogu-stats-card)](https://github.com/wao3/luogu-stats-card/blob/master/LICENSE) 121 | -------------------------------------------------------------------------------- /tcb/src/components/Luogu.vue: -------------------------------------------------------------------------------- 1 | 80 | 81 | 152 | 153 | -------------------------------------------------------------------------------- /src/common.js: -------------------------------------------------------------------------------- 1 | const anafanafo = require('anafanafo'); 2 | 3 | const NAMECOLOR = { 4 | "Gray": "#bbbbbb", 5 | "Blue": "#0e90d2", 6 | "Green": "#5eb95e", 7 | "Orange": "#e67e22", 8 | "Red": "#e74c3c", 9 | "Purple": "#9d3dcf", 10 | "Cheater": "#ad8b00" 11 | } 12 | 13 | class Card { 14 | constructor({ 15 | width = 450, 16 | height = 250, 17 | title = "", 18 | body = "", 19 | titleHeight = 25, 20 | hideTitle = false, 21 | css = "", 22 | darkMode = "", 23 | paddingX = 25, 24 | paddingY = 35, 25 | hideBorder = false, 26 | }) { 27 | this.width = width; 28 | this.height = height; 29 | this.titleHeight = titleHeight; 30 | this.title = title; 31 | this.body = body; 32 | this.hideTitle = hideTitle; 33 | this.css = css; 34 | this.darkMode = darkMode; 35 | this.paddingX = paddingX; 36 | this.paddingY = paddingY; 37 | this.hideBorder = hideBorder; 38 | } 39 | 40 | render() { 41 | const cardSize = { 42 | width: this.width + 2*this.paddingX, 43 | height: this.height + 2*this.paddingY, 44 | }; 45 | if(!this.hideTitle) cardSize.height += this.titleHeight; 46 | 47 | const bgColor = this.darkMode?"#444444":"#fffefe"; 48 | let borderColor = ""; 49 | if(!this.hideBorder) borderColor = this.darkMode?"#444444":"#E4E2E2"; 50 | 51 | return ` 52 | 53 | 59 | 60 | 61 | ${this.hideTitle ? `` : ` 62 | 63 | ${this.title} 64 | `} 65 | 66 | 67 | ${this.body} 68 | 69 | `; 70 | } 71 | } 72 | 73 | /** 74 | * 渲染错误卡片 75 | * @param {string} e 描述错误的文本 76 | * @param {Object} option 其余选项 77 | */ 78 | const renderError = (e, option) => { 79 | const css = `.t {font: 600 18px 'Microsoft Yahei UI'; fill: #e74c3c;}` 80 | const text = `${e}` 81 | return new Card({ 82 | width: 500, 83 | height: 23, 84 | hideTitle: true, 85 | css, 86 | body: text, 87 | paddingY: 20, 88 | paddingX: 20, 89 | ...option, 90 | }).render(); 91 | }; 92 | 93 | /** 94 | * 渲染 ccf badge 95 | * @param {number} level CCF等级 96 | * @param {number} x badge的x坐标 97 | * @returns {string} ccf badge的svg字符串 98 | */ 99 | const renderCCFBadge = (level, x) => { 100 | const ccfColor = (ccf) => { 101 | if(ccf >= 3 && ccf <= 5) return "#5eb95e"; 102 | if(ccf >= 6 && ccf <= 7) return "#3498db"; 103 | if(ccf >= 8) return "#f1c40f"; 104 | return null; 105 | } 106 | return ` 107 | 108 | 109 | 110 | ` 111 | } 112 | 113 | /** 114 | * 渲染柱状图 115 | * @param {Object[]} datas 柱状图的数据数组 116 | * @param {string} datas.label 一条数据的标签 117 | * @param {string} datas.color 一条数据的颜色 118 | * @param {number} datas.data 一条数据的数值 119 | * @param {number} labelWidth 标签宽度 120 | * @param {number} progressWidth 柱状图的长度 121 | * @param {string} [unit] 数据单位 122 | */ 123 | const renderChart = (datas, labelWidth, progressWidth, unit) => { //(label, color, height, num, unit) => { 124 | let chart = ""; 125 | let maxNum = datas.reduce((a, b) => Math.max(a, b.data), 0); 126 | maxNum = (parseInt((maxNum-1) / 100) + 1) * 100; 127 | 128 | for(let i = 0; i < datas.length; ++i) { 129 | const width = (datas[i].data+1) / (maxNum+1) * progressWidth; 130 | chart += ` 131 | 132 | ${datas[i].label} 133 | ${datas[i].data + unit} 134 | 135 | 136 | ` 137 | } 138 | 139 | const bodyHeight = datas.length * 30 + 10; 140 | const dw = progressWidth / 4; 141 | let coordinate = ""; 142 | for(let i = 0; i <= 4; ++i) { 143 | coordinate += ` 144 | 145 | ${maxNum*i/4} 146 | `; 147 | } 148 | return coordinate + chart; 149 | } 150 | 151 | /** 152 | * 153 | * @param {string} name 用户名 154 | * @param {string} color 用户颜色 155 | * @param {number} ccfLevel 用户ccf等级 156 | * @param {string} title 标题的后缀 157 | * @param {string} rightTop 右上角的标签(展示总数) 158 | */ 159 | const renderNameTitle = (name, color, ccfLevel, title, cardWidth, rightTop) => { 160 | const nameLength = anafanafo(name)/10*1.8; 161 | const nameColor = NAMECOLOR[color]; 162 | 163 | return ` 164 | 165 | 166 | ${name} 167 | 168 | ${ccfLevel < 3 ? "" : renderCCFBadge(ccfLevel, nameLength + 5)} 169 | 170 | ${title} 171 | 172 | 173 | ${rightTop} 174 | 175 | `; 176 | } 177 | 178 | module.exports = { 179 | NAMECOLOR, 180 | Card, 181 | renderError, 182 | renderCCFBadge, 183 | renderChart, 184 | renderNameTitle, 185 | }; 186 | --------------------------------------------------------------------------------