├── .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 |
2 |
3 |
4 |
5 |
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 |
2 |
53 |
54 |
55 |
68 |
69 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | > 注意:为了不滥用洛谷服务器流量,本项目将缓存 12 小时用户数据,即同一个用户卡片 **24 小时内最多只会向洛谷服务器请求 2 次数据**,并且只有在用户访问卡片时才会请求数据。
2 | ## 简介
3 |
4 | 
5 | 
6 | 
7 | 
8 | 
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 | 
23 |
24 | 
25 |
26 | *(上面的咕值仅为展示效果,本人咕值并没有这么高)*
27 |
28 | ## 如何使用
29 |
30 | ### 练习情况
31 |
32 | 练习情况可以自动获取用户的数据,但是前提是没有开启“完全隐私保护”,具体使用方法如下:
33 |
34 | 1. Markdown:复制以下内容到任意 markdown 编辑器中,并将`?id=`后面的数字更改为自己的 id 即可(id 是洛谷个人主页地址的一串数字)。
35 |
36 | ```markdown
37 | 
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 | 
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 | 
73 | ```
74 |
75 | 效果:
76 |
77 | 
78 |
79 | 2. **黑暗模式**,只需在链接最后带上`&dark_mode=true`即可,例如:
80 |
81 | ```markdown
82 | 
83 | ```
84 |
85 | 效果:
86 |
87 | 
88 | 3. **自定义宽度**,默认 500,限制宽度在 500 到 1920 之间,只需在链接最后带上`&card_width=需要的宽度`即可,例如:
89 |
90 | ```markdown
91 | 
92 | ```
93 |
94 | 效果:
95 |
96 | 
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 | [](https://github.com/wao3/luogu-stats-card/blob/master/LICENSE)
121 |
--------------------------------------------------------------------------------
/tcb/src/components/Luogu.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 | 练习情况
10 | 咕值信息
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
24 |
25 |
26 |
27 |
28 |
29 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
74 | {{codes[codeMode]}}
75 |
76 |
77 |
78 |
79 |
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 | `;
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 | `
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 |
--------------------------------------------------------------------------------