[
  {
    "path": ".gitignore",
    "content": "package-lock.json\ntest.svg\nnode_modules\n.vercel"
  },
  {
    "path": ".npmignore",
    "content": "package-lock.json\ntest.svg\nnode_modules\n.vercel\napi\ntcb\nvercel.json"
  },
  {
    "path": "LICENSE",
    "content": "MIT License\n\nCopyright (c) 2020 Anurag Hazra\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE."
  },
  {
    "path": "README.md",
    "content": "> 注意：为了不滥用洛谷服务器流量，本项目将缓存 12 小时用户数据，即同一个用户卡片 **24 小时内最多只会向洛谷服务器请求 2 次数据**，并且只有在用户访问卡片时才会请求数据。\n## 简介\n\n![stars](https://badgen.net/github/stars/wao3/luogu-stats-card?cache=600)\n![forks](https://badgen.net/github/forks/wao3/luogu-stats-card?cache=600)\n![visitor](https://visitor-badge.laobi.icu/badge?page_id=luogu-stats-card)\n![last commit](https://badgen.net/github/last-commit/wao3/luogu-stats-card?cache=600)\n![top language](https://img.shields.io/github/languages/top/wao3/luogu-stats-card)\n\n`luogu-stats-card`是一个动态生成洛谷用户练习数据卡片的工具，可以展示自己的做题情况。可以用于个人主页、博客、github 等可以插入图片的地方。\n\n## TODO\n\n- [x] ~~修复获取数据错误和用户设置数据不可见的 bug~~\n- [x] ~~增加黑暗模式~~\n- [x] ~~增加咕值卡片~~\n- [ ] 增加用户 tag\n- [ ] 缓存 top 500 用户咕值，使高估值用户可自动获取估值信息\n\n## 效果预览\n\n![wangao 的练习情况](https://luogu.wao3.cn/api/practice?id=313209)\n\n![wangao 的咕值信息](https://luogu.wao3.cn/api/guzhi?id=313209&scores=100,65,45,15,0)\n\n*(上面的咕值仅为展示效果，本人咕值并没有这么高)*\n\n## 如何使用\n\n### 练习情况\n\n练习情况可以自动获取用户的数据，但是前提是没有开启“完全隐私保护”，具体使用方法如下：\n\n1. Markdown：复制以下内容到任意 markdown 编辑器中，并将`?id=`后面的数字更改为自己的 id 即可（id 是洛谷个人主页地址的一串数字）。\n\n   ```markdown\n   ![我的练习情况](https://luogu.wao3.cn/api/practice?id=313209)\n   ```\n\n2. HTML：复制以下内容到 HTML 代码中，并将`?id=`后面的数字更改为自己的 id 即可（id 是洛谷个人主页地址的一串数字）。\n\n    ```html\n    <img alt=\"我的练习情况\" src=\"https://luogu.wao3.cn/api/practice?id=313209\">\n    ```\n\n### 咕值信息\n\n咕值信息无法自动获取数据，如果需要必须要提供 cookie ，但是 这种方法十分不安全，并且不方便，所以获取咕值卡片需要手动输入咕值信息，具体使用方法如下。\n\n复制以下内容到任意 markdown 编辑器中，并将 `?id=`后面的数字更改为自己的 id，将`scores=`后面更换为自己的咕值信息，一共 5 个数字，用逗号分隔。\n\n1. Markdown：复制以下内容到任意 markdown 编辑器中，并将 `?id=`后面的数字更改为自己的 id，将`scores=`后面更换为自己的咕值信息，一共 5 个数字，用逗号分隔。\n\n   ```markdown\n   ![我的咕值信息](http://luogu.wao3.cn/api/guzhi?id=313209&scores=100,65,45,15,0)\n   ```\n   \n2. HTML：复制以下内容到 HTML 代码中，并将 `?id=`后面的数字更改为自己的 id，将`scores=`后面更换为自己的咕值信息，一共 5 个数字，用逗号分隔。\n   ```html\n   <img alt=\"我的练习情况\" src=\"https://luogu.wao3.cn/api/practice?id=313209\">\n   ```\n   \n\n\n### 自定义选项\n\n使用卡片时，支持设定自定义效果选项，可以组合使用。\n\n1. **隐藏标题**，只需在链接最后带上`&hide_title=true`即可，例如：\n\n   ```markdown\n   ![wangao 的练习情况](https://luogu.wao3.cn/api/practice?id=313209&hide_title=true)\n   ```\n\n   效果：\n\n   ![wangao 的练习情况](https://luogu.wao3.cn/api/practice?id=313209&hide_title=1)\n\n2. **黑暗模式**，只需在链接最后带上`&dark_mode=true`即可，例如：\n\n   ```markdown\n   ![wangao 的练习情况](https://luogu.wao3.cn/api/practice?id=313209&dark_mode=true)\n   ```\n\n   效果：\n\n   ![wangao 的练习情况](https://luogu.wao3.cn/api/practice?id=313209&dark_mode=1)\n3. **自定义宽度**，默认 500，限制宽度在 500 到 1920 之间，只需在链接最后带上`&card_width=需要的宽度`即可，例如：\n\n   ```markdown\n   ![wangao 的练习情况](https://luogu.wao3.cn/api/practice?id=313209&card_width=750)\n   ```\n\n   效果：\n\n   ![wangao 的练习情况](https://luogu.wao3.cn/api/practice?id=313209&card_width=750)\n   \n\n## 如何参与贡献\n\n#### 提供 bug 反馈或建议\n\n使用 [issue](https://github.com/wao3/luogu-stats-card/issues) 反馈 bug 时，尽可能详细描述 bug 及其复现步骤\n\n#### 贡献代码的步骤\n\n1. fork 项目到自己的 repo\n2. 把 fork 过去的项目也就是你的项目 clone 到你的本地\n3. 修改代码\n4. commit 后 push 到自己的库\n5. 在 Github 首页可以看到一个 pull request 按钮，点击它，填写一些说明信息，然后提交即可。\n6. 等待作者合并\n\n## 其他\n\n如果对你有所帮助的话，希望能在右上角点一个 star (★ ω ★)\n\n## LICENSE\n\n[![MIT License](https://badgen.net/github/license/wao3/luogu-stats-card)](https://github.com/wao3/luogu-stats-card/blob/master/LICENSE)\n"
  },
  {
    "path": "api/guzhi.js",
    "content": "const { renderGuzhiCard } = require(\"../src/guzhi-card\");\nconst { fetchStats } = require(\"../src/stats-card\");\nconst { renderError } = require(\"../src/common.js\")\n\nmodule.exports = async (req, res) => {\n  const { id, scores, hide_title, dark_mode, card_width = 500 } = req.query;\n\n  res.setHeader(\"Content-Type\", \"image/svg+xml\");\n  // res.setHeader(\"Cache-Control\", \"public, max-age=43200\"); // 43200s（12h） cache\n\n  return res.send(\n    renderError(`访问 https://luogu.wao3.cn 更换域名，造成不便敬请谅解`, { darkMode: dark_mode })\n  );\n\n  const regNum = /^[1-9]\\d*$/;\n  const clamp = (min, max, n) => Math.max(min, Math.min(max, n));\n\n  if (!regNum.test(card_width)) {\n    return res.send(\n      renderError(`卡片宽度\"${card_width}\"不合法`, { darkMode: dark_mode })\n    );\n  }\n  if(id != undefined && !regNum.test(id)) {\n    return res.send(renderError(`\"${id}\"不是一个合法uid`, {darkMode: dark_mode}));\n  }\n\n  let stats = null;\n\n  if(id != undefined) {\n    stats = await fetchStats(id, true);\n  }\n\n  return res.send(\n    renderGuzhiCard(stats, scores, {\n      hideTitle: stats === null ? true : hide_title,\n      darkMode: dark_mode,\n      cardWidth: clamp(500, 1920, card_width),\n    })\n  );\n};\n"
  },
  {
    "path": "api/index.js",
    "content": "const { fetchStats, renderSVG } = require(\"../src/stats-card\");\nconst { renderError } = require(\"../src/common.js\")\n\nmodule.exports = async (req, res) => {\n  const { \n    id, \n    hide_title, \n    dark_mode,\n    card_width = 500,\n  } = req.query;\n\n  res.setHeader(\"Content-Type\", \"image/svg+xml\");\n  // res.setHeader(\"Cache-Control\", \"public, max-age=43200\"); // 43200s（12h） cache\n  \n  return res.send(\n    renderError(`访问 https://luogu.wao3.cn 更换域名，造成不便敬请谅解`, { darkMode: dark_mode })\n  );\n\n  const validId = /^[1-9]\\d*$/;\n  const clamp = (min, max, n) => Math.max(min, Math.min(max, n));\n\n  if(!validId.test(id)) {\n    return res.send(renderError(`\"${id}\"不是一个合法uid`, {darkMode: dark_mode}));\n  }\n  if(!validId.test(card_width)) {\n    return res.send(renderError(`卡片宽度\"${card_width}\"不合法`, {darkMode: dark_mode}));\n  }\n\n  const stats = await fetchStats(id, true);\n  return res.send(renderSVG(stats, {\n    hideTitle: hide_title,\n    darkMode: dark_mode,\n    cardWidth: clamp(500, 1920, card_width),\n  }));\n};\n"
  },
  {
    "path": "index.js",
    "content": "module.exports = {\n    ...require(\"./src/guzhi-card\"),\n    ...require(\"./src/stats-card\"),\n    ...require(\"./src/common.js\"),\n}"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"luogu-stats-card\",\n  \"version\": \"1.0.1\",\n  \"description\": \"洛谷数据渲染组件\",\n  \"main\": \"index.js\",\n  \"scripts\": {},\n  \"keywords\": [],\n  \"author\": \"wao\",\n  \"license\": \"MIT\",\n  \"dependencies\": {\n    \"anafanafo\": \"^1.0.0\",\n    \"axios\": \"^0.21.1\"\n  }\n}\n"
  },
  {
    "path": "src/common.js",
    "content": "const anafanafo = require('anafanafo');\n\nconst NAMECOLOR = {\n  \"Gray\": \"#bbbbbb\",\n  \"Blue\": \"#0e90d2\",\n  \"Green\": \"#5eb95e\",\n  \"Orange\": \"#e67e22\",\n  \"Red\": \"#e74c3c\",\n  \"Purple\": \"#9d3dcf\",\n  \"Cheater\": \"#ad8b00\"\n}\n\nclass Card {\n  constructor({\n    width = 450,\n    height = 250,\n    title = \"\",\n    body = \"\",\n    titleHeight = 25,\n    hideTitle = false,\n    css = \"\",\n    darkMode = \"\",\n    paddingX = 25,\n    paddingY = 35,\n    hideBorder = false,\n  }) {\n    this.width = width;\n    this.height = height;\n    this.titleHeight = titleHeight;\n    this.title = title;\n    this.body = body;\n    this.hideTitle = hideTitle;\n    this.css = css;\n    this.darkMode = darkMode;\n    this.paddingX = paddingX;\n    this.paddingY = paddingY;\n    this.hideBorder = hideBorder;\n  }\n\n  render() {\n    const cardSize = {\n      width: this.width + 2*this.paddingX,\n      height: this.height + 2*this.paddingY,\n    };\n    if(!this.hideTitle) cardSize.height += this.titleHeight;\n\n    const bgColor = this.darkMode?\"#444444\":\"#fffefe\";\n    let borderColor = \"\";\n    if(!this.hideBorder) borderColor = this.darkMode?\"#444444\":\"#E4E2E2\";\n\n    return `\n      <svg xmlns=\"http://www.w3.org/2000/svg\" width=\"${cardSize.width}\" height=\"${cardSize.height}\" viewBox=\"0 0 ${cardSize.width} ${cardSize.height}\" fill=\"none\">\n        <style>\n          .text { font: 400 11px 'Segoe UI', Ubuntu, Sans-Serif; fill: ${this.darkMode?\"#fffefe\":\"#333333\"} }\n          .title {fill: ${this.darkMode?\"#fffefe\":\"#333333\"}}\n          .line { stroke:${this.darkMode?\"#666666\":\"#dddddd\"}; stroke-width:1 }\n          ${this.css}\n        </style>\n        <rect x=\"0.5\" y=\"0.5\" rx=\"4.5\" height=\"99%\" stroke=\"${borderColor}\" width=\"99%\" fill=\"${bgColor}\" stroke-opacity=\"1\" />\n        \n        ${this.hideTitle ? `` : `\n        <g transform=\"translate(${this.paddingX}, ${this.paddingY})\">\n          ${this.title}\n        </g>`}\n\n        <g transform=\"translate(${this.paddingX}, ${this.hideTitle ? this.paddingY : this.paddingY + this.titleHeight})\">\n          ${this.body}\n        </g>\n      </svg>`;\n  }\n}\n\n/**\n * 渲染错误卡片\n * @param {string} e 描述错误的文本\n * @param {Object} option 其余选项\n */\nconst renderError = (e, option) => {\n  const css = `.t {font: 600 18px 'Microsoft Yahei UI'; fill: #e74c3c;}`\n  const text = `<text class=\"t\" dominant-baseline=\"text-before-edge\">${e}</text>`\n  return new Card({\n    width: 500,\n    height: 23,\n    hideTitle: true,\n    css,\n    body: text,\n    paddingY: 20,\n    paddingX: 20,\n    ...option,\n  }).render();\n};\n\n/**\n * 渲染 ccf badge\n * @param {number} level CCF等级\n * @param {number} x badge的x坐标\n * @returns {string} ccf badge的svg字符串\n */\nconst renderCCFBadge = (level, x) => {\n  const ccfColor = (ccf) => {\n    if(ccf >= 3 && ccf <= 5) return \"#5eb95e\";\n    if(ccf >= 6 && ccf <= 7) return \"#3498db\";\n    if(ccf >= 8) return \"#f1c40f\";\n    return null;\n  }\n  return `\n  <svg xmlns=\"http://www.w3.org/2000/svg\" x=\"${x}\" y=\"-14\" width=\"18\" height=\"18\" viewBox=\"0 0 18 18\" fill=\"${ccfColor(level)}\" style=\"margin-bottom: -3px;\">\n    <path d=\"M16 8C16 6.84375 15.25 5.84375 14.1875 5.4375C14.6562 4.4375 14.4688 3.1875 13.6562 2.34375C12.8125 1.53125 11.5625 1.34375 10.5625 1.8125C10.1562 0.75 9.15625 0 8 0C6.8125 0 5.8125 0.75 5.40625 1.8125C4.40625 1.34375 3.15625 1.53125 2.34375 2.34375C1.5 3.1875 1.3125 4.4375 1.78125 5.4375C0.71875 5.84375 0 6.84375 0 8C0 9.1875 0.71875 10.1875 1.78125 10.5938C1.3125 11.5938 1.5 12.8438 2.34375 13.6562C3.15625 14.5 4.40625 14.6875 5.40625 14.2188C5.8125 15.2812 6.8125 16 8 16C9.15625 16 10.1562 15.2812 10.5625 14.2188C11.5938 14.6875 12.8125 14.5 13.6562 13.6562C14.4688 12.8438 14.6562 11.5938 14.1875 10.5938C15.25 10.1875 16 9.1875 16 8ZM11.4688 6.625L7.375 10.6875C7.21875 10.8438 7 10.8125 6.875 10.6875L4.5 8.3125C4.375 8.1875 4.375 7.96875 4.5 7.8125L5.3125 7C5.46875 6.875 5.6875 6.875 5.8125 7.03125L7.125 8.34375L10.1562 5.34375C10.3125 5.1875 10.5312 5.1875 10.6562 5.34375L11.4688 6.15625C11.5938 6.28125 11.5938 6.5 11.4688 6.625Z\">\n    </path>\n  </svg>`\n}\n\n/**\n * 渲染柱状图\n * @param {Object[]} datas 柱状图的数据数组\n * @param {string} datas.label 一条数据的标签\n * @param {string} datas.color 一条数据的颜色\n * @param {number} datas.data 一条数据的数值\n * @param {number} labelWidth 标签宽度\n * @param {number} progressWidth 柱状图的长度\n * @param {string} [unit] 数据单位\n */\nconst renderChart = (datas, labelWidth, progressWidth, unit) => { //(label, color, height, num, unit) => {\n  let chart = \"\";\n  let maxNum = datas.reduce((a, b) => Math.max(a, b.data), 0);\n  maxNum = (parseInt((maxNum-1) / 100) + 1) * 100;\n\n  for(let i = 0; i < datas.length; ++i) {\n    const width = (datas[i].data+1) / (maxNum+1) * progressWidth;\n    chart += `\n    <g transform=\"translate(0, ${i*30})\">\n      <text x=\"0\" y=\"15\" class=\"text\">${datas[i].label}</text>\n      <text x=\"${width + labelWidth + 10}\" y=\"15\" class=\"text\">${datas[i].data + unit}</text>\n      <rect height=\"11\" fill=\"${datas[i].color}\" rx=\"5\" ry=\"5\" x=\"${labelWidth}\" y=\"5\" width=\"${width}\"></rect>\n    </g>\n    `\n  }\n\n  const bodyHeight = datas.length * 30 + 10;\n  const dw = progressWidth / 4;\n  let coordinate = \"\";\n  for(let i = 0; i <= 4; ++i) {\n    coordinate += `\n    <line x1=\"${labelWidth + dw*i}\" y1=\"0\" x2=\"${labelWidth + dw*i}\" y2=\"${bodyHeight - 10}\"  class=\"line\"/>\n    <text x=\"${labelWidth + dw*i - (i==0?3:5) }\" y=\"${bodyHeight}\"  class=\"text\">${maxNum*i/4}</text>\n    `;\n  }\n  return coordinate + chart;\n}\n\n/**\n * \n * @param {string} name 用户名\n * @param {string} color 用户颜色\n * @param {number} ccfLevel 用户ccf等级\n * @param {string} title 标题的后缀\n * @param {string} rightTop 右上角的标签（展示总数）\n */\nconst renderNameTitle = (name, color, ccfLevel, title, cardWidth, rightTop) => {\n  const nameLength = anafanafo(name)/10*1.8;\n  const nameColor = NAMECOLOR[color];\n\n  return `\n  <g transform=\"translate(0, 0)\" font-family=\"Verdana, Microsoft Yahei\" text-rendering=\"geometricPrecision\" font-size=\"18\">\n    <text x=\"0\" y=\"0\" fill=\"${nameColor}\" font-weight=\"bold\" textLength=\"${nameLength}\">\n      ${name}\n    </text>\n    ${ccfLevel < 3 ? \"\" : renderCCFBadge(ccfLevel, nameLength + 5)}\n    <text x=\"${nameLength + (ccfLevel < 3 ? 10 : 28)}\" y=\"0\" class=\"title\" font-weight=\"normal\">\n      ${title}\n    </text>\n    <text x=\"${cardWidth - 160}\" y=\"0\" class=\"title\" font-weight=\"normal\" font-size=\"13px\">\n      ${rightTop}\n    </text>\n  </g>`;\n}\n\nmodule.exports = { \n  NAMECOLOR,\n  Card,\n  renderError,\n  renderCCFBadge,\n  renderChart,\n  renderNameTitle,\n};\n"
  },
  {
    "path": "src/guzhi-card.js",
    "content": "const {\n  Card,\n  renderError,\n  renderChart,\n  renderNameTitle,\n} = require(\"./common.js\");\n\nconst renderGuzhiCard = (userInfo, scores, options) => {\n  const regNum = /^\\d*$/;\n  if(!scores || typeof scores !== 'string') {\n    return renderError('咕值信息不能为空', {width: 400});\n  }\n  let sp = ',';\n  if(scores.indexOf('，') >= 0) {\n    sp = '，';\n  }\n  const scoreArray = scores.split(sp).filter(x => regNum.test(x)).map(x => parseInt(x)).filter(x => x >= 0 && x <= 100);\n  if(scoreArray.length != 5) {\n    return renderError(`咕值信息\"${scores}\"不合法`, {width: 400});\n  }\n  const scoreSum = scoreArray.reduce((a, b) => a+b);\n\n  const {\n    name,\n    color,\n    ccfLevel,\n  } = userInfo || {};\n\n  const { \n    hideTitle, \n    darkMode,\n    cardWidth = 500, \n  } = options || {};\n\n  const paddingX = 25;\n  const labelWidth = 90;  //柱状图头部文字长度\n  const progressWidth = cardWidth - 2*paddingX - labelWidth - 60; //500 - 25*2(padding) - 90(头部文字长度) - 60(预留尾部文字长度)，暂时固定，后序提供自定义选项;\n\n  const getScoreColor = (score) => {\n    if (score >= 80) return \"#52c41a\";\n    if (score >= 60) return \"#fadb14\";\n    if (score >= 30) return \"#f39c11\";\n    return \"#e74c3c\";\n  }\n  const datas = [\n    {label: \"基础信用\", data: scoreArray[0], color: getScoreColor(scoreArray[0])},\n    {label: \"练习情况\", data: scoreArray[1], color: getScoreColor(scoreArray[1])},\n    {label: \"社区贡献\", data: scoreArray[2], color: getScoreColor(scoreArray[2])},\n    {label: \"比赛情况\", data: scoreArray[3], color: getScoreColor(scoreArray[3])},\n    {label: \"获得成就\", data: scoreArray[4], color: getScoreColor(scoreArray[4])},\n  ]\n\n  const title = userInfo != null ? renderNameTitle(name, color, ccfLevel, \"的咕值信息\", cardWidth, `总咕值: ${scoreSum}分`) : \"\";\n\n  const body = renderChart(datas, labelWidth, progressWidth, \"分\");\n\n  return new Card({\n    width: cardWidth - 2*paddingX,\n    height: datas.length*30 + 10,\n    hideTitle,\n    darkMode,\n    title,\n    body,\n  }).render();\n}\n\nmodule.exports = { renderGuzhiCard }"
  },
  {
    "path": "src/stats-card.js",
    "content": "const axios = require(\"axios\");\nconst {\n  Card,\n  renderError,\n  renderChart,\n  renderNameTitle,\n} = require(\"./common.js\");\n\n/**\n * \n * @param {number} id 用户id\n * @param {boolean} useProxy 使用代理\n * @returns {Object} 获取的用户数据 {name, color, ccfLevel, passed, hideInfo}\n */\nasync function fetchStats(id, useProxy) {\n  //debug 测试请求\n  let reqUrl = `https://www.luogu.com.cn/user/${id}?_contentOnly`;\n  if (useProxy) {\n    reqUrl = `https://a-1c37c2-1300876583.ap-shanghai.service.tcloudbase.com/luogu?id=${id}`;\n  }\n  const res = await axios.get(reqUrl);\n\n  const stats = {\n    name: \"NULL\",\n    color: \"Gray\",\n    ccfLevel: 0,\n    passed: [0,0,0,0,0,0,0,0],\n    hideInfo: false\n  }\n  if(res.data.code !== 200) {\n    return stats;\n  }\n\n  const user = res.data.currentData.user;\n  const passed = res.data.currentData.passedProblems;\n\n  stats.name = user.name;\n  stats.color = user.color;\n  stats.ccfLevel = user.ccfLevel;\n\n  if(!passed) {\n    stats.hideInfo = true;\n    return stats;\n  }\n\n  for(let i of passed) {\n    stats.passed[i.difficulty]++;\n  }\n\n  return stats;\n} \n\nconst renderSVG = (stats, options) => {\n  const {\n    name,\n    color,\n    ccfLevel,\n    passed,\n    hideInfo\n  } = stats;\n\n  const { \n    hideTitle, \n    darkMode,\n    cardWidth = 500, \n  } = options || {};\n\n  if(hideInfo) {\n    return renderError(\"用户开启了“完全隐私保护”，获取数据失败\");\n  }\n  \n  const paddingX = 25;\n  const labelWidth = 90;  //柱状图头部文字长度\n  const progressWidth = cardWidth - 2*paddingX - labelWidth - 60; //500 - 25*2(padding) - 90(头部文字长度) - 60(预留尾部文字长度)，暂时固定，后序提供自定义选项;\n\n  const datas = [\n    {label: \"未评定\", color:\"#bfbfbf\", data: passed[0]},\n    {label: \"入门\", color:\"#fe4c61\", data: passed[1]},\n    {label: \"普及-\", color:\"#f39c11\", data: passed[2]},\n    {label: \"普及/提高-\", color:\"#ffc116\", data: passed[3]},\n    {label: \"普及+/提高\", color:\"#52c41a\", data: passed[4]},\n    {label: \"提高+/省选-\", color:\"#3498db\", data: passed[5]},\n    {label: \"省选/NOI-\", color:\"#9d3dcf\", data: passed[6]},\n    {label: \"NOI/NOI+/CTSC\", color:\"#0e1d69\", data: passed[7]}\n  ]\n  const passedSum = passed.reduce((a, b) => a + b);\n  const body = renderChart(datas, labelWidth, progressWidth, \"题\");\n\n  const title = renderNameTitle(name, color, ccfLevel, \"的练习情况\", cardWidth, `已通过: ${passedSum}题`);\n\n  return new Card({\n    width: cardWidth - 2*paddingX,\n    height: datas.length*30 + 10,\n    hideTitle,\n    darkMode,\n    title,\n    body,\n  }).render();\n}\n\nmodule.exports = { fetchStats, renderSVG }\n"
  },
  {
    "path": "tcb/.gitignore",
    "content": ".DS_Store\nnode_modules\n/dist\n\n\n# local env files\n.env.local\n.env.*.local\n\n# Log files\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\npnpm-debug.log*\n\n# Editor directories and files\n.idea\n.vscode\n*.suo\n*.ntvs*\n*.njsproj\n*.sln\n*.sw?\n"
  },
  {
    "path": "tcb/babel.config.js",
    "content": "module.exports = {\n  presets: [\n    '@vue/cli-plugin-babel/preset'\n  ]\n}\n"
  },
  {
    "path": "tcb/cloudbaserc.json",
    "content": "{\n  \"version\": \"2.0\",\n  \"envId\": \"cloudbase-baas-5g6v8dai30476fe3\",\n  \"$schema\": \"https://framework-1258016615.tcloudbaseapp.com/schema/latest.json\",\n  \"functionRoot\": \"./functions\",\n  \"functions\": [\n    {\n      \"name\": \"index\",\n      \"timeout\": 10,\n      \"envVariables\": {},\n      \"runtime\": \"Nodejs12.16\",\n      \"memorySize\": 128,\n      \"handler\": \"index.main\"\n    },\n    {\n      \"name\": \"luogu\",\n      \"timeout\": 10,\n      \"envVariables\": {},\n      \"runtime\": \"Nodejs12.16\",\n      \"memorySize\": 128,\n      \"handler\": \"index.main\"\n    }\n  ],\n  \"framework\": {\n    \"name\": \"luogu-stats-card\",\n    \"plugins\": {\n      \"function\": {\n        \"use\": \"@cloudbase/framework-plugin-function\",\n        \"inputs\": {}\n      },\n      \"client\": {\n        \"use\": \"@cloudbase/framework-plugin-database\",\n        \"inputs\": {\n          \"collections\": [\n            {\n              \"collectionName\": \"cache\"\n            }\n          ]\n        }\n      },\n      \"homepage\": {\n        \"use\": \"@cloudbase/framework-plugin-website\",\n        \"inputs\": {\n          \"installCommand\": \"npm install\",\n          \"buildCommand\": \"npm run build\",\n          \"outputPath\": \"dist\",\n          \"ignore\": [\n            \".git\",\n            \".github\",\n            \"node_modules\",\n            \"cloudbaserc.js\"\n          ]\n        }\n      }\n    }\n  },\n  \"region\": \"ap-shanghai\"\n}\n"
  },
  {
    "path": "tcb/functions/index/index.js",
    "content": "const axios = require('axios');\n\nmodule.exports.main = async function (event, context) {\n  const baseUrl = \"https://www.wao3.cn\";\n  if (event.path === \"/\") event.path = \"/luogu/index.html\"\n  const res = await axios.get(baseUrl + event.path);\n  return {\n    statusCode: res.status,\n    headers: res.headers,\n    body: res.data,\n  }\n};"
  },
  {
    "path": "tcb/functions/index/package.json",
    "content": "{\n    \"name\": \"luogu-index\",\n    \"version\": \"1.0.0\",\n    \"description\": \"a proxy to https://wao3.cn/luogu\",\n    \"main\": \"index.js\",\n    \"scripts\": {\n        \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\"\n    },\n    \"author\": \"wao\",\n    \"license\": \"MIT\",\n    \"dependencies\": {\n        \"axios\": \"^0.21.1\"\n    }\n}\n"
  },
  {
    "path": "tcb/functions/luogu/cache.js",
    "content": "const cloudbase = require('@cloudbase/node-sdk')\n\nconst app = cloudbase.init({\n  env: cloudbase.SYMBOL_CURRENT_ENV,\n})\nconst db = app.database();\n\n// 12 hour\nconst EXPIRE_TIME_MILLISECOND = 1000 * 60 * 60 * 12;\nconst CACHE_NAME = 'cache';\n\nconst cache = {\n  get: async (key) => {\n    const res = await db.collection(CACHE_NAME)\n      .doc(key)\n      .get();\n    if (res.data === null || res.data.length === 0) {\n      return null;\n    }\n    const data = res.data[0];\n    if (!data || !data.updateTime) {\n      return null;\n    }\n    const updateTime = new Date(data.updateTime);\n    const now = new Date();\n    if (now.valueOf() - updateTime.valueOf() > EXPIRE_TIME_MILLISECOND) {\n      return null;\n    }\n    return data.value;\n  },\n  put: async (key, value) => {\n    if (key == null) {\n      return null;\n    }\n    const res = await db\n      .collection(CACHE_NAME)\n      .doc(key)\n      .set({\n        value,\n        updateTime: new Date(),\n      });\n    if (!res.updated || !res.upsertedId) {\n      return value;\n    }\n    return null;\n  }\n}\n\nmodule.exports = cache;"
  },
  {
    "path": "tcb/functions/luogu/fetchStats.js",
    "content": "const { fetchStats } = require('luogu-stats-card');\nconst cache = require('./cache');\n\nmodule.exports = async id => {\n  const cacheKey = 'uid:' + id;\n  let stats = await cache.get(cacheKey);\n  if (!stats) {\n    stats = await fetchStats(id);\n    if (stats) {\n      await cache.put(cacheKey, stats);\n    }\n  }\n  return stats;\n}"
  },
  {
    "path": "tcb/functions/luogu/index.js",
    "content": "const axios = require('axios');\nconst { renderError } = require('luogu-stats-card');\nconst practice = require('./route/practice');\nconst guzhi = require('./route/guzhi');\n\nmodule.exports.main = async function (event, context) {\n  checkParam(event.queryStringParameters);\n  let result = null;\n  if (event.path.startsWith(\"/practice\")) {\n    result = await practice(event);\n  } else if (event.path.startsWith(\"/guzhi\")) {\n    result = await guzhi(event);\n  } else {\n    result = renderError(`路径错误：${event.path}`, { darkMode: dark_mode });\n  }\n  return {\n    statusCode: 200,\n    headers: {\n      \"content-type\": \"image/svg+xml; charset=utf-8\",\n      \"Cache-Control\": \"public, max-age=43200\",\n    },\n    body: result\n  };\n};\n\nfunction checkParam(queryParam) {\n  const { id, dark_mode, card_width = 500 } = queryParam;\n\n  const regNum = /^[1-9]\\d*$/;\n  const clamp = (min, max, n) => Math.max(min, Math.min(max, n));\n\n  if (!regNum.test(card_width)) {\n    return renderError(`卡片宽度\"${card_width}\"不合法`, { darkMode: dark_mode });\n  }\n\n  if(id != undefined && !regNum.test(id)) {\n    return renderError(`卡片宽度\"${card_width}\"不合法`, { darkMode: dark_mode });\n  }\n\n  queryParam.card_width = clamp(500, 1920, card_width);\n}"
  },
  {
    "path": "tcb/functions/luogu/package.json",
    "content": "{\n  \"name\": \"luogu-tcb\",\n  \"version\": \"1.0.0\",\n  \"description\": \"\",\n  \"main\": \"index.js\",\n  \"scripts\": {\n    \"test\": \"echo \\\"Error: no test specified\\\" && exit 1\"\n  },\n  \"author\": \"wao\",\n  \"license\": \"MIT\",\n  \"dependencies\": {\n    \"@cloudbase/node-sdk\": \"^2.7.1\",\n    \"luogu-stats-card\": \"^1.0.1\"\n  }\n}\n"
  },
  {
    "path": "tcb/functions/luogu/route/guzhi.js",
    "content": "const { renderGuzhiCard } = require('luogu-stats-card');\nconst fetchStats = require('../fetchStats');\n\nmodule.exports = async function (event) {\n  const {\n    id,\n    scores,\n    hide_title,\n    dark_mode,\n    card_width = 500,\n  } = event.queryStringParameters;\n\n  const stats = await fetchStats(id);\n\n  return renderGuzhiCard(stats, scores, {\n    hideTitle: stats === null ? true : hide_title,\n    darkMode: dark_mode,\n    cardWidth: card_width,\n  });\n};"
  },
  {
    "path": "tcb/functions/luogu/route/practice.js",
    "content": "const { renderSVG } = require('luogu-stats-card');\nconst fetchStats = require('../fetchStats');\n\nmodule.exports = async function (event) {\n  const { \n    id, \n    hide_title, \n    dark_mode,\n    card_width = 500,\n  } = event.queryStringParameters;\n\n  const stats = await fetchStats(id);\n\n  return renderSVG(stats, {\n    hideTitle: hide_title,\n    darkMode: dark_mode,\n    cardWidth: card_width,\n  });\n};"
  },
  {
    "path": "tcb/functions/luogu/test.js",
    "content": "// const { main } = require('./index');\n\n// main({\n//   queryStringParameters: {\n//     id: 313209,\n//   },\n//   path: '/practice',\n// }).then(res => console.log(res));\n\n///////\n\n// const fetchStats = require('./fetchStats');\n\n// fetchStats(313209).then(res => console.log(res));\n"
  },
  {
    "path": "tcb/package.json",
    "content": "{\n  \"name\": \"guide\",\n  \"version\": \"0.1.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"serve\": \"SET NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service serve\",\n    \"build\": \"SET NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service build\",\n    \"lint\": \"SET NODE_OPTIONS=--openssl-legacy-provider && vue-cli-service lint\"\n  },\n  \"dependencies\": {\n    \"ant-design-vue\": \"^1.7.7\",\n    \"copy-to-clipboard\": \"^3.3.1\",\n    \"core-js\": \"^3.6.5\",\n    \"debounce\": \"^1.2.1\",\n    \"vue\": \"^2.6.11\"\n  },\n  \"devDependencies\": {\n    \"@vue/cli-plugin-babel\": \"~4.5.0\",\n    \"@vue/cli-plugin-eslint\": \"~4.5.0\",\n    \"@vue/cli-service\": \"~4.5.0\",\n    \"babel-eslint\": \"^10.1.0\",\n    \"eslint\": \"^6.7.2\",\n    \"eslint-plugin-vue\": \"^6.2.2\",\n    \"vue-template-compiler\": \"^2.6.11\",\n    \"babel\": \"^6.23.0\"\n  },\n  \"eslintConfig\": {\n    \"root\": true,\n    \"env\": {\n      \"node\": true\n    },\n    \"extends\": [\n      \"plugin:vue/essential\",\n      \"eslint:recommended\"\n    ],\n    \"parserOptions\": {\n      \"parser\": \"babel-eslint\"\n    },\n    \"rules\": {}\n  },\n  \"browserslist\": [\n    \"> 1%\",\n    \"last 2 versions\",\n    \"not dead\"\n  ]\n}\n"
  },
  {
    "path": "tcb/public/index.html",
    "content": "<!DOCTYPE html>\n<html lang=\"\">\n  <head>\n    <meta charset=\"utf-8\">\n    <meta http-equiv=\"X-UA-Compatible\" content=\"IE=edge\">\n    <meta name=\"viewport\" content=\"width=device-width,initial-scale=1.0\">\n    <link rel=\"icon\" href=\"<%= BASE_URL %>favicon.ico\">\n    <title>洛谷个人练习数据卡片</title>\n  </head>\n  <body>\n    <noscript>\n      <strong>We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>\n    </noscript>\n    <div id=\"app\"></div>\n    <!-- built files will be auto injected -->\n  </body>\n</html>\n"
  },
  {
    "path": "tcb/src/App.vue",
    "content": "<template>\n  <div id=\"app\">\n    <Homepage/>\n  </div>\n</template>\n\n<script>\nimport Homepage from './Homepage.vue'\n\nexport default {\n  name: 'App',\n  components: {\n    Homepage\n  }\n}\n</script>\n\n<style>\ninput::-webkit-outer-spin-button,\ninput::-webkit-inner-spin-button{\n\t-webkit-appearance: none !important;\n\tmargin: 0;\n}\n#app {\n  max-width: 1000px;\n  margin: 50px auto;\n  padding: 20px\n}\n</style>\n"
  },
  {
    "path": "tcb/src/Homepage.vue",
    "content": "<template>\n  <div>\n    <h1>洛谷个人练习数据卡片</h1>\n    <a-alert type=\"info\" show-icon style=\"margin-bottom: 1em\">\n      <p slot=\"description\" style=\"margin-bottom: 0\">\n        为了不滥用洛谷服务器流量，本项目将缓存 12 小时用户数据，即同一个用户卡片\n        24 小时内最多只会向洛谷服务器请求 2\n        次数据，并且只有在用户访问卡片时才会请求数据。\n        <br />\n        <br />\n        本项目完全开源，如果对您有帮助的话希望能\n        <a href=\"https://github.com/wao3/luogu-stats-card\" target=\"_blank\">\n          给该项目点一个star\n        </a>\n      </p>\n    </a-alert>\n    <Luogu />\n    <a\n      href=\"https://github.com/wao3/luogu-stats-card\"\n      class=\"github-corner\"\n      aria-label=\"View source on GitHub\"\n      target=\"_blank\"\n    >\n      <svg\n        width=\"80\"\n        height=\"80\"\n        viewBox=\"0 0 250 250\"\n        style=\"\n          fill: #1890FF;\n          color: #fff;\n          position: absolute;\n          top: 0;\n          border: 0;\n          right: 0;\n        \"\n        aria-hidden=\"true\"\n      >\n        <path d=\"M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z\"></path>\n        <path\n          d=\"M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2\"\n          fill=\"currentColor\"\n          style=\"transform-origin: 130px 106px\"\n          class=\"octo-arm\"\n        ></path>\n        <path\n          d=\"M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z\"\n          fill=\"currentColor\"\n          class=\"octo-body\"\n        ></path>\n      </svg>\n    </a>\n  </div>\n</template>\n\n<script>\nimport Luogu from \"./components/Luogu.vue\";\n\nexport default {\n  name: \"Homepage\",\n  components: {\n    Luogu,\n  },\n  data() {\n    return {};\n  },\n};\n</script>\n\n<style>\n.github-corner:hover .octo-arm {\n  animation: octocat-wave 560ms ease-in-out;\n}\n@keyframes octocat-wave {\n  0%,\n  100% {\n    transform: rotate(0);\n  }\n  20%,\n  60% {\n    transform: rotate(-25deg);\n  }\n  40%,\n  80% {\n    transform: rotate(10deg);\n  }\n}\n@media (max-width: 500px) {\n  .github-corner:hover .octo-arm {\n    animation: none;\n  }\n  .github-corner .octo-arm {\n    animation: octocat-wave 560ms ease-in-out;\n  }\n}\n</style>"
  },
  {
    "path": "tcb/src/components/Luogu.vue",
    "content": "<template>\n  <a-form-model :model=\"form\" labelAlign=\"left\" :label-col=\"labelCol\" :wrapper-col=\"wrapperCol\">\n    <a-form-model-item label=\"卡片类型\">\n      <a-radio-group\n        v-model=\"form.type\"\n        default-value=\"练习情况\"\n        button-style=\"solid\"\n      >\n        <a-radio-button value=\"practice\"> 练习情况 </a-radio-button>\n        <a-radio-button value=\"guzhi\"> 咕值信息 </a-radio-button>\n      </a-radio-group>\n    </a-form-model-item>\n\n    <a-form-model-item label=\"用户 UID\">\n      <a-input type=\"number\" v-model.number=\"form.uid\" />\n    </a-form-model-item>\n\n    <a-form-model-item\n      v-show=\"form.type == 'guzhi'\"\n      :label=\"itm\"\n      v-for=\"(itm, idx) in guzhiItems\"\n      :key=\"'guzhi' + itm\"\n    >\n      <a-row>\n        <a-col :span=\"20\">\n          <a-slider v-model=\"form.guzhi[idx]\" :min=\"0\" :max=\"100\" />\n        </a-col>\n        <a-col :span=\"4\">\n          <a-input-number\n            v-model=\"form.guzhi[idx]\"\n            :min=\"0\"\n            :max=\"100\"\n            style=\"marginleft: 16px\"\n          />\n        </a-col>\n      </a-row>\n    </a-form-model-item>\n\n    <a-form-model-item label=\"暗黑模式\">\n      <a-switch v-model=\"form.darkMode\" />\n    </a-form-model-item>\n\n    <a-form-model-item label=\"隐藏标题\">\n      <a-switch v-model=\"form.hideTitle\" />\n    </a-form-model-item>\n\n    <a-form-model-item label=\"卡片宽度\">\n      <a-row>\n        <a-col :span=\"20\">\n          <a-slider v-model=\"form.cardWidth\" :min=\"500\" :max=\"1920\" />\n        </a-col>\n        <a-col :span=\"4\">\n          <a-input-number\n            v-model=\"form.cardWidth\"\n            :min=\"500\"\n            :max=\"1920\"\n            style=\"marginleft: 16px\"\n          />\n        </a-col>\n      </a-row>\n    </a-form-model-item>\n\n    <a-form-model-item label=\"效果预览\">\n      <img alt=\"\" :src=\"imgUrl\" />\n    </a-form-model-item>\n\n    <a-form-model-item label=\"复制代码\">\n      <a-tabs v-model=\"codeMode\" @change=\"copyCode\">\n        <a-tab-pane\n          v-for=\"codeMode in codeModes\"\n          :key=\"codeMode\"\n          :tab=\"codeMode\"\n        >\n          <pre><code>{{codes[codeMode]}}</code></pre>\n        </a-tab-pane>\n      </a-tabs>\n    </a-form-model-item>\n  </a-form-model>\n</template>\n\n<script>\nimport { debounce } from \"debounce\";\nimport copy from 'copy-to-clipboard';\n\nexport default {\n  data() {\n    return {\n      labelCol: { span: 3 },\n      wrapperCol: { span: 21},\n      guzhiItems: [\"基础信用\", \"练习情况\", \"社区贡献\", \"比赛情况\", \"获得成就\"],\n      form: {\n        type: \"practice\",\n        uid: 313209,\n        guzhi: [0, 0, 0, 0, 0],\n        darkMode: false,\n        hideTitle: false,\n        cardWidth: 500,\n      },\n      codeModes: [\"Markdown\", \"HTML\", \"URL\"],\n      codeMode: \"Markdown\",\n      codes: {\n        \"Markdown\": '',\n        \"HTML\": '',\n        \"URL\": '',\n      },\n      imgUrl: \"https://luogu.wao3.cn/api/practice?id=313209\",\n    };\n  },\n  methods: {\n    copyCode() {\n      const code = this.codes[this.codeMode];\n      copy(code);\n      this.$message.success('已复制到剪切板');\n    }\n  },\n  watch: {\n    form: {\n      handler() {\n        const url = this.realtimeImgUrl;\n        debounce(() => {\n          this.imgUrl = url;\n        }, 200)();\n        this.codes[\"Markdown\"] = `![我的练习情况](${url})`;\n        this.codes[\"HTML\"] = `<img src=\"${url}\" alt=\"我的练习情况\"/>`;\n        this.codes[\"URL\"] = url;\n      },\n      deep: true,\n      immediate: true,\n    }\n  },\n  computed: {\n    realtimeImgUrl() {\n      const form = this.form;\n      let url = `https://luogu.wao3.cn/api/${form.type}?id=${form.uid}`;\n      if (form.type == 'guzhi') {\n        url += `&scores=${form.guzhi.join(',')}`;\n      }\n      if (form.darkMode) {\n        url += '&dark_mode=true';\n      }\n      if (form.hideTitle) {\n        url += '&hide_title=true';\n      }\n      if (form.cardWidth !== 500) {\n        url += `&card_width=${form.cardWidth}`;\n      }\n      return url;\n    }\n  }\n};\n</script>\n\n<style>\npre code {\n  display: block;\n  padding: 16px;\n  overflow: auto;\n  line-height: 1.3;\n  color: #476582;\n  background-color: rgba(27, 31, 35, 0.05);\n  border-radius: 4px;\n}\n\nimg {\n  max-width: 100%;\n}\n\n.ant-input-number {\n  width: 100% !important;\n}\n</style>"
  },
  {
    "path": "tcb/src/main.js",
    "content": "import Vue from 'vue'\nimport App from './App.vue'\nimport Antd from 'ant-design-vue';\nimport 'ant-design-vue/dist/antd.css';\n\nVue.config.productionTip = false\n\nVue.use(Antd);\n\nnew Vue({\n  render: h => h(App),\n}).$mount('#app')\n"
  },
  {
    "path": "vercel.json",
    "content": "{\n  \"builds\": [\n    { \"src\": \"api/*.js\", \"use\": \"@vercel/node\" }\n  ],\n  \"routes\": [\n    { \"src\": \"/practice\", \"dest\": \"/api/index.js\" },\n    { \"src\": \"/guzhi\", \"dest\": \"/api/guzhi.js\" },\n    { \"src\": \"/\", \"status\": 301, \"headers\": { \"Location\": \"https://luogu.wao3.cn\" } }\n  ],\n  \"regions\": [\"hkg1\"]\n}\n"
  }
]