├── public ├── favicon.ico └── index.html ├── babel.config.js ├── src ├── assets │ ├── 404_images │ │ ├── 404.png │ │ └── 404_cloud.png │ └── help_images │ │ ├── search.png │ │ ├── token.png │ │ ├── upload.png │ │ ├── new-repos.png │ │ ├── generate-token.png │ │ └── setting-token.png ├── store │ ├── getters.js │ ├── index.js │ └── modules │ │ ├── settings.js │ │ └── app.js ├── layout │ ├── components │ │ ├── index.js │ │ ├── Sidebar │ │ │ ├── FixiOSBug.js │ │ │ ├── Link.vue │ │ │ ├── Item.vue │ │ │ ├── index.vue │ │ │ ├── Logo.vue │ │ │ └── SidebarItem.vue │ │ ├── AppMain.vue │ │ └── Navbar.vue │ ├── mixin │ │ └── ResizeHandler.js │ └── index.vue ├── App.vue ├── utils │ ├── uuid.js │ ├── get-page-title.js │ ├── copy.js │ ├── validate.js │ ├── task-queue.js │ ├── directive.js │ ├── request.js │ └── index.js ├── icons │ ├── svg │ │ ├── link.svg │ │ ├── markdown.svg │ │ └── dashboard.svg │ ├── index.js │ └── svgo.yml ├── views │ ├── dashboard │ │ └── index.vue │ ├── Setting.vue │ ├── Help.vue │ ├── 404.vue │ └── Manage.vue ├── styles │ ├── mixin.scss │ ├── variables.scss │ ├── element-ui.scss │ ├── transition.scss │ ├── index.scss │ └── sidebar.scss ├── settings.js ├── main.js ├── api │ └── github.js ├── components │ ├── Hamburger │ │ └── index.vue │ ├── SvgIcon │ │ └── index.vue │ └── Breadcrumb │ │ └── index.vue └── router │ └── index.js ├── .gitignore ├── README.md ├── package.json ├── LICENSE └── vue.config.js /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Naccl/PictureHosting/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/assets/404_images/404.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Naccl/PictureHosting/HEAD/src/assets/404_images/404.png -------------------------------------------------------------------------------- /src/assets/help_images/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Naccl/PictureHosting/HEAD/src/assets/help_images/search.png -------------------------------------------------------------------------------- /src/assets/help_images/token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Naccl/PictureHosting/HEAD/src/assets/help_images/token.png -------------------------------------------------------------------------------- /src/assets/help_images/upload.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Naccl/PictureHosting/HEAD/src/assets/help_images/upload.png -------------------------------------------------------------------------------- /src/assets/404_images/404_cloud.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Naccl/PictureHosting/HEAD/src/assets/404_images/404_cloud.png -------------------------------------------------------------------------------- /src/assets/help_images/new-repos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Naccl/PictureHosting/HEAD/src/assets/help_images/new-repos.png -------------------------------------------------------------------------------- /src/assets/help_images/generate-token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Naccl/PictureHosting/HEAD/src/assets/help_images/generate-token.png -------------------------------------------------------------------------------- /src/assets/help_images/setting-token.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Naccl/PictureHosting/HEAD/src/assets/help_images/setting-token.png -------------------------------------------------------------------------------- /src/store/getters.js: -------------------------------------------------------------------------------- 1 | const getters = { 2 | sidebar: state => state.app.sidebar, 3 | device: state => state.app.device, 4 | } 5 | export default getters 6 | -------------------------------------------------------------------------------- /src/layout/components/index.js: -------------------------------------------------------------------------------- 1 | export { default as Navbar } from './Navbar' 2 | export { default as Sidebar } from './Sidebar' 3 | export { default as AppMain } from './AppMain' 4 | -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | -------------------------------------------------------------------------------- /src/utils/uuid.js: -------------------------------------------------------------------------------- 1 | export function randomUUID() { 2 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 3 | let r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8) 4 | return v.toString(16) 5 | }) 6 | } -------------------------------------------------------------------------------- /src/utils/get-page-title.js: -------------------------------------------------------------------------------- 1 | import defaultSettings from '@/settings' 2 | 3 | const title = defaultSettings.title 4 | 5 | export default function getPageTitle(pageTitle) { 6 | if (pageTitle) { 7 | return `${pageTitle} - ${title}` 8 | } 9 | return `${title}` 10 | } 11 | -------------------------------------------------------------------------------- /src/icons/svg/link.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/utils/copy.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 通过生成DOM节点来复制内容至剪贴板 3 | * @param {string} 需要复制的内容 4 | */ 5 | export function copy(copyCont) { 6 | let oInput = document.createElement('input') 7 | oInput.value = copyCont 8 | document.body.appendChild(oInput) 9 | oInput.select() 10 | document.execCommand('Copy') 11 | oInput.remove() 12 | } -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import getters from './getters' 4 | import app from './modules/app' 5 | import settings from './modules/settings' 6 | 7 | Vue.use(Vuex) 8 | 9 | export default new Vuex.Store({ 10 | modules: { 11 | app, 12 | settings, 13 | }, 14 | getters 15 | }) 16 | -------------------------------------------------------------------------------- /src/icons/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import SvgIcon from '@/components/SvgIcon'// svg component 3 | 4 | // register globally 5 | Vue.component('svg-icon', SvgIcon) 6 | 7 | const req = require.context('./svg', false, /\.svg$/) 8 | const requireAll = requireContext => requireContext.keys().map(requireContext) 9 | requireAll(req) 10 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /src/icons/svgo.yml: -------------------------------------------------------------------------------- 1 | # replace default config 2 | 3 | # multipass: true 4 | # full: true 5 | 6 | plugins: 7 | 8 | # - name 9 | # 10 | # or: 11 | # - name: false 12 | # - name: true 13 | # 14 | # or: 15 | # - name: 16 | # param1: 1 17 | # param2: 2 18 | 19 | - removeAttrs: 20 | attrs: 21 | - 'fill' 22 | - 'fill-rule' 23 | -------------------------------------------------------------------------------- /src/views/dashboard/index.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 12 | 13 | 25 | -------------------------------------------------------------------------------- /src/utils/validate.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param {string} path 3 | * @returns {Boolean} 4 | */ 5 | export function isExternal(path) { 6 | return /^(https?:|mailto:|tel:)/.test(path) 7 | } 8 | 9 | /** 10 | * https://developer.mozilla.org/zh-CN/docs/Web/HTML/Element/img 11 | * @param {string} fileName 12 | * @returns {Boolean} 13 | */ 14 | export function isImgExt(fileName) { 15 | return /\.(apng|avif|bmp|gif|ico|cur|jpg|jpeg|jfif|pjpeg|pjp|png|svg|tif|tiff|webp)$/i.test(fileName) 16 | } -------------------------------------------------------------------------------- /src/styles/mixin.scss: -------------------------------------------------------------------------------- 1 | @mixin clearfix { 2 | &:after { 3 | content: ""; 4 | display: table; 5 | clear: both; 6 | } 7 | } 8 | 9 | @mixin scrollBar { 10 | &::-webkit-scrollbar-track-piece { 11 | background: #d3dce6; 12 | } 13 | 14 | &::-webkit-scrollbar { 15 | width: 6px; 16 | } 17 | 18 | &::-webkit-scrollbar-thumb { 19 | background: #99a9bf; 20 | border-radius: 20px; 21 | } 22 | } 23 | 24 | @mixin relative { 25 | position: relative; 26 | width: 100%; 27 | height: 100%; 28 | } 29 | -------------------------------------------------------------------------------- /src/utils/task-queue.js: -------------------------------------------------------------------------------- 1 | /** 2 | * 任务队列,按时间间隔执行函数 3 | * 例: taskQueue(()=>{console.log(123)},1000) 4 | */ 5 | let queue = [] 6 | let timer = null 7 | 8 | function process() { 9 | if (queue.length === 0) { 10 | clearInterval(timer) 11 | timer = null 12 | return 13 | } 14 | let fn = queue.shift() 15 | fn() 16 | if (queue.length === 0) { 17 | clearInterval(timer) 18 | timer = null 19 | } 20 | } 21 | 22 | export function taskQueue(fn, timeout/*仅第一个任务的timeout有效*/) { 23 | queue.push(fn) 24 | if (!timer) { 25 | process() 26 | timer = setInterval(process, timeout) 27 | } 28 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PictureHosting 2 | 3 | GitHub 图床管理工具,[在线预览](https://naccl.github.io/PictureHosting/)(由于众所周知的原因,请自备网络环境) 4 | 5 | 基于 [GitHub API](https://docs.github.com/cn/rest),使用 GitHub 仓库作为图床 + jsDelivr CDN 加速访问 6 | 7 | 项目基于 [my-vue-admin-template](https://github.com/Naccl/my-vue-admin-template) 模板创建,纯 Vue,无后端(如果 GitHub 不算的话 8 | 9 | 灵感来自 [imgurl](https://github.com/WishMelz/imgurl),优化了界面和使用体验,解决了仓库多级目录的问题 10 | 11 | 12 | 13 | ## Project setup 14 | ``` 15 | npm install 16 | ``` 17 | 18 | ### Compiles and hot-reloads for development 19 | ``` 20 | npm run serve 21 | ``` 22 | 23 | ### Compiles and minifies for production 24 | ``` 25 | npm run build 26 | ``` 27 | 28 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | <%= htmlWebpackPlugin.options.title %> 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/settings.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | /** 3 | * @type {string} 4 | * @description page title 5 | */ 6 | title: 'Picture Hosting', 7 | 8 | /** 9 | * @type {string} 10 | * @description logo URL 11 | */ 12 | logo: 'https://wpimg.wallstcn.com/69a1c46c-eb1c-4b46-8bd4-e9e686ef5251.png', 13 | 14 | /** 15 | * @type {boolean} true | false 16 | * @description Whether fix the header 17 | */ 18 | fixedHeader: true, 19 | 20 | /** 21 | * @type {boolean} true | false 22 | * @description Whether show the logo in sidebar 23 | */ 24 | sidebarLogo: true, 25 | 26 | /** 27 | * @type {Array} 28 | * @description 默认展开的父级菜单 29 | */ 30 | defaultOpeneds: ['/pictureHosting'] 31 | } -------------------------------------------------------------------------------- /src/styles/variables.scss: -------------------------------------------------------------------------------- 1 | // sidebar 2 | $menuText:#bfcbd9; 3 | $menuActiveText:#409EFF; 4 | $subMenuActiveText:#f4f4f5; //https://github.com/ElemeFE/element/issues/12951 5 | 6 | $menuBg:#304156; 7 | $menuHover:#263445; 8 | 9 | $subMenuBg:#1f2d3d; 10 | $subMenuHover:#001528; 11 | 12 | $sideBarWidth: 210px; 13 | 14 | // the :export directive is the magic sauce for webpack 15 | // https://www.bluematador.com/blog/how-to-share-variables-between-js-and-sass 16 | :export { 17 | menuText: $menuText; 18 | menuActiveText: $menuActiveText; 19 | subMenuActiveText: $subMenuActiveText; 20 | menuBg: $menuBg; 21 | menuHover: $menuHover; 22 | subMenuBg: $subMenuBg; 23 | subMenuHover: $subMenuHover; 24 | sideBarWidth: $sideBarWidth; 25 | } 26 | -------------------------------------------------------------------------------- /src/store/modules/settings.js: -------------------------------------------------------------------------------- 1 | import defaultSettings from '@/settings' 2 | 3 | const {title, logo, fixedHeader, sidebarLogo, defaultOpeneds} = defaultSettings 4 | 5 | const state = { 6 | title: title, 7 | logo: logo, 8 | fixedHeader: fixedHeader, 9 | sidebarLogo: sidebarLogo, 10 | defaultOpeneds: defaultOpeneds, 11 | } 12 | 13 | const mutations = { 14 | CHANGE_SETTING: (state, {key, value}) => { 15 | // eslint-disable-next-line no-prototype-builtins 16 | if (state.hasOwnProperty(key)) { 17 | state[key] = value 18 | } 19 | } 20 | } 21 | 22 | const actions = { 23 | changeSetting({commit}, data) { 24 | commit('CHANGE_SETTING', data) 25 | } 26 | } 27 | 28 | export default { 29 | namespaced: true, 30 | state, 31 | mutations, 32 | actions 33 | } 34 | 35 | -------------------------------------------------------------------------------- /src/layout/components/Sidebar/FixiOSBug.js: -------------------------------------------------------------------------------- 1 | export default { 2 | computed: { 3 | device() { 4 | return this.$store.state.app.device 5 | } 6 | }, 7 | mounted() { 8 | // In order to fix the click on menu on the ios device will trigger the mouseleave bug 9 | // https://github.com/PanJiaChen/vue-element-admin/issues/1135 10 | this.fixBugIniOS() 11 | }, 12 | methods: { 13 | fixBugIniOS() { 14 | const $subMenu = this.$refs.subMenu 15 | if ($subMenu) { 16 | const handleMouseleave = $subMenu.handleMouseleave 17 | $subMenu.handleMouseleave = (e) => { 18 | if (this.device === 'mobile') { 19 | return 20 | } 21 | handleMouseleave(e) 22 | } 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/layout/components/AppMain.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 19 | 20 | 34 | 35 | 43 | -------------------------------------------------------------------------------- /src/layout/components/Sidebar/Link.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 44 | -------------------------------------------------------------------------------- /src/layout/components/Sidebar/Item.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 42 | -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import App from './App.vue' 3 | import router from './router' 4 | import store from './store' 5 | 6 | // A modern alternative to CSS resets 7 | import 'normalize.css/normalize.css' 8 | // element-ui 9 | import ElementUI from 'element-ui' 10 | import 'element-ui/lib/theme-chalk/index.css' 11 | // global css 12 | import '@/styles/index.scss' 13 | // icon 14 | import '@/icons' 15 | //v-viewer 16 | import 'viewerjs/dist/viewer.css' 17 | import Viewer from 'v-viewer' 18 | // directive 19 | import './utils/directive' 20 | 21 | Vue.use(ElementUI) 22 | Vue.use(Viewer) 23 | 24 | Vue.prototype.msgSuccess = function (msg) { 25 | this.$message.success(msg) 26 | } 27 | 28 | Vue.prototype.msgError = function (msg) { 29 | this.$message.error(msg) 30 | } 31 | 32 | Vue.config.productionTip = false 33 | 34 | new Vue({ 35 | router, 36 | store, 37 | render: h => h(App) 38 | }).$mount('#app') -------------------------------------------------------------------------------- /src/styles/element-ui.scss: -------------------------------------------------------------------------------- 1 | // cover some element-ui styles 2 | 3 | .el-breadcrumb__inner, 4 | .el-breadcrumb__inner a { 5 | font-weight: 400 !important; 6 | } 7 | 8 | .el-upload { 9 | input[type="file"] { 10 | display: none !important; 11 | } 12 | } 13 | 14 | .el-upload__input { 15 | display: none; 16 | } 17 | 18 | 19 | // to fixed https://github.com/ElemeFE/element/issues/2461 20 | .el-dialog { 21 | transform: none; 22 | left: 0; 23 | position: relative; 24 | margin: 0 auto; 25 | } 26 | 27 | // refine element ui upload 28 | .upload-container { 29 | .el-upload { 30 | width: 100%; 31 | 32 | .el-upload-dragger { 33 | width: 100%; 34 | height: 200px; 35 | } 36 | } 37 | } 38 | 39 | // dropdown 40 | .el-dropdown-menu { 41 | a { 42 | display: block 43 | } 44 | } 45 | 46 | // to fix el-date-picker css style 47 | .el-range-separator { 48 | box-sizing: content-box; 49 | } 50 | -------------------------------------------------------------------------------- /src/styles/transition.scss: -------------------------------------------------------------------------------- 1 | // global transition css 2 | 3 | /* fade */ 4 | .fade-enter-active, 5 | .fade-leave-active { 6 | transition: opacity 0.28s; 7 | } 8 | 9 | .fade-enter, 10 | .fade-leave-active { 11 | opacity: 0; 12 | } 13 | 14 | /* fade-transform */ 15 | .fade-transform-leave-active, 16 | .fade-transform-enter-active { 17 | transition: all .25s; 18 | } 19 | 20 | .fade-transform-enter { 21 | opacity: 0; 22 | transform: translateX(-10px); 23 | } 24 | 25 | .fade-transform-leave-to { 26 | opacity: 0; 27 | transform: translateX(10px); 28 | } 29 | 30 | /* breadcrumb transition */ 31 | .breadcrumb-enter-active, 32 | .breadcrumb-leave-active { 33 | transition: all .25s; 34 | } 35 | 36 | .breadcrumb-enter, 37 | .breadcrumb-leave-active { 38 | opacity: 0; 39 | transform: translateX(20px); 40 | } 41 | 42 | .breadcrumb-move { 43 | transition: all .5s; 44 | } 45 | 46 | .breadcrumb-leave-active { 47 | position: absolute; 48 | } 49 | -------------------------------------------------------------------------------- /src/icons/svg/markdown.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "PictureHosting", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build" 8 | }, 9 | "dependencies": { 10 | "axios": "0.24.0", 11 | "core-js": "3.6.5", 12 | "element-ui": "2.13.2", 13 | "normalize.css": "7.0.0", 14 | "nprogress": "0.2.0", 15 | "v-viewer": "1.6.4", 16 | "vue": "2.6.11", 17 | "vue-router": "3.3.4", 18 | "vuex": "3.5.1" 19 | }, 20 | "devDependencies": { 21 | "@vue/cli-plugin-babel": "4.5.0", 22 | "@vue/cli-plugin-router": "4.5.0", 23 | "@vue/cli-plugin-vuex": "4.5.0", 24 | "@vue/cli-service": "4.5.0", 25 | "sass": "1.26.8", 26 | "sass-loader": "8.0.2", 27 | "svg-sprite-loader": "4.1.3", 28 | "svgo": "1.2.2", 29 | "vue-template-compiler": "2.6.11" 30 | }, 31 | "browserslist": [ 32 | "> 1%", 33 | "last 2 versions", 34 | "not dead" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /src/store/modules/app.js: -------------------------------------------------------------------------------- 1 | const state = { 2 | sidebar: { 3 | opened: true, 4 | withoutAnimation: false 5 | }, 6 | device: 'desktop' 7 | } 8 | 9 | const mutations = { 10 | TOGGLE_SIDEBAR: state => { 11 | state.sidebar.opened = !state.sidebar.opened 12 | state.sidebar.withoutAnimation = false 13 | }, 14 | CLOSE_SIDEBAR: (state, withoutAnimation) => { 15 | state.sidebar.opened = false 16 | state.sidebar.withoutAnimation = withoutAnimation 17 | }, 18 | TOGGLE_DEVICE: (state, device) => { 19 | state.device = device 20 | } 21 | } 22 | 23 | const actions = { 24 | toggleSideBar({ commit }) { 25 | commit('TOGGLE_SIDEBAR') 26 | }, 27 | closeSideBar({ commit }, { withoutAnimation }) { 28 | commit('CLOSE_SIDEBAR', withoutAnimation) 29 | }, 30 | toggleDevice({ commit }, device) { 31 | commit('TOGGLE_DEVICE', device) 32 | } 33 | } 34 | 35 | export default { 36 | namespaced: true, 37 | state, 38 | mutations, 39 | actions 40 | } 41 | -------------------------------------------------------------------------------- /src/utils/directive.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | /** 4 | * 防抖 单位时间只触发最后一次 5 | * 例:刷新 6 | * 简写:刷新 7 | */ 8 | Vue.directive('debounce', { 9 | inserted: function (el, binding) { 10 | let [fn, event = "click", time = 300] = binding.value 11 | let timer 12 | el.addEventListener(event, () => { 13 | timer && clearTimeout(timer) 14 | timer = setTimeout(() => fn(), time) 15 | }) 16 | } 17 | }) 18 | 19 | /** 20 | * 节流 每单位时间可触发一次 21 | * 例:刷新 22 | * 传递参数:刷新 23 | */ 24 | Vue.directive('throttle', { 25 | inserted: function (el, binding) { 26 | let [fn, event = "click", time = 300] = binding.value 27 | let now, preTime 28 | el.addEventListener(event, () => { 29 | now = new Date() 30 | if (!preTime || now - preTime > time) { 31 | preTime = now 32 | fn() 33 | } 34 | }) 35 | } 36 | }) 37 | -------------------------------------------------------------------------------- /src/api/github.js: -------------------------------------------------------------------------------- 1 | import request from '@/utils/request' 2 | 3 | // 获取用户信息 4 | export function getUserInfo(token) { 5 | return request({ 6 | url: `/user`, 7 | method: 'GET', 8 | headers: { 9 | Authorization: `token ${token}` 10 | } 11 | }) 12 | } 13 | 14 | // 获取用户仓库列表 15 | export function getUserRepos(name) { 16 | return request({ 17 | url: `/users/${name}/repos`, 18 | method: 'GET' 19 | }) 20 | } 21 | 22 | // 获取用户仓库下指定目录的文件列表 23 | export function getReposContents(name, repos, path) { 24 | return request({ 25 | url: `/repos/${name}/${repos}/contents${path}`, 26 | method: 'GET' 27 | }) 28 | } 29 | 30 | // 删除文件 31 | export function delFile(name, repos, filePath, data) { 32 | return request({ 33 | url: `/repos/${name}/${repos}/contents/${filePath}`, 34 | method: 'DELETE', 35 | data 36 | }) 37 | } 38 | 39 | // 上传文件至仓库指定目录下 40 | export function upload(name, repos, path, fileName, data) { 41 | return request({ 42 | url: `/repos/${name}/${repos}/contents${path}/${fileName}`, 43 | method: 'PUT', 44 | data 45 | }) 46 | } -------------------------------------------------------------------------------- /src/utils/request.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import {Message} from 'element-ui' 3 | import NProgress from 'nprogress' 4 | import 'nprogress/nprogress.css' 5 | 6 | const request = axios.create({ 7 | baseURL: 'https://api.github.com', 8 | //对于较大的文件,可能需要更多的超时时间 9 | timeout: 30000, 10 | }) 11 | 12 | // request interceptor 13 | request.interceptors.request.use(config => { 14 | NProgress.start() 15 | if (/get/i.test(config.method)) { 16 | //get请求添加时间戳防止响应缓存 17 | config.params = config.params || {} 18 | config.params.t = new Date().getTime() 19 | } 20 | const token = localStorage.getItem('githubToken') 21 | if (token) { 22 | config.headers.Authorization = `token ${token}` 23 | } 24 | return config 25 | }, 26 | error => { 27 | console.info(error) 28 | return Promise.reject(error) 29 | } 30 | ) 31 | 32 | // response interceptor 33 | request.interceptors.response.use(response => { 34 | NProgress.done() 35 | const res = response.data 36 | return res 37 | }, 38 | error => { 39 | console.info(error) 40 | Message.error(error.message) 41 | return Promise.reject(error) 42 | } 43 | ) 44 | 45 | export default request -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Naccl (https://naccl.top/) 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. 22 | -------------------------------------------------------------------------------- /src/styles/index.scss: -------------------------------------------------------------------------------- 1 | @import './variables.scss'; 2 | @import './mixin.scss'; 3 | @import './transition.scss'; 4 | @import './element-ui.scss'; 5 | @import './sidebar.scss'; 6 | 7 | body { 8 | height: 100%; 9 | -moz-osx-font-smoothing: grayscale; 10 | -webkit-font-smoothing: antialiased; 11 | text-rendering: optimizeLegibility; 12 | font-family: Helvetica Neue, Helvetica, PingFang SC, Hiragino Sans GB, Microsoft YaHei, Arial, sans-serif; 13 | } 14 | 15 | label { 16 | font-weight: 700; 17 | } 18 | 19 | html { 20 | height: 100%; 21 | box-sizing: border-box; 22 | } 23 | 24 | #app { 25 | height: 100%; 26 | } 27 | 28 | *, 29 | *:before, 30 | *:after { 31 | box-sizing: inherit; 32 | } 33 | 34 | a:focus, 35 | a:active { 36 | outline: none; 37 | } 38 | 39 | a, 40 | a:focus, 41 | a:hover { 42 | cursor: pointer; 43 | color: inherit; 44 | text-decoration: none; 45 | } 46 | 47 | div:focus { 48 | outline: none; 49 | } 50 | 51 | .clearfix { 52 | &:after { 53 | visibility: hidden; 54 | display: block; 55 | font-size: 0; 56 | content: " "; 57 | clear: both; 58 | height: 0; 59 | } 60 | } 61 | 62 | // main-container global css 63 | .app-container { 64 | padding: 20px; 65 | } 66 | -------------------------------------------------------------------------------- /src/layout/components/Navbar.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 30 | 31 | 58 | -------------------------------------------------------------------------------- /src/components/Hamburger/index.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 32 | 33 | 45 | -------------------------------------------------------------------------------- /src/layout/mixin/ResizeHandler.js: -------------------------------------------------------------------------------- 1 | import store from '@/store' 2 | 3 | const { body } = document 4 | const WIDTH = 992 // refer to Bootstrap's responsive design 5 | 6 | export default { 7 | watch: { 8 | $route(route) { 9 | if (this.device === 'mobile' && this.sidebar.opened) { 10 | store.dispatch('app/closeSideBar', { withoutAnimation: false }) 11 | } 12 | } 13 | }, 14 | beforeMount() { 15 | window.addEventListener('resize', this.$_resizeHandler) 16 | }, 17 | beforeDestroy() { 18 | window.removeEventListener('resize', this.$_resizeHandler) 19 | }, 20 | mounted() { 21 | const isMobile = this.$_isMobile() 22 | if (isMobile) { 23 | store.dispatch('app/toggleDevice', 'mobile') 24 | store.dispatch('app/closeSideBar', { withoutAnimation: true }) 25 | } 26 | }, 27 | methods: { 28 | // use $_ for mixins properties 29 | // https://vuejs.org/v2/style-guide/index.html#Private-property-names-essential 30 | $_isMobile() { 31 | const rect = body.getBoundingClientRect() 32 | return rect.width - 1 < WIDTH 33 | }, 34 | $_resizeHandler() { 35 | if (!document.hidden) { 36 | const isMobile = this.$_isMobile() 37 | store.dispatch('app/toggleDevice', isMobile ? 'mobile' : 'desktop') 38 | 39 | if (isMobile) { 40 | store.dispatch('app/closeSideBar', { withoutAnimation: true }) 41 | } 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/components/SvgIcon/index.vue: -------------------------------------------------------------------------------- 1 | 7 | 8 | 47 | 48 | 63 | -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import VueRouter from 'vue-router' 3 | import Layout from '@/layout' 4 | import getPageTitle from '@/utils/get-page-title' 5 | 6 | Vue.use(VueRouter) 7 | 8 | const routes = [ 9 | { 10 | path: '/404', 11 | component: () => import('@/views/404'), 12 | meta: {title: '404 NOT FOUND'}, 13 | hidden: true 14 | }, 15 | { 16 | path: '/', 17 | component: Layout, 18 | redirect: '/dashboard', 19 | children: [ 20 | { 21 | path: 'dashboard', 22 | name: 'Dashboard', 23 | component: () => import('@/views/dashboard/index'), 24 | meta: {title: 'Dashboard', icon: 'dashboard'} 25 | } 26 | ] 27 | }, 28 | { 29 | path: '/pictureHosting', 30 | component: Layout, 31 | redirect: '/pictureHosting/setting', 32 | name: 'PictureHosting', 33 | meta: {title: '图床', icon: 'el-icon-picture'}, 34 | children: [ 35 | { 36 | path: 'setting', 37 | name: 'Setting', 38 | component: () => import('@/views/Setting'), 39 | meta: {title: '配置', icon: 'el-icon-setting'} 40 | }, 41 | { 42 | path: 'manage', 43 | name: 'Manage', 44 | component: () => import('@/views/Manage'), 45 | meta: {title: '管理', icon: 'el-icon-folder-opened'} 46 | }, 47 | { 48 | path: 'help', 49 | name: 'Help', 50 | component: () => import('@/views/Help'), 51 | meta: {title: '教程', icon: 'el-icon-s-opportunity'} 52 | }, 53 | ] 54 | }, 55 | 56 | // 404 page must be placed at the end !!! 57 | {path: '*', redirect: '/404', hidden: true} 58 | ] 59 | 60 | const router = new VueRouter({ 61 | // mode: 'history', 62 | base: process.env.BASE_URL, 63 | routes 64 | }) 65 | 66 | router.beforeEach((to, from, next) => { 67 | document.title = getPageTitle(to.meta.title) 68 | next() 69 | }) 70 | 71 | export default router -------------------------------------------------------------------------------- /src/layout/components/Sidebar/index.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 64 | 65 | -------------------------------------------------------------------------------- /src/layout/components/Sidebar/Logo.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 33 | 34 | 83 | -------------------------------------------------------------------------------- /src/icons/svg/dashboard.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/layout/index.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 52 | 53 | 94 | -------------------------------------------------------------------------------- /src/components/Breadcrumb/index.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 65 | 66 | 79 | -------------------------------------------------------------------------------- /src/views/Setting.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 74 | 75 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const path = require('path') 3 | const defaultSettings = require('./src/settings.js') 4 | 5 | function resolve(dir) { 6 | return path.join(__dirname, dir) 7 | } 8 | 9 | const name = defaultSettings.title // page title 10 | 11 | // If your port is set to 80, 12 | // use administrator privileges to execute the command line. 13 | // For example, Mac: sudo npm run 14 | // You can change the port by the following methods: 15 | // port = 9528 npm run dev OR npm run dev --port = 9528 16 | const port = process.env.port || process.env.npm_config_port || 9001 // dev port 17 | 18 | // All configuration item explanations can be find in https://cli.vuejs.org/config/ 19 | module.exports = { 20 | /** 21 | * You will need to set publicPath if you plan to deploy your site under a sub path, 22 | * for example GitHub Pages. If you plan to deploy your site to https://foo.github.io/bar/, 23 | * then publicPath should be set to "/bar/". 24 | * In most cases please use '/' !!! 25 | * Detail: https://cli.vuejs.org/config/#publicpath 26 | */ 27 | publicPath: '/PictureHosting/', 28 | outputDir: 'dist', 29 | assetsDir: 'static', 30 | lintOnSave: process.env.NODE_ENV === 'development', 31 | productionSourceMap: false, 32 | devServer: { 33 | port: port, 34 | open: true, 35 | overlay: { 36 | warnings: false, 37 | errors: true 38 | }, 39 | }, 40 | configureWebpack: { 41 | // provide the app's title in webpack's name field, so that 42 | // it can be accessed in index.html to inject the correct title. 43 | name: name, 44 | resolve: { 45 | alias: { 46 | '@': resolve('src') 47 | } 48 | } 49 | }, 50 | chainWebpack(config) { 51 | // it can improve the speed of the first screen, it is recommended to turn on preload 52 | config.plugin('preload').tap(() => [ 53 | { 54 | rel: 'preload', 55 | // to ignore runtime.js 56 | // https://github.com/vuejs/vue-cli/blob/dev/packages/@vue/cli-service/lib/config/app.js#L171 57 | fileBlacklist: [/\.map$/, /hot-update\.js$/, /runtime\..*\.js$/], 58 | include: 'initial' 59 | } 60 | ]) 61 | 62 | // when there are many pages, it will cause too many meaningless requests 63 | config.plugins.delete('prefetch') 64 | 65 | // set svg-sprite-loader 66 | config.module 67 | .rule('svg') 68 | .exclude.add(resolve('src/icons')) 69 | .end() 70 | config.module 71 | .rule('icons') 72 | .test(/\.svg$/) 73 | .include.add(resolve('src/icons')) 74 | .end() 75 | .use('svg-sprite-loader') 76 | .loader('svg-sprite-loader') 77 | .options({ 78 | symbolId: 'icon-[name]' 79 | }) 80 | .end() 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/views/Help.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 84 | 85 | -------------------------------------------------------------------------------- /src/layout/components/Sidebar/SidebarItem.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 96 | -------------------------------------------------------------------------------- /src/utils/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Created by PanJiaChen on 16/11/18. 3 | */ 4 | 5 | /** 6 | * Parse the time to string 7 | * @param {(Object|string|number)} time 8 | * @param {string} cFormat 9 | * @returns {string | null} 10 | */ 11 | export function parseTime(time, cFormat) { 12 | if (arguments.length === 0 || !time) { 13 | return null 14 | } 15 | const format = cFormat || '{y}-{m}-{d} {h}:{i}:{s}' 16 | let date 17 | if (typeof time === 'object') { 18 | date = time 19 | } else { 20 | if ((typeof time === 'string')) { 21 | if ((/^[0-9]+$/.test(time))) { 22 | // support "1548221490638" 23 | time = parseInt(time) 24 | } else { 25 | // support safari 26 | // https://stackoverflow.com/questions/4310953/invalid-date-in-safari 27 | time = time.replace(new RegExp(/-/gm), '/') 28 | } 29 | } 30 | 31 | if ((typeof time === 'number') && (time.toString().length === 10)) { 32 | time = time * 1000 33 | } 34 | date = new Date(time) 35 | } 36 | const formatObj = { 37 | y: date.getFullYear(), 38 | m: date.getMonth() + 1, 39 | d: date.getDate(), 40 | h: date.getHours(), 41 | i: date.getMinutes(), 42 | s: date.getSeconds(), 43 | a: date.getDay() 44 | } 45 | const time_str = format.replace(/{([ymdhisa])+}/g, (result, key) => { 46 | const value = formatObj[key] 47 | // Note: getDay() returns 0 on Sunday 48 | if (key === 'a') { return ['日', '一', '二', '三', '四', '五', '六'][value ] } 49 | return value.toString().padStart(2, '0') 50 | }) 51 | return time_str 52 | } 53 | 54 | /** 55 | * @param {number} time 56 | * @param {string} option 57 | * @returns {string} 58 | */ 59 | export function formatTime(time, option) { 60 | if (('' + time).length === 10) { 61 | time = parseInt(time) * 1000 62 | } else { 63 | time = +time 64 | } 65 | const d = new Date(time) 66 | const now = Date.now() 67 | 68 | const diff = (now - d) / 1000 69 | 70 | if (diff < 30) { 71 | return '刚刚' 72 | } else if (diff < 3600) { 73 | // less 1 hour 74 | return Math.ceil(diff / 60) + '分钟前' 75 | } else if (diff < 3600 * 24) { 76 | return Math.ceil(diff / 3600) + '小时前' 77 | } else if (diff < 3600 * 24 * 2) { 78 | return '1天前' 79 | } 80 | if (option) { 81 | return parseTime(time, option) 82 | } else { 83 | return ( 84 | d.getMonth() + 85 | 1 + 86 | '月' + 87 | d.getDate() + 88 | '日' + 89 | d.getHours() + 90 | '时' + 91 | d.getMinutes() + 92 | '分' 93 | ) 94 | } 95 | } 96 | 97 | /** 98 | * @param {string} url 99 | * @returns {Object} 100 | */ 101 | export function param2Obj(url) { 102 | const search = decodeURIComponent(url.split('?')[1]).replace(/\+/g, ' ') 103 | if (!search) { 104 | return {} 105 | } 106 | const obj = {} 107 | const searchArr = search.split('&') 108 | searchArr.forEach(v => { 109 | const index = v.indexOf('=') 110 | if (index !== -1) { 111 | const name = v.substring(0, index) 112 | const val = v.substring(index + 1, v.length) 113 | obj[name] = val 114 | } 115 | }) 116 | return obj 117 | } 118 | -------------------------------------------------------------------------------- /src/styles/sidebar.scss: -------------------------------------------------------------------------------- 1 | #app { 2 | 3 | .main-container { 4 | min-height: 100%; 5 | transition: margin-left .28s; 6 | margin-left: $sideBarWidth; 7 | position: relative; 8 | } 9 | 10 | .sidebar-container { 11 | transition: width 0.28s; 12 | width: $sideBarWidth !important; 13 | background-color: $menuBg; 14 | height: 100%; 15 | position: fixed; 16 | font-size: 0px; 17 | top: 0; 18 | bottom: 0; 19 | left: 0; 20 | z-index: 1001; 21 | overflow: hidden; 22 | 23 | // reset element-ui css 24 | .horizontal-collapse-transition { 25 | transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out; 26 | } 27 | 28 | .scrollbar-wrapper { 29 | overflow-x: hidden !important; 30 | } 31 | 32 | .el-scrollbar__bar.is-vertical { 33 | right: 0px; 34 | } 35 | 36 | .el-scrollbar { 37 | height: 100%; 38 | } 39 | 40 | &.has-logo { 41 | .el-scrollbar { 42 | height: calc(100% - 50px); 43 | } 44 | } 45 | 46 | .is-horizontal { 47 | display: none; 48 | } 49 | 50 | a { 51 | display: inline-block; 52 | width: 100%; 53 | overflow: hidden; 54 | } 55 | 56 | .svg-icon { 57 | margin-left: -2px; 58 | margin-right: 12px; 59 | font-size: 18px; 60 | //margin-right: 16px; 61 | } 62 | 63 | .sub-el-icon { 64 | margin-right: 12px; 65 | margin-left: -2px; 66 | } 67 | 68 | .el-menu { 69 | border: none; 70 | height: 100%; 71 | width: 100% !important; 72 | } 73 | 74 | // menu hover 75 | .submenu-title-noDropdown, 76 | .el-submenu__title { 77 | &:hover { 78 | background-color: $menuHover !important; 79 | } 80 | } 81 | 82 | .is-active>.el-submenu__title { 83 | color: $subMenuActiveText !important; 84 | } 85 | 86 | & .nest-menu .el-submenu>.el-submenu__title, 87 | & .el-submenu .el-menu-item { 88 | min-width: $sideBarWidth !important; 89 | background-color: $subMenuBg !important; 90 | 91 | &:hover { 92 | background-color: $subMenuHover !important; 93 | } 94 | } 95 | } 96 | 97 | .hideSidebar { 98 | .sidebar-container { 99 | width: 54px !important; 100 | } 101 | 102 | .main-container { 103 | margin-left: 54px; 104 | } 105 | 106 | .submenu-title-noDropdown { 107 | padding: 0 !important; 108 | position: relative; 109 | 110 | .el-tooltip { 111 | padding: 0 !important; 112 | 113 | .svg-icon { 114 | margin-left: 20px; 115 | } 116 | 117 | .sub-el-icon { 118 | margin-left: 19px; 119 | } 120 | } 121 | } 122 | 123 | .el-submenu { 124 | overflow: hidden; 125 | 126 | &>.el-submenu__title { 127 | padding: 0 !important; 128 | 129 | .svg-icon { 130 | margin-left: 20px; 131 | } 132 | 133 | .sub-el-icon { 134 | margin-left: 19px; 135 | } 136 | 137 | .el-submenu__icon-arrow { 138 | display: none; 139 | } 140 | } 141 | } 142 | 143 | .el-menu--collapse { 144 | .el-submenu { 145 | &>.el-submenu__title { 146 | &>span { 147 | height: 0; 148 | width: 0; 149 | overflow: hidden; 150 | visibility: hidden; 151 | display: inline-block; 152 | } 153 | } 154 | } 155 | } 156 | } 157 | 158 | .el-menu--collapse .el-menu .el-submenu { 159 | min-width: $sideBarWidth !important; 160 | } 161 | 162 | // mobile responsive 163 | .mobile { 164 | .main-container { 165 | margin-left: 0px; 166 | } 167 | 168 | .sidebar-container { 169 | transition: transform .28s; 170 | width: $sideBarWidth !important; 171 | } 172 | 173 | &.hideSidebar { 174 | .sidebar-container { 175 | pointer-events: none; 176 | transition-duration: 0.3s; 177 | transform: translate3d(-$sideBarWidth, 0, 0); 178 | } 179 | } 180 | } 181 | 182 | .withoutAnimation { 183 | 184 | .main-container, 185 | .sidebar-container { 186 | transition: none; 187 | } 188 | } 189 | } 190 | 191 | // when menu collapsed 192 | .el-menu--vertical { 193 | &>.el-menu { 194 | .svg-icon { 195 | margin-right: 16px; 196 | } 197 | .sub-el-icon { 198 | margin-right: 12px; 199 | margin-left: -2px; 200 | } 201 | } 202 | 203 | .nest-menu .el-submenu>.el-submenu__title, 204 | .el-menu-item { 205 | &:hover { 206 | // you can use $subMenuHover 207 | background-color: $menuHover !important; 208 | } 209 | } 210 | 211 | // the scroll bar appears when the subMenu is too long 212 | >.el-menu--popup { 213 | max-height: 100vh; 214 | overflow-y: auto; 215 | 216 | &::-webkit-scrollbar-track-piece { 217 | background: #d3dce6; 218 | } 219 | 220 | &::-webkit-scrollbar { 221 | width: 6px; 222 | } 223 | 224 | &::-webkit-scrollbar-thumb { 225 | background: #99a9bf; 226 | border-radius: 20px; 227 | } 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/views/404.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 34 | 35 | 243 | -------------------------------------------------------------------------------- /src/views/Manage.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 257 | 258 | 272 | 273 | --------------------------------------------------------------------------------