Add files via upload

This commit is contained in:
Mujinniao
2022-11-21 17:02:50 +08:00
committed by GitHub
parent 79e861eca1
commit 287f5058c7
96 changed files with 6654 additions and 0 deletions

74
picx/src/App.vue Normal file
View File

@@ -0,0 +1,74 @@
<template>
<el-config-provider :size="size" :z-index="zIndex" :locale="locale">
<main-container />
</el-config-provider>
</template>
<script lang="ts">
import { defineComponent, onMounted, reactive, toRefs } from 'vue'
import { ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/lib/locale/lang/zh-cn'
import mainContainer from '@/components/main-container/main-container.vue'
import setTheme from '@/utils/set-theme-mode'
import { useStore } from '@/store'
export default defineComponent({
name: 'App',
components: {
ElConfigProvider,
mainContainer
},
setup() {
const store = useStore()
const data = reactive({
zIndex: 3000,
size: 'small', // large | default | small
locale: zhCn
})
const elementPlusSizeHandle = (width: number) => {
if (width <= 600) {
store.dispatch('SET_USER_SETTINGS', {
elementPlusSize: 'small'
})
data.size = 'small'
} else if (width <= 800) {
store.dispatch('SET_USER_SETTINGS', {
elementPlusSize: 'default'
})
data.size = 'default'
} else {
store.dispatch('SET_USER_SETTINGS', {
elementPlusSize: 'large'
})
data.size = 'large'
}
}
onMounted(() => {
setTheme()
elementPlusSizeHandle(window.innerWidth)
window.addEventListener('resize', (e: any) => {
elementPlusSizeHandle(e.target.innerWidth)
})
})
return {
...toRefs(data)
}
}
})
</script>
<style lang="stylus">
#app {
font-family Avenir, Helvetica, Arial, sans-serif
-webkit-font-smoothing antialiased
-moz-osx-font-smoothing grayscale
box-sizing border-box
position relative
width 100%
height 100%
}
</style>

BIN
picx/src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@@ -0,0 +1,82 @@
import { computed } from 'vue'
import axios from '@/utils/axios'
import { store } from '@/store'
import { getFileSuffix, isImage } from '@/utils/file-handle-helper'
import structureImageObject from '@/utils/image-helper'
const userConfigInfo = computed(() => store.getters.getUserConfigInfo).value
/**
* 获取指定路径path下的目录列表
* @param path 路径
*/
export const getDirListByPath = (path: string = '') => {
return new Promise((resolve) => {
axios
.get(
`/repos/${userConfigInfo.owner}/${userConfigInfo.selectedRepos}/contents/${path}`,
{
params: {
ref: userConfigInfo.selectedBranch
}
}
)
.then((res: any) => {
if (res && res.status === 200 && res.data.length > 0) {
resolve(
res.data
.filter((v: any) => v.type === 'dir')
.map((x: any) => ({
value: x.name,
label: x.name
}))
)
} else {
resolve(null)
}
})
.catch(() => {
resolve(null)
})
})
}
/**
* 获取指定路径path下的目录和图片
* @param path
*/
export const getContentByReposPath = (path: string = '') => {
return new Promise((resolve) => {
axios
.get(
`/repos/${userConfigInfo.owner}/${userConfigInfo.selectedRepos}/contents/${path}`,
{
params: {
ref: userConfigInfo.selectedBranch
}
}
)
.then((res: any) => {
if (res && res.status === 200 && res.data.length > 0) {
res.data
.filter((v: any) => v.type === 'dir')
.forEach((x: any) => store.dispatch('DIR_IMAGE_LIST_ADD_DIR', x.path))
setTimeout(() => {
res.data
.filter((v: any) => v.type === 'file' && isImage(getFileSuffix(v.name)))
.forEach((x: any) =>
store.dispatch('DIR_IMAGE_LIST_ADD_IMAGE', structureImageObject(x, path))
)
}, 100)
resolve(true)
} else {
resolve(null)
}
})
.catch(() => {
resolve(null)
})
})
}

View File

@@ -0,0 +1,6 @@
/* eslint-disable import/prefer-default-export */
export enum deleteStatusEnum {
deleted = 'deleted',
allDeleted = 'allDeleted',
deleteFail = 'deleteFail'
}

View File

@@ -0,0 +1,11 @@
export interface DirModel {
value: string
label: string
}
export enum DirModeEnum {
autoDir = 'autoDir',
newDir = 'newDir',
rootDir = 'rootDir',
reposDir = 'reposDir'
}

View File

@@ -0,0 +1,8 @@
enum ExternalLinkType {
staticaly = 'staticaly',
jsdelivr = 'jsdelivr',
github = 'github',
cloudflare = 'cloudflare'
}
export default ExternalLinkType

View File

@@ -0,0 +1,6 @@
const PICX_PREFIX = 'PICX_'
export const PICX_CONFIG = `${PICX_PREFIX}CONFIG`
export const PICX_UPLOADED = `${PICX_PREFIX}UPLOADED`
export const PICX_MANAGEMENT = `${PICX_PREFIX}MANAGEMENT_MULTI`
export const PICX_SETTINGS = `${PICX_PREFIX}SETTINGS`

View File

@@ -0,0 +1,68 @@
export enum UploadStatusEnum {
// eslint-disable-next-line no-unused-vars
uploaded = 'uploaded',
// eslint-disable-next-line no-unused-vars
allUploaded = 'allUploaded',
// eslint-disable-next-line no-unused-vars
uploadFail = 'uploadFail'
}
export interface UploadedImageModel {
type: string
uuid: string
sha: string
dir: string
path: string
name: string
size: any
deleting: boolean
is_transform_md: boolean
checked: boolean
github_url: string
jsdelivr_cdn_url: string
staticaly_cdn_url: string
cloudflare_cdn_url: string
}
export interface ToUploadImageModel {
uuid: string
uploadStatus: {
progress: number
uploading: boolean
}
imgData: {
base64Content: string
base64Url: string
}
fileInfo: {
compressedSize?: number | undefined
originSize?: number | undefined
size: number | undefined
lastModified: number | undefined
}
filename: {
name: string
hash: string
suffix: string
prefixName: string
now: string
initName: string
newName: string
isHashRename: boolean
isRename: boolean
isPrefix: boolean
}
externalLink: {
github: string
jsdelivr: string
staticaly: string
cloudflare: string
}
uploadedImg?: UploadedImageModel
}

View File

@@ -0,0 +1,35 @@
import { DirModeEnum, DirModel } from './dir.model'
export interface ReposModel {
value: string
label: string
desc?: string
}
export interface BranchModel {
value: string
label: string
}
export enum BranchModeEnum {
newBranch = 'newBranch',
reposBranch = 'reposBranch'
}
export interface UserConfigInfoModel {
token: string
owner: string
email: string
name: string
avatarUrl: string
selectedRepos: string
reposList: ReposModel[]
selectedBranch: string
branchMode: BranchModeEnum
branchList: BranchModel[]
dirMode: DirModeEnum
selectedDir: string
selectedDirList: string[]
dirList: DirModel[]
loggingStatus: boolean
}

View File

@@ -0,0 +1,15 @@
import { CompressEncoderMap } from '../../utils/compress'
import ExternalLinkType from '@/common/model/external-link.model'
export interface UserSettingsModel {
defaultHash: boolean
defaultMarkdown: boolean
defaultPrefix: boolean
prefixName: string
themeMode: 'auto' | 'light' | 'dark'
autoLightThemeTime: string[]
isCompress: boolean
compressEncoder: CompressEncoderMap
elementPlusSize: 'large' | 'default' | 'small'
externalLinkType: ExternalLinkType
}

View File

@@ -0,0 +1,11 @@
export declare type Recordable<T = any> = Record<string, T>
export declare interface ViteEnv {
VITE_PORT?: number
VITE_USE_PWA?: boolean
VITE_PUBLIC_PATH?: string
VITE_GLOB_APP_TITLE?: string
VITE_GLOB_APP_SHORT_NAME?: string
VITE_OPEN_BROWSER?: boolean
VITE_CORS?: boolean
}

View File

@@ -0,0 +1,62 @@
@import "../../style/base.styl"
.copy-external-link-box {
position relative
width 100%
height 100%
box-sizing border-box
display flex
justify-content space-between
align-items flex-end
padding 2rem
.markdown-icon-box {
width 26rem
height 20rem
position relative
cursor pointer
.markdown-icon {
path {
fill var(--markdown-icon-color)
}
&.active {
path {
fill var(--markdown-icon-active-color)
}
}
}
}
.btn-box {
position relative
box-sizing border-box
height 100%
display flex
justify-content space-between
.btn-item {
height 20rem
box-sizing border-box
border-radius 5rem
font-size 12rem
cursor pointer
transition all 0.3s ease
}
.copy-url {
box-sizing border-box
border 1rem solid var(--default-text-color)
color var(--default-text-color)
padding 1rem 2rem
margin-right 4rem
&:hover {
background var(--default-text-color)
color var(--background-color)
}
}
}
}

View File

@@ -0,0 +1,104 @@
<template>
<div class="copy-external-link-box">
<div>
<el-tooltip
:content="img.is_transform_md ? '点击转换普通外链' : '点击转换 Markdown 格式外链'"
placement="top"
>
<div
class="markdown-icon-box flex-center"
@click="img.is_transform_md = !img.is_transform_md"
>
<svg
v-if="!img.is_transform_md"
t="1631782798077"
class="markdown-icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="2861"
width="26"
height="26"
>
<path
d="M92 192C42.24 192 0 232.128 0 282.016v459.968C0 791.904 42.24 832 92 832h840C981.76 832 1024 791.872 1024 741.984V282.016C1024 232.16 981.76 192 932 192z m0 64h840c16.512 0 28 12.256 28 26.016v459.968c0 13.76-11.52 26.016-28 26.016H92C75.488 768 64 755.744 64 741.984V282.016c0-13.76 11.52-25.984 28-25.984zM160 352v320h96v-212.992l96 127.008 96-127.04V672h96V352h-96l-96 128-96-128z m544 0v160h-96l144 160 144-160h-96v-160z"
p-id="2862"
fill="#808080"
></path>
</svg>
<svg
v-if="img.is_transform_md"
t="1631784688556"
class="markdown-icon active"
viewBox="0 0 1280 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="3242"
width="26"
height="26"
>
<path
d="M1187.6 118.2H92.4C41.4 118.2 0 159.6 0 210.4v603c0 51 41.4 92.4 92.4 92.4h1095.4c51 0 92.4-41.4 92.2-92.2V210.4c0-50.8-41.4-92.2-92.4-92.2zM677 721.2H554v-240l-123 153.8-123-153.8v240H184.6V302.8h123l123 153.8 123-153.8h123v418.4z m270.6 6.2L763 512H886V302.8h123V512H1132z"
p-id="3243"
fill="#3c3c3c"
></path>
</svg>
</div>
</el-tooltip>
</div>
<div class="btn-box">
<el-tooltip content="点击复制 Staticaly CDN 外链" placement="top">
<span
class="btn-item copy-url flex-center"
@click="copyLink(externalLinkType.staticaly)"
>
Staticaly
</span>
</el-tooltip>
<el-tooltip content="点击复制 Cloudflare CDN 外链" placement="top">
<span
class="btn-item copy-url flex-center"
@click="copyLink(externalLinkType.cloudflare)"
>
Cloudflare
</span>
</el-tooltip>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, computed, ref, onUpdated } from 'vue'
import ExternalLinkType from '@/common/model/external-link.model'
import { store } from '@/store'
import { copyExternalLink } from '@/utils/external-link-handler'
import { UploadedImageModel } from '@/common/model/upload.model'
const props = defineProps({
imgObj: {
type: Object,
default: () => {}
}
})
const userSettings = computed(() => store.getters.getUserSettings).value
let img = ref(props.imgObj as UploadedImageModel).value
const externalLinkType = ExternalLinkType
const copyLink = (type: ExternalLinkType) => {
copyExternalLink(img, type)
}
onUpdated(() => {
img = props.imgObj
})
onMounted(() => {
img.is_transform_md = userSettings.defaultMarkdown
})
</script>
<style scoped lang="stylus">
@import "copy-external-link.styl"
</style>

View File

@@ -0,0 +1,47 @@
@import '../../style/base.styl'
.folder-card {
position relative
width 110rem
height 106rem
display flex
align-items center
flex-direction column
justify-content flex-start
cursor pointer
box-sizing border-box
padding 3rem
user-select none
&:hover {
background var(--second-background-color)
}
.icon {
display flex
align-items center
justify-content center
width 50rem
height 50rem
svg {
width 100%
height 100%
}
}
.text {
width 90%
font-size 14rem
margin-top 5rem
text-align center
overflow hidden
text-overflow ellipsis
display -webkit-box
-webkit-box-orient vertical
-webkit-line-clamp 2
word-wrap break-word
word-break break-all
}
}

View File

@@ -0,0 +1,116 @@
<template>
<div class="folder-card" @dblclick="dblclickFolder">
<el-tooltip
v-if="mode === 'dir'"
effect="dark"
content="双击进入下一级目录"
placement="top"
>
<div class="icon">
<svg
t="1639999626518"
class="icon"
viewBox="0 0 1228 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="3575"
width="200"
height="200"
>
<path
d="M1196.987733 212.5824v540.0576c0 39.594667-34.474667 71.3728-76.765866 71.3728H323.242667c-51.780267 0-88.746667-46.762667-73.250134-92.808533l126.737067-375.808H70.417067C31.675733 355.362133 0 326.4512 0 291.089067V98.372267C0 63.044267 31.675733 34.0992 70.417067 34.0992h378.811733c26.7264 0 51.029333 13.9264 63.010133 35.703467l39.048534 71.406933H1120.256c42.257067 0 76.8 32.119467 76.8 71.3728"
fill="#5398DF"
p-id="3576"
></path>
<path
d="M1128.721067 997.853867H68.266667a68.266667 68.266667 0 0 1-68.266667-68.266667V280.3712a68.266667 68.266667 0 0 1 68.266667-68.266667h1060.4544a68.266667 68.266667 0 0 1 68.266666 68.266667V929.5872a68.266667 68.266667 0 0 1-68.266666 68.266667"
fill="#85BCFF"
p-id="3577"
></path>
</svg>
</div>
</el-tooltip>
<div class="icon" v-if="mode === 'back'">
<svg
t="1640264285200"
class="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
p-id="29312"
width="200"
height="200"
>
<path
d="M426.666667 384V213.333333l-298.666667 298.666667 298.666667 298.666667v-174.933334c213.333333 0 362.666667 68.266667 469.333333 217.6-42.666667-213.333333-170.666667-426.666667-469.333333-469.333333z"
p-id="29313"
fill="#85BCFF"
></path>
</svg>
</div>
<div class="text" v-if="mode === 'dir'">{{ folderObj.dir }}</div>
<div class="text" v-if="mode === 'back'">双击返回</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useStore } from '@/store'
const store = useStore()
const userConfigInfo = computed(() => store.getters.getUserConfigInfo).value
// eslint-disable-next-line no-undef
const props = defineProps({
folderObj: {
type: Object,
default: () => {}
},
mode: {
type: String,
default: 'dir'
}
})
const emits = defineEmits(['update:modelValue'])
const dblclickFolder = () => {
const { folderObj, mode } = props
if (mode === 'back') {
const currentDir = userConfigInfo.selectedDir
if (currentDir === '/') {
return
}
const currentDirList = currentDir.split('/')
if (currentDirList.length === 1) {
userConfigInfo.selectedDir = '/'
} else if (currentDirList.length > 1) {
currentDirList.length -= 1
userConfigInfo.selectedDir = currentDirList.join('/')
}
} else {
userConfigInfo.selectedDir = folderObj.dirPath
}
const { selectedDir } = userConfigInfo
if (selectedDir === '/') {
userConfigInfo.selectedDirList = []
userConfigInfo.dirMode = 'rootDir'
} else {
userConfigInfo.selectedDirList = selectedDir.split('/')
userConfigInfo.dirMode = 'reposDir'
}
store.dispatch('USER_CONFIG_INFO_NOT_PERSIST')
}
</script>
<style scoped lang="stylus">
@import 'folder-card.styl';
</style>

View File

@@ -0,0 +1,92 @@
@import "../../style/base.styl"
.header {
width 100%
height 100%
background var(--background-color)
padding 0 20rem
box-sizing border-box
display flex
justify-content space-between
align-items center
.header-left {
height 100%
display flex
justify-content flex-start
.brand {
height 100%
display flex
justify-content flex-start
align-items center
cursor pointer
.logo {
width 46rem
height 46rem
margin-right 10rem
img {
width 100%
}
}
.title {
font-size 36rem
font-weight bold
}
}
.website-count {
box-sizing border-box
display flex
align-items flex-end
font-size 14rem
margin-left 10rem
padding-bottom 12rem
cursor pointer
+picx-mobile() {
display none
}
}
}
.header-right {
.user-info {
display: flex;
align-items: center;
cursor: pointer;
.username {
font-size: 16rem;
}
.avatar {
display: flex;
justify-content: center;
align-items: center;
width: 38rem;
height: 38rem;
color: var(--default-text-color);
border-radius: 50%;
border: 1rem solid var(--default-text-color);
margin-left: 10rem;
padding: 1rem;
box-sizing: border-box;
img {
width: 100%;
height: 100%;
border-radius: 50%;
}
}
}
}
}

View File

@@ -0,0 +1,122 @@
<template>
<header class="header">
<div class="header-left">
<div class="brand" @click="router.push('/')">
<div class="logo">
<img src="../../assets/logo.png" alt="PicX" />
</div>
<div class="title">PicX</div>
</div>
<div class="website-count" @click="goGitHubRepo">
<el-tooltip content="感觉好用,点 Star 支持作者(* ̄︶ ̄)" placement="bottom">
<site-count :isuv="false" />
</el-tooltip>
</div>
</div>
<div class="header-right">
<div class="user-info" @click="onUserInfoClick">
<div class="username">
{{ userConfigInfo.owner ? userConfigInfo.owner : defaultUsername }}
</div>
<div class="avatar" v-if="!userConfigInfo?.avatarUrl">
<el-icon :size="22"><UserFilled /></el-icon>
</div>
<el-dropdown
trigger="click"
@command="handleCommand"
v-if="userConfigInfo?.avatarUrl"
>
<span class="el-dropdown-link">
<span class="avatar">
<img :src="userConfigInfo?.avatarUrl" :alt="userConfigInfo?.owner" />
</span>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout"> 退出登录 </el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</header>
</template>
<script lang="ts">
import { defineComponent, reactive, computed, toRefs } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from '@/store'
import siteCount from '@/components/site-count/site-count.vue'
export default defineComponent({
name: 'header-content',
components: {
siteCount
},
setup() {
const router = useRouter()
const store = useStore()
const reactiveData = reactive({
defaultUsername: '未登录',
userConfigInfo: computed(() => store.state.userConfigInfoModule.userConfigInfo)
})
const onUserInfoClick = () => {
if (
!reactiveData.userConfigInfo.loggingStatus &&
router.currentRoute.value.path !== '/config'
) {
router.push('/config')
}
}
const logout = () => {
store.dispatch('LOGOUT')
router.push('/config')
}
const handleCommand = (command: string) => {
// eslint-disable-next-line default-case
switch (command) {
case 'upload':
router.push('/')
break
case 'config':
router.push('/config')
break
case 'management':
router.push('/management')
break
case 'logout':
logout()
break
}
}
const goGitHubRepo = () => {
window.open('https://github.com/XPoet/picx')
}
return {
...toRefs(reactiveData),
router,
onUserInfoClick,
handleCommand,
goGitHubRepo
}
}
})
</script>
<style scoped lang="stylus">
@import "header-content.styl"
</style>

View File

@@ -0,0 +1,142 @@
@import "../../style/base.styl"
$infoBoxHeight = 56rem
.image-card {
position relative
width 100%
height 100%
box-shadow 1rem 2rem 3rem var(--shadow-color)
box-sizing border-box
padding-bottom $infoBoxHeight
user-select none
&.checked, &:hover {
box-shadow 0 0 10rem #666
}
&.listing {
display flex
justify-content flex-start
align-items center
padding 5rem
border-radius $box-border-radius
.image-box {
height 45rem
width 45rem
}
.info-box {
position relative
width 80%
}
:deep(.el-loading-mask) {
.el-loading-spinner {
margin-top -25rem
.circular {
height 24rem
width 24rem
}
.el-loading-text {
margin 0
}
}
}
}
.image-box {
position relative
width 100%
height 100%
img {
width 100%
height 100%
object-fit cover
cursor pointer
}
}
.info-box {
width 100%
height $infoBoxHeight
position absolute
bottom 0
left 0
.image-info {
width 100%
height 100%
padding 5rem
color var(--default-text-color)
box-sizing border-box
display flex
flex-direction column
justify-content space-between
.rename-input {
height 20rem
display flex
margin-bottom 4rem
}
.filename {
height 16rem
overflow hidden
text-overflow ellipsis
white-space nowrap
font-size 14rem
margin-bottom 6rem
}
}
}
.operation-box {
position absolute
top 10rem
right 8rem
width calc(100% - 16rem)
display flex
justify-content space-between
.operation-left {
.picked-btn {
i {
font-weight bold
}
}
}
.operation-right {
display flex
}
.operation-btn {
width 32rem
height 32rem
border-radius 50%
box-shadow 0 0 6rem #555
cursor pointer
background var(--background-color)
display flex
justify-content center
align-items center
margin-right 8rem
font-size 18rem
&:last-child {
margin-right 0
}
}
}
}

View File

@@ -0,0 +1,287 @@
<template>
<div
class="image-card"
:class="{ listing: listing, checked: imageObj.checked }"
v-loading="imageObj.deleting"
element-loading-text="删除中..."
@mouseenter="isShowDelBtn = true"
@mouseleave="isShowDelBtn = false"
>
<div class="image-box">
<img data-fancybox="gallery" :src="imgUrl" />
</div>
<div class="info-box">
<div class="image-info">
<el-input
size="small"
v-if="renameValue && props.modelValue === props.index"
class="rename-input"
v-model="renameValue"
@blur="renameInputBlur"
@keydown.enter.prevent="updateRename"
ref="renameInputRef"
></el-input>
<div class="filename" v-else>{{ imageObj.name }}</div>
<div class="image-operation">
<copy-external-link :img-obj="imageObj" />
</div>
</div>
</div>
<div
class="operation-box"
v-show="isShowDelBtn || dropdownVisible || imageObj.checked"
>
<div class="operation-left">
<div
v-if="isManagementPage"
:class="[imageObj.checked ? 'picked-btn' : 'pick-btn', 'operation-btn']"
@click="trigglePick(imageObj)"
>
<el-icon v-if="imageObj.checked"><Check /></el-icon>
</div>
</div>
<div class="operation-right">
<el-dropdown size="default" trigger="click" @visible-change="visibleChange">
<div class="operation-btn">
<el-icon><MoreFilled /></el-icon>
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="deleteImageTips(imageObj)">
删除
</el-dropdown-item>
<el-dropdown-item @click.self="renameImage(imageObj)">
重命名
</el-dropdown-item>
<el-dropdown-item @click="viewImageProperties(imageObj)">
属性
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import type { ElInput } from 'element-plus'
import { useRoute } from 'vue-router'
import { useStore } from '@/store'
import axios from '@/utils/axios'
import { UploadedImageModel } from '@/common/model/upload.model'
import { getBase64ByImageUrl, getImage } from '@/utils/rename-image'
import { uploadImage_single } from '@/utils/upload-helper'
import { getFilename, getFileSize, getFileSuffix } from '@/utils/file-handle-helper'
import ExternalLinkType from '@/common/model/external-link.model'
const props = defineProps({
listing: {
type: Boolean,
default: false
},
imageObj: {
type: Object,
default: () => {}
},
isUploaded: {
type: Boolean,
default: false
},
index: {
type: Number
},
modelValue: {
type: Number
}
})
const emits = defineEmits(['update:modelValue'])
const store = useStore()
const router = useRoute()
const userConfigInfo = computed(() => store.getters.getUserConfigInfo).value
const userSettings = computed(() => store.getters.getUserSettings).value
const isManagementPage = computed(() => {
return router.path === '/management'
})
const imgUrl = computed(() => {
switch (userSettings.externalLinkType) {
case ExternalLinkType.jsdelivr:
return props.imageObj.jsdelivr_cdn_url
case ExternalLinkType.staticaly:
return props.imageObj.staticaly_cdn_url
case ExternalLinkType.cloudflare:
return props.imageObj.cloudflare_cdn_url
default:
return props.imageObj.github_url
}
})
const renameInputRef = ref<InstanceType<typeof ElInput>>()
const isShowDelBtn = ref(false)
const renameValue = ref('')
const dropdownVisible = ref<Boolean>(false)
const doDeleteImage = (
imageObj: UploadedImageModel,
isRename: boolean = false
): Promise<Boolean> => {
if (!isRename) {
// eslint-disable-next-line no-param-reassign
imageObj.deleting = true
}
const { owner, selectedRepos } = userConfigInfo
return new Promise((resolve) => {
axios
.delete(`/repos/${owner}/${selectedRepos}/contents/${imageObj.path}`, {
data: {
owner,
repo: selectedRepos,
path: imageObj.path,
message: 'Delete picture via PicX(https://github.com/XPoet/picx)',
sha: imageObj.sha
}
})
.then((res) => {
console.log('[deleteImage] ', res)
if (res && res.status === 200) {
// eslint-disable-next-line no-param-reassign
imageObj.deleting = false
// eslint-disable-next-line no-unused-expressions
ElMessage.success(`${isRename ? '更新' : '删除'}成功!`)
store.dispatch('UPLOADED_LIST_REMOVE', imageObj.uuid)
store.dispatch('DIR_IMAGE_LIST_REMOVE', imageObj)
resolve(true)
} else {
// eslint-disable-next-line no-param-reassign
imageObj.deleting = false
}
})
})
}
const deleteImageTips = (imageObj: UploadedImageModel) => {
ElMessageBox.confirm(
`
<div>此操作将会永久删除图片:</div>
<strong>${imageObj.name}</strong>
`,
`删除提示`,
{
dangerouslyUseHTMLString: true,
type: 'warning'
}
)
.then(() => {
doDeleteImage(imageObj)
})
.catch(() => {
console.log('取消删除')
})
}
const trigglePick = (imageObj: UploadedImageModel) => {
// eslint-disable-next-line no-param-reassign
imageObj.checked = !imageObj.checked
store.commit('IMAGE_CARD', { imageObj })
}
const renameImage = async (imgObj: UploadedImageModel) => {
emits('update:modelValue', props.index)
renameValue.value = getFilename(imgObj.name)
await nextTick(() => {
const temp = setTimeout(() => {
renameInputRef.value?.focus()
clearTimeout(temp)
}, 150)
})
}
const renameInputBlur = () => {
emits('update:modelValue', undefined)
}
const updateRename = async () => {
renameInputBlur()
const { imageObj }: any = props
if (renameValue.value === getFilename(imageObj.name) || !renameValue.value) {
return
}
const renameFn = async () => {
const loading = ElLoading.service({
lock: true,
text: '正在重命名...'
})
const suffix = getFileSuffix(imageObj.name)
const imgInfo = {
name: renameValue.value + imageObj.name.substring(imageObj.name.indexOf('.')),
size: imageObj.size,
lastModified: Date.now(),
type: `image/${suffix}`
}
const base64 = await getBase64ByImageUrl(imgUrl.value, suffix)
if (base64) {
const newImgObj = getImage(base64, imgInfo)
if (newImgObj) {
const isUploadSuccess = await uploadImage_single(userConfigInfo, newImgObj)
if (isUploadSuccess) {
renameValue.value = ''
await doDeleteImage(imageObj, true)
await store.dispatch('UPLOADED_LIST_REMOVE', newImgObj.uuid)
}
}
}
loading.close()
}
ElMessageBox.confirm(`该图片重命名为 ${renameValue.value} `, `提示`, {
type: 'warning'
})
.then(async () => {
await renameFn()
})
.catch(() => {
console.log('取消图片重命名')
})
}
const visibleChange = (e: boolean) => {
dropdownVisible.value = e
}
const viewImageProperties = (imgObj: UploadedImageModel) => {
ElMessageBox.confirm(
`
<div>图片名称:<strong>${imgObj.name}</strong></div>
<div>图片大小:<strong>${getFileSize(imgObj.size)} KB</strong></div>
`,
`属性`,
{
showCancelButton: false,
showConfirmButton: false,
dangerouslyUseHTMLString: true,
type: 'info'
}
)
}
</script>
<style scoped lang="stylus">
@import 'image-card.styl';
</style>

View File

@@ -0,0 +1,52 @@
@import "../../style/base.styl"
.selector-wrapper {
padding 4rem 12rem
width 100%
box-sizing border-box
display flex
justify-content space-between
align-items center
border-bottom 1rem solid var(--third-background-color)
.selector-left-box {
display flex
align-items center
:deep(.el-checkbox) {
font-weight unset
}
:deep(.el-checkbox__label ) {
line-height unset
}
.cancel-select-btn {
color #576b95
cursor pointer
}
div.item {
margin-left 8rem
}
}
.selector-right-box {
.btn-icon {
cursor: pointer;
font-size: 22rem;
margin-left: 10rem;
}
}
}
.temp-batch-externalink {
opacity 0
position absolute
left -9999rem
top -9999rem
width 0
height 0
}

View File

@@ -0,0 +1,103 @@
<template>
<div class="selector-wrapper" v-if="getImageCardCheckedNum">
<div class="selector-left-box">
<el-checkbox
:label="checked ? '取消全选' : '全选'"
v-model="checked"
@change="triggleFullCheck"
></el-checkbox>
<div class="item">已选择 {{ getImageCardCheckedNum }} 张图片</div>
<div class="item cancel-select-btn" @click="cancelPick">取消选择</div>
</div>
<div class="selector-right-box">
<el-tooltip placement="top" content="批量复制外链">
<el-icon class="btn-icon" @click="copyLink"><CopyDocument /></el-icon>
</el-tooltip>
<el-tooltip placement="top" content="批量删除图片">
<el-icon class="btn-icon" @click="batchDeleteImage"><Delete /></el-icon>
</el-tooltip>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, watch, ref } from 'vue'
import { useStore } from '@/store'
import { UploadedImageModel } from '@/common/model/upload.model'
import { batchCopyExternalLink } from '@/utils/external-link-handler'
import { delelteBatchImage } from '@/utils/delete-image-card'
import { deleteStatusEnum } from '@/common/model/delete.model'
const props = defineProps({
currentDirImageList: {
type: Array,
default: () => []
}
})
const emits = defineEmits(['update:initImageList'])
const store = useStore()
const checked = ref(false)
const getImageCardCheckedArr = computed(() => store.getters.getImageCardCheckedArr)
const userConfigInfo = computed(() => store.getters.getUserConfigInfo).value
const userSettings = computed(() => store.getters.getUserSettings).value
const getImageCardCheckedNum = computed(() => getImageCardCheckedArr.value.length || 0)
watch(
() => getImageCardCheckedNum.value,
(newVal) => {
const newValCheckedNum = props.currentDirImageList.length
checked.value = newVal === newValCheckedNum
}
)
function copyLink() {
batchCopyExternalLink(getImageCardCheckedArr.value, userSettings.externalLinkType)
}
function cancelPick() {
props.currentDirImageList.forEach((item: UploadedImageModel) => {
if (item.checked) {
// eslint-disable-next-line no-param-reassign
item.checked = false
}
})
}
async function batchDeleteImage() {
if (getImageCardCheckedArr.value?.length > 0) {
ElMessageBox.confirm('是否批量删除已选中的图片?', '删除提示', {
type: 'warning'
})
.then(async () => {
const res = await delelteBatchImage(getImageCardCheckedArr.value, userConfigInfo)
if (res === deleteStatusEnum.allDeleted) {
ElMessage.success('批量删除成功!')
}
})
.catch(() => {
console.log('取消批量删除')
})
} else {
ElMessage.warning('请先选择图片')
}
}
function triggleFullCheck() {
let checkedImgArr: Array<UploadedImageModel> = []
props.currentDirImageList.forEach((item: UploadedImageModel) => {
// eslint-disable-next-line no-param-reassign
item.checked = checked.value
})
checkedImgArr = props.currentDirImageList as any
store.commit('REPLACE_IMAGE_CARD', { checkedImgArr })
}
onMounted(() => {
triggleFullCheck()
})
</script>
<style scoped lang="stylus">
@import 'image-selector.styl';
</style>

View File

@@ -0,0 +1,75 @@
$transition-duration = 0.3s
$transition-delay = 0s
.image-viewer {
position fixed
left 0
top 0
width 100%
height 100%
display flex
align-items center
justify-content center
background rgba(0, 0, 0, 0)
visibility hidden
z-index 1000
padding 6%
box-sizing border-box
transition-property visibility, background
transition-delay $transition-delay, $transition-delay
transition-duration $transition-duration, $transition-duration
transition-timing-function ease, ease
&.active {
background rgba(0, 0, 0, 0.6)
visibility visible
.image-box {
transform scale(1)
padding 2rem
.image-info {
display block
}
}
}
.image-box {
position relative;
width 60%
height 100%
display flex
flex-direction column
justify-content center
align-items center
transform scale(0)
transition-property transform
transition-delay $transition-delay
transition-duration $transition-duration
transition-timing-function ease
@media (max-width: 1200px) {
width 80%
}
.img {
cursor zoom-out
max-width 100%
max-height 100%
}
.image-info {
display none
padding 10rem
.item {
margin 0 6rem
}
}
}
}

View File

@@ -0,0 +1,44 @@
<template>
<div
class="image-viewer"
:class="{ active: imageViewer.isShow }"
@click="imageViewer.isShow = false"
>
<div class="image-box" v-if="imageViewer?.imgInfo?.url">
<img class="img" :src="imageViewer?.imgInfo?.url" />
<div class="image-info" v-if="imageViewer.imgInfo">
<el-tag class="item" size="small" v-if="imageViewer.imgInfo.name">
图片名{{ imageViewer.imgInfo.name }}
</el-tag>
<el-tag class="item" size="small" v-if="imageViewer.imgInfo.size">
图片大小{{ parseFileSize(imageViewer.imgInfo.size) }}
</el-tag>
<el-tag class="item" size="small" v-if="imageViewer.imgInfo.lastModified">
最后修改时间{{ formatLastModified(imageViewer.imgInfo.lastModified) }}
</el-tag>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useStore } from '@/store'
import { getFileSize } from '@/utils/file-handle-helper'
import TimeHelper from '@/utils/time-helper'
const store = useStore()
const imageViewer = computed(() => store.getters.getImageViewer)
function parseFileSize(size: number) {
return `${getFileSize(size)} KB`
}
function formatLastModified(t: number) {
return TimeHelper.formatTimestamp(t)
}
</script>
<style scoped lang="stylus">
@import "image-viewer.styl"
</style>

View File

@@ -0,0 +1,73 @@
@import "../../style/base.styl"
$top-height = 60rem
$left-side-width = 80rem
.main-container {
position absolute
box-sizing border-box
width 100%
height 100%
background var(--second-background-color)
padding-top $top-height
font-size 15rem
.top {
position absolute
top 0
left 0
box-sizing border-box
width 100%
height $top-height
}
.bottom {
position relative
box-sizing border-box
width 100%
height 100%
padding-top $component-interval
.container {
position relative
box-sizing border-box
width 100%
height 100%
padding-left $left-side-width
.left {
position absolute
box-sizing border-box
width $left-side-width
height 100%
top 0
left 0
}
.right {
position relative
width 100%
height 100%
box-sizing border-box
padding 0 $component-interval 0 $component-interval
.content {
position relative
box-sizing border-box
width 100%
height 100%
}
}
}
}
}

View File

@@ -0,0 +1,64 @@
<template>
<main class="main-container" @click="changeUploadAreaActive">
<div class="top">
<header-content />
</div>
<div class="bottom">
<div class="container">
<div class="left">
<nav-content />
</div>
<div class="right">
<div class="content">
<router-view />
</div>
</div>
</div>
</div>
</main>
<image-viewer></image-viewer>
</template>
<script lang="ts">
import { defineComponent, onMounted } from 'vue'
import headerContent from '@/components/header-content/header-content.vue'
import navContent from '@/components/nav-content/nav-content.vue'
import imageViewer from '@/components/image-viewer/image-viewer.vue'
import { useStore } from '@/store'
import userConfigInfoModel from '@/utils/set-theme-mode'
export default defineComponent({
name: 'main-container',
components: {
headerContent,
navContent,
imageViewer
},
setup() {
const store = useStore()
const changeUploadAreaActive = (e: any) => {
store.commit(
'CHANGE_UPLOAD_AREA_ACTIVE',
e.target.classList.contains('active-upload')
)
}
onMounted(() => {
userConfigInfoModel()
})
return {
changeUploadAreaActive
}
}
})
</script>
<style scoped lang="stylus">
@import "main-container.styl"
</style>

View File

@@ -0,0 +1,40 @@
@import "../../style/base.styl"
.nav {
position relative
width 100%
height 100%
box-sizing border-box
background var(--background-color)
ul.nav-list {
padding 0
margin 0
li.nav-item {
box-sizing border-box
width 100%
height 76rem
cursor pointer
&.active {
font-weight bold
background var(--second-background-color)
}
.nav-content {
display flex
flex-direction column
justify-content center
align-items center
.nav-name {
margin-top 5rem
font-size 12rem
}
}
}
}
}

View File

@@ -0,0 +1,152 @@
<template>
<aside class="nav">
<ul class="nav-list">
<li
class="nav-item flex-center"
v-for="(navItem, index) in navList"
:key="index"
:class="{ active: navItem.isActive }"
@click="navClick(navItem)"
v-show="navItem.isShow"
>
<div class="nav-content">
<el-icon :size="navIconSize">
<component :is="navItem.icon"></component>
</el-icon>
<span class="nav-name">{{ navItem.name }}</span>
</div>
</li>
</ul>
</aside>
</template>
<script setup lang="ts">
import { onMounted, watch, computed, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from '@/store'
const router = useRouter()
const store = useStore()
const userConfigInfo = computed(() => store.getters.getUserConfigInfo).value
const userSettings = computed(() => store.getters.getUserSettings).value
const navIconSize = computed(() => {
switch (userSettings.elementPlusSize) {
case 'small':
return 22
case 'large':
return 30
default:
return 26
}
})
const navList = ref([
{
name: '图床配置',
icon: 'edit',
isActive: false,
path: '/config',
isShow: true
},
{
name: '上传图片',
icon: 'upload',
isActive: false,
path: '/upload',
isShow: true
},
{
name: '图床管理',
icon: 'box',
isActive: false,
path: '/management',
isShow: true
},
{
name: '我的设置',
icon: 'setting',
isActive: false,
path: '/settings',
isShow: true
},
{
name: '使用教程',
icon: 'magic-stick',
isActive: false,
path: '/tutorials',
isShow: true
},
{
name: '帮助反馈',
icon: 'chat-dot-round',
isActive: false,
path: '/about',
isShow: true
}
])
const navClick = (e: any) => {
const { path } = e
if (path === '/management') {
if (userConfigInfo.selectedRepos === '') {
ElMessage.warning('请选择一个仓库!')
router.push('/config')
return
}
if (userConfigInfo.selectedDir === '') {
ElMessage.warning('目录不能为空!')
router.push('/config')
return
}
}
router.push(path)
}
const changeNavActive = (currentPath: string) => {
navList.value.forEach((v) => {
const temp = v
temp.isActive = v.path === currentPath
return temp
})
}
watch(
() => router.currentRoute.value,
(_n) => {
changeNavActive(_n.path)
}
)
watch(
() => userConfigInfo.loggingStatus,
(_n) => {
navList.value.forEach((v: any) => {
// eslint-disable-next-line default-case
switch (v.path) {
case '/management':
case '/settings':
// eslint-disable-next-line no-param-reassign
v.isShow = _n
}
})
},
{
deep: true,
immediate: true
}
)
onMounted(() => {
router.isReady().then(() => {
changeNavActive(router.currentRoute.value.path)
})
})
</script>
<style scoped lang="stylus">
@import "nav-content.styl"
</style>

View File

@@ -0,0 +1,16 @@
.selected-info-bar-box {
height 100%
display flex
align-items center
justify-content flex-start
font-size 12rem
box-sizing border-box
.info-item {
margin-right 8rem
&:last-child {
margin-right 0
}
}
}

View File

@@ -0,0 +1,35 @@
<template>
<div class="selected-info-bar-box" v-if="userConfigInfo.selectedRepos">
<span class="info-item">
仓库
<el-tag>
{{ userConfigInfo.selectedRepos }}
</el-tag>
</span>
<span class="info-item" v-if="userConfigInfo.selectedBranch">
分支
<el-tag>
{{ userConfigInfo.selectedBranch }}
</el-tag>
</span>
<span class="info-item" v-if="userConfigInfo.selectedDir">
目录
<el-tag>
{{ userConfigInfo.selectedDir }}
</el-tag>
</span>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { useStore } from '@/store'
const store = useStore()
const userConfigInfo = computed(() => store.getters.getUserConfigInfo)
const userSettings = computed(() => store.getters.getUserSettings)
</script>
<style scoped lang="stylus">
@import "selected-info-bar.styl"
</style>

View File

@@ -0,0 +1,58 @@
<template>
<span class="site-count" ref="siteCountDom" v-show="isShow">
超过
<span id="busuanzi_value_site_uv" class="uv" v-show="isuv"></span>
<span id="busuanzi_value_site_pv" class="pv" v-show="!isuv"></span>
次被使用
</span>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref, Ref } from 'vue'
export default defineComponent({
name: 'site-count',
props: {
isuv: {
type: Boolean,
default: false
}
},
setup(props, ctx) {
const siteCountDom: Ref = ref<null | HTMLElement>(null)
const isShow: Ref<boolean> = ref(false)
const getInnerText = (dom, isuv) => {
return dom.querySelector(`.${isuv ? 'u' : 'p'}v`).innerText
}
onMounted(() => {
const script: any = document.createElement('script')
script.async = true
script.src = '//busuanzi.ibruce.info/busuanzi/2.3/busuanzi.pure.mini.js'
siteCountDom.value.appendChild(script)
script.onload = () => {
const tempT = setTimeout(() => {
if (getInnerText(siteCountDom.value, props.isuv)) {
isShow.value = true
}
clearTimeout(tempT)
}, 1500)
}
})
return {
siteCountDom,
isShow
}
}
})
</script>
<style lang="stylus">
.site-count {
transition all 0.2s ease-in
}
</style>

View File

@@ -0,0 +1,211 @@
@import "../../style/base.styl"
$info-item-height = 68rem
$info-item-border = 1rem
$info-item-padding = 5rem
$compressed-file-background-color = #228eff
$image-width = $info-item-height - ($info-item-border * 2)
.to-upload-image-list-card {
position relative
width 100%
box-sizing border-box
margin-top 6rem
.header {
width 100%
height 30rem
box-sizing border-box
font-size 12rem
display flex
align-items center
justify-content space-between
padding-bottom 6rem
}
.body {
width 100%
height 100%
max-height 170rem
overflow-y auto
box-sizing border-box
padding 10rem
border 1rem solid var(--border-color)
margin-top 10rem
&::-webkit-scrollbar {
width 5rem
}
&::-webkit-scrollbar-thumb {
border-radius 2rem
}
.image-uploading-info-box {
position relative
width 100%
box-sizing border-box
padding 0
margin 0
.image-uploading-info-item {
position relative
box-sizing border-box
width 100%
height $info-item-height
border $info-item-border solid var(--border-color)
border-radius 5rem
margin-bottom 10rem
overflow hidden
font-size 15rem
padding-left $image-width
transition all 0.3s ease
&.disable {
pointer-events none
cursor not-allowed
}
&:last-child {
margin-bottom 0
}
&:hover {
box-shadow 0 0 5rem var(--shadow-hover-color)
}
.left-image-box {
position absolute
top 0
left 0
width $image-width
height 100%
box-sizing border-box
margin-right 5rem
img {
object-fit cover
width 100%
height 100%
overflow hidden
cursor pointer
border-top-left-radius 5rem
border-bottom-left-radius 5rem
}
}
.right-operation-box {
position relative
width 100%
height 100%
box-sizing border-box
padding $info-item-padding 20rem $info-item-padding $info-item-padding
.top, .bottom {
width 100%
height 50%
box-sizing border-box
padding 0 5rem
}
.top {
display flex
justify-content space-between
.image-name,
.image-info {
display flex
align-items center
box-sizing border-box
height 100%
}
.image-name {
font-size 13rem
overflow hidden
text-overflow ellipsis
white-space nowrap
}
.image-info {
font-size 12rem
.item {
padding 1rem 4rem
background var(--third-background-color)
border-radius 2rem
margin-left 10rem
&.compressed {
color $compressed-file-background-color
}
}
}
}
.bottom {
display flex
align-items center
&.rename-operation {
.el-checkbox {
margin-right 20rem
}
.rename-input {
input {
height 23rem
line-height 23rem
}
}
}
}
}
.upload-status-box {
box-sizing border-box
color #fff
position absolute
right -17rem
top -7rem
width 46rem
height 26rem
text-align center
transform rotate(45deg)
box-shadow 0 1rem 1rem var(--border-color)
&.wait-upload {
background var(--await-upload-color)
}
&.uploading {
background var(--uploading-color)
}
&.uploaded {
background var(--uploaded-color)
}
i {
font-size 12rem
margin-top 12rem
transform rotate(-45deg)
}
}
.remove-to-upload-image {
position absolute
bottom 5rem
right 5rem
cursor pointer
}
}
}
}
}

View File

@@ -0,0 +1,300 @@
<template>
<div
class="to-upload-image-list-card"
v-if="toUploadImage.list.length || userConfigInfo.selectedRepos"
>
<div class="header">
<div>
<selected-info-bar />
</div>
<div>
<span v-if="toUploadImage.list.length">
已上传{{ toUploadImage.uploadedNumber }} / {{ toUploadImage.list.length }}
</span>
</div>
</div>
<div class="body" v-if="toUploadImage.list.length">
<ul class="image-uploading-info-box">
<li
class="image-uploading-info-item"
:class="{ disable: loadingAllImage }"
v-for="(imgItem, index) in toUploadImage.list"
:key="index"
>
<div class="left-image-box">
<img data-fancybox="gallery" :src="imgItem.imgData.base64Url" />
</div>
<div class="right-operation-box">
<div class="top">
<div class="image-name">
{{ imgItem.filename.now }}
</div>
<div class="image-info">
<span class="file-size item" v-if="userSettings.isCompress">
<del>
{{ getFileSize(imgItem.fileInfo.originSize) }}
</del>
</span>
<span
class="file-size item"
:class="{ compressed: userSettings.isCompress }"
>
{{ getFileSize(imgItem.fileInfo.size) }}
</span>
<span class="last-modified item">
{{ formatLastModified(imgItem.fileInfo.lastModified) }}
</span>
</div>
</div>
<div
class="bottom rename-operation"
v-if="
!imgItem.uploadStatus.uploading && imgItem.uploadStatus.progress !== 100
"
>
<!-- 哈希化 -->
<el-checkbox
label="哈希化"
v-model="imgItem.filename.isHashRename"
@change="hashRename($event, imgItem)"
></el-checkbox>
<!-- 重命名 -->
<el-checkbox
label="重命名"
v-model="imgItem.filename.isRename"
@change="rename($event, imgItem)"
></el-checkbox>
<el-input
class="rename-input"
size="small"
v-if="imgItem.filename.isRename"
v-model="imgItem.filename.newName"
@input="rename($event, imgItem)"
clearable
></el-input>
<!-- 命名前缀 -->
<el-checkbox
label="命名前缀"
v-if="
!imgItem.filename.isRename &&
userConfigInfo.defaultPrefix &&
userConfigInfo.prefixName
"
v-model="imgItem.filename.isPrefix"
@change="prefixName($event, imgItem)"
></el-checkbox>
</div>
<div
class="bottom rename-operation"
v-if="
!imgItem.uploadStatus.uploading && imgItem.uploadStatus.progress === 100
"
>
<copy-externalLink :img-obj="imgItem.uploadedImg" />
</div>
</div>
<div
class="upload-status-box"
:class="{
'wait-upload':
!imgItem.uploadStatus.uploading && imgItem.uploadStatus.progress !== 100,
uploading:
imgItem.uploadStatus.uploading && imgItem.uploadStatus.progress !== 100,
uploaded:
!imgItem.uploadStatus.uploading && imgItem.uploadStatus.progress === 100
}"
>
<el-icon
v-if="
!imgItem.uploadStatus.uploading && imgItem.uploadStatus.progress !== 100
"
><Upload
/></el-icon>
<el-icon
class="is-loading"
v-if="
imgItem.uploadStatus.uploading && imgItem.uploadStatus.progress !== 100
"
><Loading
/></el-icon>
<el-icon
v-if="
!imgItem.uploadStatus.uploading && imgItem.uploadStatus.progress === 100
"
><Check
/></el-icon>
</div>
<div
class="remove-to-upload-image"
v-if="
imgItem.uploadStatus.progress !== 100 && !imgItem.uploadStatus.uploading
"
@click="removeToUploadImage(imgItem)"
>
<el-tooltip effect="dark" content="移除" placement="top">
<el-icon><Delete /></el-icon>
</el-tooltip>
</div>
</li>
</ul>
</div>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, reactive, toRefs, onMounted } from 'vue'
import { useStore } from '@/store'
import { getFileSize } from '@/utils/file-handle-helper'
import { UserConfigInfoModel } from '@/common/model/user-config-info.model'
import { ToUploadImageModel, UploadStatusEnum } from '@/common/model/upload.model'
import TimeHelper from '@/utils/time-helper'
import copyExternalLink from '@/components/copy-external-link/copy-external-link.vue'
import selectedInfoBar from '@/components/selected-info-bar/selected-info-bar.vue'
import { uploadImage_single } from '@/utils/upload-helper'
export default defineComponent({
name: 'to-upload-image-card',
components: {
copyExternalLink,
selectedInfoBar
},
props: {
loadingAllImage: {
type: Boolean,
default: false
}
},
setup() {
const store = useStore()
const reactiveData = reactive({
isShowDialog: false,
curImgInfo: {
size: ''
},
userConfigInfo: computed(() => store.getters.getUserConfigInfo).value,
userSettings: computed(() => store.getters.getUserSettings).value,
toUploadImage: computed(() => store.getters.getToUploadImage).value,
hashRename(e: boolean, img: any) {
if (e) {
// eslint-disable-next-line no-param-reassign
img.filename.now = `${img.filename.name}.${img.filename.hash}.${img.filename.suffix}`
} else {
// eslint-disable-next-line no-param-reassign
img.filename.now = `${img.filename.name}.${img.filename.suffix}`
}
},
prefixName(e: boolean, img: any) {
if (e) {
// eslint-disable-next-line no-param-reassign
img.filename.name = `${img.filename.prefixName}${img.filename.initName}`
} else {
// eslint-disable-next-line no-param-reassign
img.filename.name = `${img.filename.initName}`
}
if (img.filename.isHashRename) {
// eslint-disable-next-line no-param-reassign
img.filename.now = `${img.filename.name}.${img.filename.hash}.${img.filename.suffix}`
} else {
// eslint-disable-next-line no-param-reassign
img.filename.now = `${img.filename.name}.${img.filename.suffix}`
}
},
rename(e: boolean, img: any) {
if (e) {
// eslint-disable-next-line no-param-reassign
img.filename.name = img.filename.newName.trim().replace(/\s+/g, '-')
} else {
// eslint-disable-next-line no-param-reassign
reactiveData.prefixName(img.filename.isPrefix, img) // 恢复列表prefix选项
}
if (img.filename.isHashRename) {
// eslint-disable-next-line no-param-reassign
img.filename.now = `${img.filename.name}.${img.filename.hash}.${img.filename.suffix}`
} else {
// eslint-disable-next-line no-param-reassign
img.filename.now = `${img.filename.name}.${img.filename.suffix}`
}
},
getFileSize(size: number) {
return `${getFileSize(size)} KB`
},
formatLastModified(t: number) {
return TimeHelper.formatTimestamp(t)
},
async uploadImage_all(userConfigInfo: UserConfigInfoModel) {
const uploadIndex = this.toUploadImage.uploadedNumber
if (uploadIndex >= this.toUploadImage.list.length) {
return UploadStatusEnum.uploaded
}
if (
await uploadImage_single(userConfigInfo, this.toUploadImage.list[uploadIndex])
) {
if (uploadIndex < this.toUploadImage.list.length) {
await this.uploadImage_all(userConfigInfo)
return UploadStatusEnum.allUploaded
}
return UploadStatusEnum.uploaded
}
return UploadStatusEnum.uploadFail
}
})
const removeToUploadImage = (imgItem: ToUploadImageModel) => {
store.dispatch('TO_UPLOAD_IMAGE_LIST_REMOVE', imgItem.uuid)
}
onMounted(() => {
const {
defaultHash: isHash,
defaultPrefix: isPrefix,
prefixName
} = reactiveData.userSettings
reactiveData.toUploadImage.list.forEach((v: ToUploadImageModel) => {
// eslint-disable-next-line no-param-reassign
v.filename.isPrefix = isPrefix
// eslint-disable-next-line no-param-reassign
v.filename.prefixName = prefixName
reactiveData.prefixName(isPrefix, v)
// eslint-disable-next-line no-param-reassign
v.filename.isHashRename = isHash
reactiveData.hashRename(isHash, v)
})
})
return {
...toRefs(reactiveData),
removeToUploadImage
}
}
})
</script>
<style lang="stylus">
@import "to-upload-image-card.styl"
</style>

View File

@@ -0,0 +1,40 @@
<template>
<div class="tutorials-step-1">
<h3>
创建一个用来存储图片的
<span class="go-create-repo" @click="goCreateRepo"> GitHub 仓库</span>
</h3>
<img
src="https://cdn.staticaly.com/gh/XPoet/image-hosting@master/PicX/image.j1486dtk68n.png"
alt="Create GitHub Repository"
/>
</div>
</template>
<script lang="ts" setup>
function goCreateRepo() {
window.open('https://github.com/new')
}
</script>
<style lang="stylus">
.tutorials-step-1 {
width 800rem
.go-create-repo {
cursor pointer
color #1c81e9
&:hover {
color #085fb8
border-bottom 1rem solid #085fb8
}
}
img {
width 100%
}
}
</style>

View File

@@ -0,0 +1,54 @@
<template>
<div class="tutorials-step-2">
<h3>
创建一个有 repo 权限的
<span class="go-create-token" @click="goCreateToken"> GitHub Token</span>
</h3>
<img
src="https://cdn.staticaly.com/gh/XPoet/image-hosting@master/PicX/image.lpt1xl9fu.png"
alt="Create GitHub Token"
/>
<p>然后点击 Generate token 按钮即可生成一个token如下图</p>
<img
src="https://cdn.staticaly.com/gh/XPoet/image-hosting@master/PicX/image.pzmcp6b80fk.png"
alt="token-demo"
/>
<p style="color: red">
<em>新生成的 Token 只会显示一次请妥善保存如有遗失重新生成即可</em>
</p>
</div>
</template>
<script lang="ts" setup>
function goCreateToken() {
window.open('https://github.com/settings/tokens/new')
}
</script>
<style lang="stylus">
.tutorials-step-2 {
width 800rem
.go-create-token {
cursor pointer
color #1c81e9
&:hover {
color #085fb8
border-bottom 1rem solid #085fb8
}
}
img {
width 100%
}
p {
font-weight bold
padding-top 20rem
}
}
</style>

View File

@@ -0,0 +1,62 @@
<template>
<div class="tutorials-step-3">
<h3>进行图床配置绑定 GitHub Token存储图片的仓库和目录</h3>
<p>1填写 Token自动获取该用户下的仓库</p>
<img
src="https://cdn.staticaly.com/gh/XPoet/image-hosting@master/PicX/image.4g8q5m7c8sq0.png"
/>
<br />
<p>2在仓库的下拉列表中选择一个作为图床的仓库</p>
<img
src="https://cdn.staticaly.com/gh/XPoet/image-hosting@master/PicX/image.746g75olruk0.png"
/>
<br />
<p>3选择一种目录方式目录即仓库里存放图片的文件夹</p>
<img
src="https://cdn.staticaly.com/gh/XPoet/image-hosting@master/PicX/image.5ydmhgxjhgo0.png"
/>
<div class="dir-desc-box">
<ul>
<li>新建目录需手动输入一个新目录</li>
<li>根目录图片将直接存储在仓库根目录下</li>
<li>自动目录自动生成日期格式 YYYYMMDD 的目录例如20200909</li>
<li>选择仓库目录自动获取仓库下所有目录选择一个即可</li>
</ul>
</div>
</div>
</template>
<style lang="stylus">
.tutorials-step-3 {
width 800rem
img {
width 100%
}
p {
font-weight bold
padding-top 20rem
}
.dir-desc-box {
display flex
justify-content center
ul {
width 100%
padding 0
li {
padding-top 10rem
text-align left
}
}
}
}
</style>

View File

@@ -0,0 +1,57 @@
@import "../../style/base.styl"
.upload-area {
position: relative;
width: 100%;
height: 300rem;
border: 4rem dashed var(--third-text-color)
box-sizing border-box
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
&.focus {
border-color: var(--upload-area-focus-color);
}
&:hover {
border-color: var(--upload-area-focus-color);
}
label {
display: block;
position: absolute;
width: 100%;
height: 100%;
z-index: 1000;
cursor: pointer;
}
input[type="file"] {
position: absolute;
left: -9999rem;
top: -9999rem;
}
.tips {
text-align: center;
color: #aaa;
.icon {
font-size: 100rem;
}
.text {
cursor: default;
font-size: 20rem;
}
}
img {
object-fit: cover;
width: 100%;
height: 100%;
}
}

View File

@@ -0,0 +1,148 @@
<template>
<div
class="upload-area active-upload"
:class="{ focus: uploadAreaActive }"
@dragover.prevent
@drop.stop.prevent="onDrop"
@paste="onPaste"
v-loading="imageLoading"
element-loading-text="图片上传中..."
element-loading-background="rgba(0, 0, 0, 0.5)"
>
<label for="uploader" class="active-upload" v-if="uploadAreaActive"></label>
<input id="uploader" type="file" @change="onSelect" multiple="multiple" />
<div class="tips active-upload" v-if="!toUploadImage.curImgBase64Url">
<el-icon class="icon active-upload"><UploadFilled /></el-icon>
<div class="text active-upload">拖拽粘贴或点击此处上传</div>
</div>
<img
class="active-upload"
v-if="toUploadImage.curImgBase64Url"
:src="toUploadImage.curImgBase64Url"
alt="Pictures to be uploaded"
/>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, reactive, toRefs } from 'vue'
import { useStore } from '@/store'
import { filenameHandle } from '@/utils/file-handle-helper'
import selectedFileHandle, { handleResult } from '@/utils/selected-file-handle'
import createToUploadImageObject from '@/utils/create-to-upload-image'
import paste from '@/utils/paste'
import Upload from '@/views/upload/upload.vue'
export default defineComponent({
name: 'upload-area',
props: {
imageLoading: {
type: Boolean,
default: false
}
},
setup() {
const store = useStore()
const reactiveData = reactive({
userConfigInfo: computed(() => store.getters.getUserConfigInfo).value,
userSettings: computed(() => store.getters.getUserSettings).value,
uploadAreaActive: computed((): boolean => store.getters.getUploadAreaActive),
uploadSettings: computed(() => store.getters.getUploadSettings).value,
toUploadImage: computed(() => store.getters.getToUploadImage).value,
// 选择图片
onSelect(e: any) {
store.commit('CHANGE_UPLOAD_AREA_ACTIVE', true)
// eslint-disable-next-line no-restricted-syntax
for (const file of e.target.files) {
selectedFileHandle(file, this.uploadSettings.imageMaxSize)?.then((result) => {
if (!result) {
return
}
const { base64, originalFile, compressFile } = result
this.getImage(base64, originalFile, compressFile)
})
}
},
// 拖拽图片
onDrop(e: any) {
store.commit('CHANGE_UPLOAD_AREA_ACTIVE', true)
// eslint-disable-next-line no-restricted-syntax
for (const file of e.dataTransfer.files) {
selectedFileHandle(file, this.uploadSettings.imageMaxSize)?.then((result) => {
if (!result) {
return
}
const { base64, originalFile, compressFile } = result
this.getImage(base64, originalFile, compressFile)
})
}
},
// 复制图片
async onPaste(e: any) {
const { base64, originalFile, compressFile }: handleResult = await paste(
e,
this.uploadSettings.imageMaxSize
)
this.getImage(base64, originalFile, compressFile)
},
// 获取图片对象
getImage(base64Data: string, originFile: File, compressFile?: File) {
if (
this.toUploadImage.list.length === this.toUploadImage.uploadedNumber &&
this.toUploadImage.list.length > 0 &&
this.toUploadImage.uploadedNumber > 0
) {
store.dispatch('TO_UPLOAD_IMAGE_CLEAN_LIST')
store.dispatch('TO_UPLOAD_IMAGE_CLEAN_UPLOADED_NUMBER')
}
const { defaultHash, isCompress, defaultPrefix, prefixName } = this.userSettings
const file = isCompress ? compressFile : originFile
const curImg = createToUploadImageObject()
curImg.imgData.base64Url = base64Data
// eslint-disable-next-line prefer-destructuring
curImg.imgData.base64Content = base64Data.split(',')[1]
const { name, hash, suffix } = filenameHandle(file?.name)
curImg.uuid = hash
curImg.fileInfo.compressedSize = compressFile?.size
curImg.fileInfo.originSize = originFile.size
curImg.fileInfo.size = file?.size
curImg.fileInfo.lastModified = file?.lastModified
curImg.filename.initName = name
curImg.filename.name = defaultPrefix ? `${prefixName}${name}` : name
curImg.filename.prefixName = prefixName
curImg.filename.hash = hash
curImg.filename.suffix = suffix
curImg.filename.now = defaultHash
? `${curImg.filename.name}.${hash}.${suffix}`
: `${curImg.filename.name}.${suffix}`
curImg.filename.isHashRename = defaultHash
curImg.filename.isPrefix = defaultPrefix
store.dispatch('TO_UPLOAD_IMAGE_LIST_ADD', JSON.parse(JSON.stringify(curImg)))
store.dispatch('TO_UPLOAD_IMAGE_SET_CURRENT', {
uuid: hash,
base64Url: base64Data
})
}
})
return {
...toRefs(reactiveData)
}
}
})
</script>
<style scoped lang="stylus">
@import "upload-area.styl"
</style>

21
picx/src/main.ts Normal file
View File

@@ -0,0 +1,21 @@
import { createApp } from 'vue'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import router from '@/router/index'
import { key, store } from '@/store'
import App from './App.vue'
import 'element-plus/theme-chalk/dark/css-vars.css'
if (import.meta.env.MODE === 'production') {
// @ts-ignore
import('@/utils/register-sw.ts')
}
const app = createApp(App)
// import element-plus icons
// eslint-disable-next-line no-restricted-syntax
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
// @ts-ignore
app.use(router).use(store, key).mount('#app')

32
picx/src/plugins/index.ts Normal file
View File

@@ -0,0 +1,32 @@
import type { Plugin } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import { ViteEnv } from '@/common/model/vite-config.model'
import configPWAPlugin from './pwa'
export default function createVitePlugins(viteEnv: ViteEnv, isBuild: boolean) {
const vitePlugins: (Plugin | Plugin[])[] = [vue()]
// On-demand import style for Element Plus
vitePlugins.push(
AutoImport({
resolvers: [ElementPlusResolver()]
}),
Components({
resolvers: [ElementPlusResolver()]
})
)
// production env
if (isBuild) {
// add plugin vite-plugin-pwa
if (viteEnv.VITE_USE_PWA) {
vitePlugins.push(configPWAPlugin(viteEnv))
}
}
return vitePlugins
}

24
picx/src/plugins/pwa.ts Normal file
View File

@@ -0,0 +1,24 @@
/**
* Zero config PWA for Vite
* Plugin: vite-plugin-pwa
* https://github.com/antfu/vite-plugin-pwa
*/
import { VitePWA } from 'vite-plugin-pwa'
import { ViteEnv } from '@/common/model/vite-config.model'
export default function configPWAPlugin(env: ViteEnv) {
return VitePWA({
registerType: 'autoUpdate',
manifest: {
name: env.VITE_GLOB_APP_TITLE,
short_name: env.VITE_GLOB_APP_SHORT_NAME,
icons: [
{
src: './logo@192x192.png',
sizes: '192x192',
type: 'image/png'
}
]
}
})
}

82
picx/src/router/index.ts Normal file
View File

@@ -0,0 +1,82 @@
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router'
import config from '@/views/config/config.vue'
import upload from '@/views/upload/upload.vue'
import management from '@/views/management/management.vue'
import tutorials from '@/views/tutorials/tutorials.vue'
import settings from '@/views/settings/settings.vue'
import { store } from '@/store'
const titleSuffix = ` | PicX 图床神器`
const routes: Array<RouteRecordRaw> = [
{
path: '/',
name: 'index',
redirect: {
name: 'upload'
}
},
{
path: '/config',
name: 'config',
component: config,
meta: {
title: `图床配置${titleSuffix}`
}
},
{
path: '/upload',
name: 'upload',
component: upload,
meta: {
title: `图片上传${titleSuffix}`
}
},
{
path: '/management',
name: 'Management',
component: management,
meta: {
title: `图床管理${titleSuffix}`
}
},
{
path: '/tutorials',
name: 'tutorials',
component: tutorials,
meta: {
title: `使用教程${titleSuffix}`
}
},
{
path: '/about',
name: 'about',
component: () => import('@/views/about/about.vue'),
meta: {
title: `帮助反馈${titleSuffix}`
}
},
{
path: '/settings',
name: 'settings',
component: settings,
meta: {
title: `我的设置${titleSuffix}`
}
}
]
const router = createRouter({
history: createWebHashHistory(),
routes
})
router.beforeEach((to, from, next) => {
if (to.meta.title) (<any>window).document.title = to.meta.title
if (from.path === '/management') {
store.dispatch('USER_CONFIG_INFO_RESET')
}
next()
})
export default router

6
picx/src/shims-vue.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
declare module '*.vue' {
import { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

57
picx/src/store/index.ts Normal file
View File

@@ -0,0 +1,57 @@
import { InjectionKey } from 'vue'
import { createStore, Store, useStore as baseUseStore } from 'vuex'
import RootStateTypes, { AllStateTypes } from './types'
import dirImageListModule from './modules/dir-image-list'
import toUploadImageModule from './modules/to-upload-image'
import uploadedImageListModule from './modules/uploaded-image-list'
import userConfigInfoModule from './modules/user-config-info'
import imageViewerModule from './modules/image-viewer'
import imageCardModule from './modules/image-card'
import uploadAreaActiveModule from './modules/upload-area-active'
import uploadSettingsModule from './modules/upload-settings'
import userSettingsModule from './modules/user-settings'
// Create a new store instance.
export const store = createStore<RootStateTypes>({
modules: {
dirImageListModule,
toUploadImageModule,
uploadedImageListModule,
userConfigInfoModule,
imageViewerModule,
imageCardModule,
uploadAreaActiveModule,
uploadSettingsModule,
userSettingsModule
},
state: {
rootName: 'root'
},
mutations: {},
actions: {
// 退出登录(删除 localStorage 和 sessionStorage 数据,清空 state 的值)
LOGOUT({ dispatch, commit }) {
dispatch('DIR_IMAGE_LOGOUT')
dispatch('TO_UPLOAD_IMAGE_LOGOUT')
dispatch('UPLOADED_LIST_LOGOUT')
dispatch('USER_CONFIG_INFO_LOGOUT')
commit('IMAGE_VIEWER_LOGOUT')
commit('UPLOAD_AREA_ACTIVE_LOGOUT')
commit('UPLOAD_SETTINGS_LOGOUT')
dispatch('USER_SETTINGS_LOGOUT')
localStorage.clear()
sessionStorage.clear()
}
},
getters: {}
})
export const key: InjectionKey<Store<RootStateTypes>> = Symbol('vuex-store')
export function useStore<T = AllStateTypes>() {
return baseUseStore<T>(key)
}

View File

@@ -0,0 +1,260 @@
import { Module } from 'vuex'
import { PICX_MANAGEMENT } from '@/common/model/storage.model'
import DirImageListStateTypes, { DirObject } from './types'
import RootStateTypes from '../../types'
import {
createDirObject,
getUpLevelDirList,
getUpOneLevelDir
} from '@/store/modules/dir-image-list/utils'
import { UploadedImageModel } from '@/common/model/upload.model'
import { getDirContent } from '@/views/management/management.util'
const initDirObject = () => {
const dirObj = localStorage.getItem(PICX_MANAGEMENT)
return dirObj ? JSON.parse(dirObj) : createDirObject('/', '/')
}
const dirImageListModule: Module<DirImageListStateTypes, RootStateTypes> = {
state: {
name: 'dirImageListModule',
dirObject: initDirObject()
},
mutations: {},
actions: {
// 图床管理 - 增加目录
DIR_IMAGE_LIST_ADD_DIR({ state, dispatch }, dirPath: string) {
if (dirPath === '/') {
return
}
const findAssign = (dirObj: DirObject, dir: string, dirPath: string) => {
if (dirObj) {
if (!dirObj.childrenDirs.some((v: DirObject) => v.dir === dir)) {
dirObj.childrenDirs.push(createDirObject(dir, dirPath))
}
const temp = dirObj.childrenDirs.find((x: DirObject) => x.dir === dir)
return temp || createDirObject(dir, dirPath)
}
return createDirObject(dir, dirPath)
}
const dirList: string[] = dirPath.split('/')
let dirPathC = ''
let tempDirObj: DirObject = state.dirObject
// eslint-disable-next-line no-plusplus
for (let i = 0, len = dirList.length; i < len; i++) {
const dirName = dirList[i]
dirPathC += `${i > 0 ? '/' : ''}${dirName}`
tempDirObj = findAssign(tempDirObj, dirName, dirPathC)
if (i === 0) {
dispatch('USER_CONFIG_INFO_ADD_DIR', dirName)
}
}
dispatch('DIR_IMAGE_LIST_PERSIST')
},
// 图床管理 - 删除目录
DIR_IMAGE_LIST_REMOVE_DIR({ state, dispatch }, dirPath: string) {
if (dirPath === '/') {
return
}
const rmDir = (dirObj: DirObject, dir: string, isRm: boolean) => {
if (dir === '/') {
return state.dirObject
}
const temp = dirObj.childrenDirs.find((v) => v.dir === dir)
if (!temp) {
return dirObj
}
if (isRm) {
const rmIndex = dirObj.childrenDirs.findIndex((v: any) => v.dir === dir)
if (rmIndex !== -1) {
dirObj.childrenDirs.splice(rmIndex, 1)
}
}
return temp
}
const dirList = dirPath.split('/')
let tempDirObj = state.dirObject
dirList.forEach((d, i) => {
tempDirObj = rmDir(tempDirObj, d, i === dirList.length - 1)
// 删除在用户配置信息模块里的目录项
if (i === 0) {
dispatch('USER_CONFIG_INFO_REMOVE_DIR', d)
}
})
dispatch('DIR_IMAGE_LIST_PERSIST')
},
// 图床管理 - 增加图片
DIR_IMAGE_LIST_ADD_IMAGE({ state, dispatch }, item: UploadedImageModel) {
const addImg = (
dirObj: DirObject,
dir: string,
Img: UploadedImageModel,
isAdd: boolean = false
) => {
if (!dirObj) {
return state.dirObject
}
const temp = dirObj.childrenDirs?.find((x: DirObject) => x.dir === dir)
if (!temp) {
return state.dirObject
}
if (isAdd && !temp.imageList.some((v) => v.name === Img.name)) {
temp.imageList.push(Img)
}
return temp
}
let tempDirObj: DirObject = state.dirObject
if (item.dir === '/') {
if (!tempDirObj.imageList.some((v) => v.name === item.name)) {
tempDirObj.imageList.push(item)
}
} else {
const dirList: string[] = item.dir.split('/')
dirList.forEach((dir, i) => {
tempDirObj = addImg(tempDirObj, dir, item, i === dirList.length - 1)
})
}
dispatch('DIR_IMAGE_LIST_PERSIST')
},
// 图床管理 - 删除图片(即删除指定目录里的指定图片)
DIR_IMAGE_LIST_REMOVE({ state, dispatch }, item: any) {
// 删除
const rm = (list: UploadedImageModel[], uuid: string) => {
if (list.length) {
const rmIndex = list.findIndex((v: any) => v.uuid === uuid)
if (rmIndex !== -1) {
list.splice(rmIndex, 1)
}
}
}
// 删除图片
const rmImg = (
dirObj: DirObject,
dir: string,
img: UploadedImageModel,
isRm: boolean
) => {
if (!dirObj) {
return state.dirObject
}
const temp = dirObj.childrenDirs.find((x: DirObject) => x.dir === dir)
if (!temp) {
return state.dirObject
}
if (temp.dir === dir && isRm) {
rm(temp.imageList, img.uuid)
}
return temp
}
const { dir, uuid } = item
if (dir === '/') {
rm(state.dirObject.imageList, uuid)
dispatch('DIR_IMAGE_LIST_PERSIST')
return
}
const dirList: string[] = dir.split('/')
let tempDirObj: DirObject = state.dirObject
dirList.forEach((d, i) => {
tempDirObj = rmImg(tempDirObj, d, item, i === dirList.length - 1)
if (!tempDirObj.imageList.length && !tempDirObj.childrenDirs.length) {
const dirPathList = getUpLevelDirList(tempDirObj.dirPath)
// 循环遍历判断上一级目录的内容是否为空,为空则删除,依次往上查找,直到根目录
dirPathList.forEach((dp) => {
const dpc = getDirContent(dp, state.dirObject)
if (dpc && !dpc.imageList.length && !dpc.childrenDirs.length) {
const { dirPath } = getUpOneLevelDir(dp)
dispatch('SET_USER_CONFIG_INFO', { selectedDir: dirPath })
dispatch('DIR_IMAGE_LIST_REMOVE_DIR', dp)
}
})
}
})
},
// 图床管理 - 初始化指定目录(即删除指定目录的子目录列表和图片列表) -- OK
DIR_IMAGE_LIST_INIT_DIR({ state, dispatch }, dirPath: string) {
let tempDirObj = state.dirObject
if (dirPath === '/') {
tempDirObj.imageList = []
tempDirObj.childrenDirs = []
dispatch('DIR_IMAGE_LIST_PERSIST')
return
}
const initDirObject = (dirObj: DirObject, dir: string, isInit: boolean) => {
if (!dirObj) {
return state.dirObject
}
const temp = dirObj.childrenDirs?.find((x: DirObject) => x.dir === dir)
if (!temp) {
return state.dirObject
}
if (isInit) {
temp.imageList = []
temp.childrenDirs = []
}
return temp
}
const dirList = dirPath.split('/')
dirList.forEach((d, i) => {
tempDirObj = initDirObject(tempDirObj, d, i === dirList.length - 1)
})
dispatch('DIR_IMAGE_LIST_PERSIST')
},
// 图床管理 - 持久化存储 -- OK
DIR_IMAGE_LIST_PERSIST({ state }) {
localStorage.setItem(PICX_MANAGEMENT, JSON.stringify(state.dirObject))
},
// 图床管理 - 退出登录
DIR_IMAGE_LOGOUT({ state }) {
state.dirObject = createDirObject('/', '/')
}
},
getters: {
getDirObject: (state: any) => state.dirObject
}
}
export default dirImageListModule

View File

@@ -0,0 +1,14 @@
import { UploadedImageModel } from '@/common/model/upload.model'
export interface DirObject {
type: 'dir'
dir: string
dirPath: string
childrenDirs: DirObject[]
imageList: UploadedImageModel[]
}
export default interface DirImageListStateTypes {
name: string
dirObject: DirObject
}

View File

@@ -0,0 +1,73 @@
import { DirObject } from '@/store/modules/dir-image-list/types'
/**
* 构造一个新的目录对象
* @param dir
* @param dirPath
*/
export const createDirObject = (dir: string, dirPath: string): DirObject => {
return {
type: 'dir',
dir,
dirPath,
childrenDirs: [],
imageList: []
}
}
/**
* 获取上一级目录
* @param dirPath
*/
export const getUpOneLevelDir = (dirPath: string) => {
if (dirPath === '/') {
return {
currentDir: '/',
dirPath: '/'
}
}
const dirList = dirPath.split('/')
if (dirList.length === 1) {
return {
currentDir: '/',
dirPath: '/'
}
}
if (dirList.length > 1) {
dirList.length -= 1
return {
currentDir: dirList[dirList.length - 1],
dirPath: dirList.join('/')
}
}
return {
currentDir: '/',
dirPath: '/'
}
}
/**
* 获取上级目录列表
* @param dirPath
*/
export const getUpLevelDirList = (dirPath: string) => {
if (dirPath === '/') {
return []
}
const dirList = dirPath.split('/')
const tempL: string[] = []
let tempP = ''
dirList.forEach((d, i) => {
tempP += `${i > 0 ? '/' : ''}${d}`
tempL.unshift(tempP)
})
return tempL
}

View File

@@ -0,0 +1,41 @@
import { Module } from 'vuex'
import { ImageCardStateTypes } from './types'
import RootStateTypes from '../../types'
import { UploadedImageModel } from '@/common/model/upload.model'
const imageCardModule: Module<ImageCardStateTypes, RootStateTypes> = {
state: {
imgCardArr: []
},
mutations: {
IMAGE_CARD(state: ImageCardStateTypes, { imageObj }) {
const { uuid, checked } = imageObj
if (checked) {
state.imgCardArr.forEach((item) => {
if (item.uuid === uuid) {
// eslint-disable-next-line no-param-reassign
item.checked = true
}
})
}
},
REPLACE_IMAGE_CARD(state: ImageCardStateTypes, { checkedImgArr }) {
if (checkedImgArr.length > 0) {
state.imgCardArr = checkedImgArr
} else {
state.imgCardArr = []
}
}
},
actions: {},
getters: {
getImageCardArr: (state: ImageCardStateTypes) => state.imgCardArr,
getImageCardCheckedArr: (state: ImageCardStateTypes) => {
return state.imgCardArr.filter((item: UploadedImageModel) => {
return item.checked
})
}
}
}
export default imageCardModule

View File

@@ -0,0 +1,5 @@
import { UploadedImageModel } from '@/common/model/upload.model'
export interface ImageCardStateTypes {
imgCardArr: UploadedImageModel[]
}

View File

@@ -0,0 +1,29 @@
import { Module } from 'vuex'
import ImageViewerStateTypes from './types'
import RootStateTypes from '../../types'
const imageViewerModule: Module<ImageViewerStateTypes, RootStateTypes> = {
state: {
imageViewer: {
imgInfo: null,
isShow: false
}
},
mutations: {
IMAGE_VIEWER(state: ImageViewerStateTypes, { imgInfo, isShow }) {
state.imageViewer.imgInfo = imgInfo
state.imageViewer.isShow = isShow
},
IMAGE_VIEWER_LOGOUT(state: ImageViewerStateTypes) {
state.imageViewer.isShow = false
state.imageViewer.imgInfo = null
}
},
actions: {},
getters: {
getImageViewer: (state: ImageViewerStateTypes) => state.imageViewer
}
}
export default imageViewerModule

View File

@@ -0,0 +1,12 @@
export interface ImgInfo {
name: string
size: number
lastModified: number
url: string
}
export default interface ImageViewerStateTypes {
imageViewer: {
imgInfo: ImgInfo | null
isShow: boolean
}
}

View File

@@ -0,0 +1,92 @@
import { ToUploadImageModel } from '@/common/model/upload.model'
import { Module } from 'vuex'
import ToUploadImageStateTypes from '@/store/modules/to-upload-image/types'
import RootStateTypes from '@/store/types'
const toUploadImageModule: Module<ToUploadImageStateTypes, RootStateTypes> = {
state: {
curImgBase64Url: '',
curImgUuid: '',
list: [],
uploadedNumber: 0
},
actions: {
// 要上传的图片列表 - 增加图片项
TO_UPLOAD_IMAGE_LIST_ADD({ state }, item: ToUploadImageModel) {
state.list.unshift(item)
},
// 要上传的图片列表 - 设置当前图片的 Base64Url
TO_UPLOAD_IMAGE_SET_CURRENT({ state }, { uuid, base64Url }) {
state.curImgUuid = uuid
state.curImgBase64Url = base64Url
},
// 要上传的图片列表 - 上传完成的图片数量 +1
TO_UPLOAD_IMAGE_UPLOADED({ state }) {
state.uploadedNumber += 1
},
// 要上传的图片列表 - 删除图片项
TO_UPLOAD_IMAGE_LIST_REMOVE({ state }, uuid: string) {
if (state.list.length > 0) {
const rmIndex = state.list.findIndex((v: ToUploadImageModel) => v.uuid === uuid)
if (rmIndex !== -1) {
state.list.splice(rmIndex, 1)
}
if (state.list.length === 0) {
state.curImgBase64Url = ''
state.uploadedNumber = 0
} else if (state.curImgUuid === uuid) {
const cur = state.list[0]
state.curImgBase64Url = cur.imgData.base64Url
state.curImgUuid = cur.uuid
}
}
},
// 要上传的图片列表 - 上传失败时,在列表中移除已上传的图片
TO_UPLOAD_IMAGE_LIST_FAIL({ state }) {
if (state.list.length > 0) {
const temp: ToUploadImageModel[] = state.list.filter(
(v: ToUploadImageModel) => v.uploadStatus.progress !== 100
)
if (temp.length > 0) {
state.list = temp
state.uploadedNumber = 0
state.curImgBase64Url = temp[0].imgData.base64Url
}
}
},
// 要上传的图片列表 - 清空 Url
TO_UPLOAD_IMAGE_CLEAN_URL({ state }) {
state.curImgBase64Url = ''
},
// 要上传的图片列表 - 清空 List
TO_UPLOAD_IMAGE_CLEAN_LIST({ state }) {
state.list = []
},
// 要上传的图片列表 - 清空上传完成数量
TO_UPLOAD_IMAGE_CLEAN_UPLOADED_NUMBER({ state }) {
state.uploadedNumber = 0
},
// 要上传的图片列表 - 退出登录
TO_UPLOAD_IMAGE_LOGOUT({ state }) {
state.curImgBase64Url = ''
state.list = []
state.uploadedNumber = 0
}
},
getters: {
getToUploadImageList: (state: ToUploadImageStateTypes) => state.list,
getToUploadImage: (state: ToUploadImageStateTypes) => state
}
}
export default toUploadImageModule

View File

@@ -0,0 +1,6 @@
export default interface ToUploadImageStateTypes {
curImgBase64Url: string
curImgUuid: string
list: any[]
uploadedNumber: number
}

View File

@@ -0,0 +1,25 @@
import { Module } from 'vuex'
import UploadAreaActiveStateTypes from './types'
import RootStateTypes from '../../types'
const uploadAreaActiveModule: Module<UploadAreaActiveStateTypes, RootStateTypes> = {
state: {
uploadAreaActive: false
},
mutations: {
// 修改上传区域激活状态
CHANGE_UPLOAD_AREA_ACTIVE(state: UploadAreaActiveStateTypes, isActive: boolean) {
state.uploadAreaActive = isActive
},
UPLOAD_AREA_ACTIVE_LOGOUT(state: UploadAreaActiveStateTypes) {
state.uploadAreaActive = false
}
},
actions: {},
getters: {
getUploadAreaActive: (state: UploadAreaActiveStateTypes) => state.uploadAreaActive
}
}
export default uploadAreaActiveModule

View File

@@ -0,0 +1,3 @@
export default interface UploadAreaActiveStateTypes {
uploadAreaActive: boolean
}

View File

@@ -0,0 +1,24 @@
import { Module } from 'vuex'
import UploadAreaActiveStateTypes from './types'
import RootStateTypes from '../../types'
const uploadSettingsModule: Module<UploadAreaActiveStateTypes, RootStateTypes> = {
state: {
uploadSettings: {
isSetMaxSize: true,
imageMaxSize: 30 * 1024
}
},
mutations: {
UPLOAD_SETTINGS_LOGOUT(state: UploadAreaActiveStateTypes) {
state.uploadSettings.isSetMaxSize = true
state.uploadSettings.imageMaxSize = 50 * 1024
}
},
actions: {},
getters: {
getUploadSettings: (state) => state.uploadSettings
}
}
export default uploadSettingsModule

View File

@@ -0,0 +1,6 @@
export default interface UploadSettingsStateTypes {
uploadSettings: {
isSetMaxSize: boolean
imageMaxSize: number
}
}

View File

@@ -0,0 +1,53 @@
import { Module } from 'vuex'
import UploadedImageListStateTypes from '@/store/modules/uploaded-image-list/types'
import RootStateTypes from '@/store/types'
import { PICX_UPLOADED } from '@/common/model/storage.model'
import { UploadedImageModel } from '@/common/model/upload.model'
const initUploadedImageList = (): UploadedImageModel[] => {
const imageList: string | null = sessionStorage.getItem(PICX_UPLOADED)
return imageList ? JSON.parse(imageList) : []
}
const uploadedImageListModule: Module<UploadedImageListStateTypes, RootStateTypes> = {
state: {
uploadedImageList: initUploadedImageList()
},
mutations: {},
actions: {
// 上传完成的图片列表 - 增加
UPLOADED_LIST_ADD({ state, dispatch }, item: UploadedImageModel) {
state.uploadedImageList.unshift(item)
dispatch('UPLOADED_LIST_PERSIST')
},
// 上传完成的图片列表 - 删除
UPLOADED_LIST_REMOVE({ state, dispatch }, uuid: string) {
if (state.uploadedImageList.length > 0) {
const rmIndex = state.uploadedImageList.findIndex((v) => v.uuid === uuid)
if (rmIndex !== -1) {
state.uploadedImageList.splice(rmIndex, 1)
dispatch('UPLOADED_LIST_PERSIST')
}
}
},
// 上传完成的图片列表 - 持久化
UPLOADED_LIST_PERSIST({ state }) {
sessionStorage.setItem(PICX_UPLOADED, JSON.stringify(state.uploadedImageList))
},
// 上传完成的图片列表 - 退出登录
UPLOADED_LIST_LOGOUT({ state }) {
state.uploadedImageList = []
}
},
getters: {
getUploadedImageList: (state: any) => state.uploadedImageList
}
}
export default uploadedImageListModule

View File

@@ -0,0 +1,5 @@
import { UploadedImageModel } from '@/common/model/upload.model'
export default interface UploadedImageListStateTypes {
uploadedImageList: UploadedImageModel[]
}

View File

@@ -0,0 +1,155 @@
import { Module } from 'vuex'
import {
BranchModeEnum,
UserConfigInfoModel
} from '@/common/model/user-config-info.model'
import { PICX_CONFIG } from '@/common/model/storage.model'
import { deepAssignObject, cleanObject } from '@/utils/object-helper'
import UserConfigInfoStateTypes from '@/store/modules/user-config-info/types'
import RootStateTypes from '@/store/types'
import { DirModeEnum } from '@/common/model/dir.model'
import TimeHelper from '@/utils/time-helper'
const initUserConfigInfo = (): UserConfigInfoModel => {
const initConfig: UserConfigInfoModel = {
token: '',
owner: '',
email: '',
name: '',
avatarUrl: '',
selectedRepos: '',
reposList: [],
branchMode: BranchModeEnum.reposBranch,
branchList: [],
selectedBranch: '',
selectedDir: '',
dirMode: DirModeEnum.reposDir,
dirList: [],
loggingStatus: false,
selectedDirList: []
}
const LSConfig: string | null = localStorage.getItem(PICX_CONFIG)
if (LSConfig) {
// Assign: oldConfig -> initConfig
deepAssignObject(initConfig, JSON.parse(LSConfig))
if (initConfig.selectedBranch && !initConfig.branchList.length) {
initConfig.branchList = [
{
value: initConfig.selectedBranch,
label: initConfig.selectedBranch
}
]
}
if (initConfig.dirMode === DirModeEnum.autoDir) {
initConfig.selectedDir = TimeHelper.getYyyyMmDd()
}
return initConfig
}
return initConfig
}
const userConfigInfoUpdate = (state: UserConfigInfoStateTypes): void => {
const { selectedDir, selectedBranch, dirMode } = state.userConfigInfo
if (dirMode === 'newDir') {
const strList = selectedDir.split('')
let count = 0
let newStr = ''
// eslint-disable-next-line no-plusplus
for (let i = 0; i < strList.length; i++) {
if (strList[i] === ' ' || strList[i] === '.' || strList[i] === '、') {
strList[i] = '-'
}
if (strList[i] === '/') {
count += 1
}
if (count >= 3) {
break
}
newStr += strList[i]
}
state.userConfigInfo.selectedDir = newStr
}
state.userConfigInfo.selectedBranch = selectedBranch.replace(/\s+/g, '-')
}
const userConfigInfoModule: Module<UserConfigInfoStateTypes, RootStateTypes> = {
state: {
userConfigInfo: initUserConfigInfo()
},
actions: {
// 持久化状态获取
USER_CONFIG_INFO_RESET({ state }) {
state.userConfigInfo = initUserConfigInfo()
},
// 设置用户配置信息
SET_USER_CONFIG_INFO(
{ state, dispatch },
configInfo: UserConfigInfoStateTypes,
needPersist: boolean = true
) {
// eslint-disable-next-line no-restricted-syntax
for (const key in configInfo) {
// eslint-disable-next-line no-prototype-builtins
if (state.userConfigInfo.hasOwnProperty(key)) {
// @ts-ignore
state.userConfigInfo[key] = configInfo[key]
} else if (key === 'needPersist') {
// eslint-disable-next-line
needPersist = false
}
}
if (!needPersist) return
dispatch('USER_CONFIG_INFO_PERSIST')
},
// 用户配置信息 - 增加目录
USER_CONFIG_INFO_ADD_DIR({ state, dispatch }, dir: string) {
if (!state.userConfigInfo.dirList.some((v: any) => v.value === dir)) {
state.userConfigInfo.dirList.push({ label: dir, value: dir })
dispatch('USER_CONFIG_INFO_PERSIST')
}
},
// 用户配置信息 - 删除目录列表的某个目录
USER_CONFIG_INFO_REMOVE_DIR({ state, dispatch }, dir: string) {
const { dirList } = state.userConfigInfo
if (dirList.some((v: any) => v.value === dir)) {
const rmIndex = dirList.findIndex((v: any) => v.value === dir)
dirList.splice(rmIndex, 1)
dispatch('USER_CONFIG_INFO_PERSIST')
}
},
// 持久化用户配置信息
USER_CONFIG_INFO_PERSIST({ state }) {
userConfigInfoUpdate(state)
localStorage.setItem(PICX_CONFIG, JSON.stringify(state.userConfigInfo))
},
// 修改 userConfigInfo 但无需持久化 (目前提供图床管理页面使用)
USER_CONFIG_INFO_NOT_PERSIST({ state }) {
userConfigInfoUpdate(state)
},
// 退出登录
USER_CONFIG_INFO_LOGOUT({ state }) {
cleanObject(state.userConfigInfo)
}
},
getters: {
getUserLoggingStatus: (state: UserConfigInfoStateTypes): boolean =>
state.userConfigInfo.loggingStatus,
getUserConfigInfo: (state: UserConfigInfoStateTypes): UserConfigInfoModel =>
state.userConfigInfo
}
}
export default userConfigInfoModule

View File

@@ -0,0 +1,5 @@
import { UserConfigInfoModel } from '@/common/model/user-config-info.model'
export default interface UserConfigInfoStateTypes {
userConfigInfo: UserConfigInfoModel
}

View File

@@ -0,0 +1,67 @@
import { Module } from 'vuex'
import { PICX_SETTINGS } from '@/common/model/storage.model'
import { deepAssignObject } from '@/utils/object-helper'
import UserConfigInfoStateTypes from '@/store/modules/user-config-info/types'
import RootStateTypes from '@/store/types'
import { CompressEncoderMap } from '@/utils/compress'
import { UserSettingsModel } from '@/common/model/user-settings.model'
import UserSettingsStateTypes from '@/store/modules/user-settings/types'
import { getLocalItem } from '@/utils/common-utils'
import ExternalLinkType from '@/common/model/external-link.model'
const initSettings: UserSettingsModel = {
defaultHash: true,
defaultMarkdown: false,
defaultPrefix: false,
prefixName: '',
isCompress: true,
compressEncoder: CompressEncoderMap.webP,
themeMode: 'light',
autoLightThemeTime: ['08:00', '19:00'],
elementPlusSize: 'default',
externalLinkType: ExternalLinkType.staticaly
}
const initUserSettings = (): UserSettingsModel => {
const LSSettings = getLocalItem(PICX_SETTINGS)
if (LSSettings) {
deepAssignObject(initSettings, LSSettings)
}
return initSettings
}
const userSettingsModule: Module<UserSettingsStateTypes, RootStateTypes> = {
state: {
userSettings: initUserSettings()
},
actions: {
// 设置
SET_USER_SETTINGS({ state }, configInfo: UserConfigInfoStateTypes) {
// eslint-disable-next-line no-restricted-syntax
for (const key in configInfo) {
// eslint-disable-next-line no-prototype-builtins
if (state.userSettings.hasOwnProperty(key)) {
// @ts-ignore
state.userSettings[key] = configInfo[key]
}
}
},
// 持久化
USER_SETTINGS_PERSIST({ state }) {
localStorage.setItem(PICX_SETTINGS, JSON.stringify(state.userSettings))
},
// 退出登录
USER_SETTINGS_LOGOUT({ state }) {
state.userSettings = initSettings
}
},
getters: {
getUserSettings: (state): UserSettingsModel => state.userSettings
}
}
export default userSettingsModule

View File

@@ -0,0 +1,5 @@
import { UserSettingsModel } from '@/common/model/user-settings.model'
export default interface UserSettingsStateTypes {
userSettings: UserSettingsModel
}

21
picx/src/store/types.ts Normal file
View File

@@ -0,0 +1,21 @@
import DirImageListStateTypes from './modules/dir-image-list/types'
import ToUploadImageStateTypes from './modules/to-upload-image/types'
import UploadedImageListStateTypes from './modules/uploaded-image-list/types'
import UserConfigInfoStateTypes from './modules/user-config-info/types'
import ImageViewerStateTypes from './modules/image-viewer/types'
import UploadAreaActiveStateTypes from './modules/upload-area-active/types'
import UploadSettingsStateTypes from './modules/upload-settings/types'
export default interface RootStateTypes {
rootName: string
}
export interface AllStateTypes extends RootStateTypes {
dirImageListModule: DirImageListStateTypes
toUploadImageModule: ToUploadImageStateTypes
uploadedImageListModule: UploadedImageListStateTypes
userConfigInfoModule: UserConfigInfoStateTypes
imageViewerModule: ImageViewerStateTypes
uploadAreaActiveModule: UploadAreaActiveStateTypes
uploadSettingsModule: UploadSettingsStateTypes
}

98
picx/src/style/base.styl Normal file
View File

@@ -0,0 +1,98 @@
@import './theme.styl'
@import './variables.styl'
$component-interval = 16rem
$box-border-radius = 6rem
$content-max-width = 888rem
$scrollbar-size = 8rem
:root {
font-size 1px
+picx-tablet() {
font-size 0.9px
}
+picx-mobile() {
font-size 0.8px
}
}
html, body {
position relative
padding 0
margin 0
width 100%
height 100%
color var(--default-text-color)
}
a {
text-decoration none
font-size 1.5rem
color var(--default-text-color)
}
a:link {
color var(--default-text-color)
text-decoration none
}
ul, ol, li {
list-style none
}
* {
&::-webkit-scrollbar {
height $scrollbar-size
width $scrollbar-size
}
&::-webkit-scrollbar-thumb {
background var(--scrollbar-color)
border-radius $box-border-radius
}
&::-webkit-scrollbar-track {
background transparent
}
}
.flex-center {
display flex
justify-content center
align-items center
}
.flex-start {
display flex
justify-content flex-start
align-items center
}
.page-container {
width 100%
height 100%
box-sizing border-box
padding 30rem
background var(--background-color)
border-top-left-radius $box-border-radius
border-top-right-radius $box-border-radius
overflow-y auto
}
.clear {
&::after {
content ''
display block
clear both
visibility hidden
overflow hidden
height 0
}
}

25
picx/src/style/theme.styl Normal file
View File

@@ -0,0 +1,25 @@
@import './variables.styl'
:root {
root-color('light')
}
@media (prefers-color-scheme light) {
:root {
root-color('light')
}
}
@media (prefers-color-scheme dark) {
:root {
root-color('dark')
}
}
.light {
root-color('light')
}
.dark {
root-color('dark')
}

View File

@@ -0,0 +1,110 @@
// ========================================================================================
// media query
// ========================================================================================
$media-max-width-tablet = 800px; // media query max width (tablet)
$media-max-width-mobile = 500px; // media query max width (mobile)
picx-tablet()
@media (max-width: $media-max-width-tablet)
{ block }
picx-mobile()
@media (max-width: $media-max-width-mobile)
{ block }
// ========================================================================================
// z-index
// ========================================================================================
$z-index-1 = 1001;
$z-index-2 = 1002;
$z-index-3 = 1003;
$z-index-4 = 1004;
$z-index-5 = 1005;
$z-index-6 = 1006;
$z-index-7 = 1007;
$z-index-8 = 1008;
$z-index-9 = 1009;
// ========================================================================================
// light mode color
// ========================================================================================
$primary-color = #0066CC;
$background-color = #fff;
$second-background-color = darken($background-color, 5%);
$third-background-color = darken($background-color, 10%);
$default-text-color = #50505c;
$first-text-color = darken($default-text-color, 10%);
$second-text-color = darken($default-text-color, 5%);
$third-text-color = lighten($default-text-color, 30%);
$fourth-text-color = lighten($default-text-color, 90%);
$border-color = darken($background-color, 30%);
$selection-color = lighten($primary-color, 10%);
$shadow-color = rgba(0, 0, 0, 0.2);
$shadow-hover-color = rgba(0, 0, 0, 0.28);
$scrollbar-color = darken($background-color, 20%);
$scroll-bar-bg-color = darken($background-color, 30%);
$upload-area-focus-color = #0066CC;
$await-upload-color = #E6A23C;
$uploading-color = #409EFF;
$uploaded-color = #67C23A;
$markdown-icon-color = #808080;
$markdown-icon-active-color = darken($markdown-icon-color, 30%);
// ========================================================================================
// dark mode color
// ========================================================================================
$dark-primary-color = #0066CC;
$dark-background-color = #2a2a2f;
$dark-second-background-color = darken($dark-background-color, 10%);
$dark-third-background-color = darken($dark-background-color, 15%);
$dark-default-text-color = #bebec6;
$dark-first-text-color = lighten($dark-default-text-color, 30%);
$dark-second-text-color = lighten($dark-default-text-color, 20%);
$dark-third-text-color = darken($dark-default-text-color, 20%);
$dark-fourth-text-color = darken($dark-default-text-color, 80%);
$dark-border-color = lighten($dark-background-color, 20%);
$dark-selection-color = $selection-color;
$dark-shadow-color = rgba(128, 128, 128, 0.2);
$dark-shadow-hover-color = rgba(128, 128, 128, 0.28);
$dark-scrollbar-color = darken($dark-background-color, 20%);
$dark-scroll-bar-bg-color = lighten($dark-background-color, 30%);
$dark-upload-area-focus-color = #1070d0;
$dark-await-upload-color = #c08327;
$dark-uploading-color = #287dd5;
$dark-uploaded-color = #55b626;
$dark-markdown-icon-color = #aaa;
$dark-markdown-icon-active-color = lighten($dark-markdown-icon-color, 30%);
// ========================================================================
// light/dark mode color
// ========================================================================
root-color(mode) {
--background-color: mode == 'light' ? $background-color : $dark-background-color;
--second-background-color: mode == 'light' ? $second-background-color : $dark-second-background-color;
--third-background-color: mode == 'light' ? $third-background-color : $dark-third-background-color;
--primary-color: mode == 'light' ? $primary-color : $dark-primary-color;
--first-text-color: mode == 'light' ? $first-text-color : $dark-first-text-color;
--second-text-color: mode == 'light' ? $second-text-color : $dark-second-text-color;
--third-text-color: mode == 'light' ? $third-text-color : $dark-third-text-color;
--fourth-text-color: mode == 'light' ? $fourth-text-color : $dark-fourth-text-color;
--default-text-color: mode == 'light' ? $default-text-color : $dark-default-text-color;
--border-color: mode == 'light' ? $border-color : $dark-border-color;
--selection-color: mode == 'light' ? $selection-color : $dark-selection-color;
--shadow-color: mode == 'light' ? $shadow-color : $dark-shadow-color;
--shadow-hover-color: mode == 'light' ? $shadow-hover-color : $dark-shadow-hover-color;
--scrollbar-color: mode == 'light' ? $scrollbar-color : $dark-scrollbar-color;
--scroll-bar-bg-color: mode == 'light' ? $scroll-bar-bg-color : $dark-scroll-bar-bg-color;
--upload-area-focus-color : mode == 'light' ? $upload-area-focus-color : $dark-upload-area-focus-color;
--await-upload-color : mode == 'light' ? $await-upload-color : $dark-await-upload-color;
--uploading-color : mode == 'light' ? $uploading-color : $dark-uploading-color;
--uploaded-color : mode == 'light' ? $uploaded-color : $dark-uploaded-color;
--markdown-icon-color : mode == 'light' ? $markdown-icon-color : $dark-markdown-icon-color;
--markdown-icon-active-color : mode == 'light' ? $markdown-icon-active-color : $dark-markdown-icon-active-color;
}

51
picx/src/utils/axios.ts Normal file
View File

@@ -0,0 +1,51 @@
import Axios from 'axios'
import { PICX_CONFIG } from '@/common/model/storage.model'
const baseURL = 'https://api.github.com'
const axios = Axios.create({
baseURL,
timeout: 300000 // request timeout 请求超时 5m
})
axios.defaults.headers['Content-Type'] = 'application/json'
// 发起请求之前的拦截器(前置拦截)
axios.interceptors.request.use(
(config) => {
const userConfig = localStorage.getItem(PICX_CONFIG)
if (userConfig) {
const { token } = JSON.parse(userConfig)
if (token) {
config.headers.Authorization = `token ${token}`
}
}
return config
},
(error) => {
return Promise.reject(error)
}
)
// 响应拦截器
axios.interceptors.response.use(
(response) => {
return response
},
(error) => {
if (error.response && error.response.data) {
const code = error.response.status
const msg = error.response.data.message
ElMessage.error(`Code: ${code}, Message: ${msg}`)
console.error(`[PicX Error]`, error.response)
} else {
ElMessage.error(`${error}`)
}
return error.response
}
)
export default axios

View File

@@ -0,0 +1,27 @@
/**
* Get JavaScript basic data types
* @param data
* @returns {string} array | string | number ...
*/
export const getType = (data: string) => {
const type = Object.prototype.toString.call(data).split(' ')[1]
return type.substring(0, type.length - 1).toLowerCase()
}
/**
* Gets a string(uuid) that is not repeated
* @returns uuid {string}
*/
export const getUuid = () => {
return Number(Math.random().toString().substr(2, 5) + Date.now()).toString(36)
}
/**
* get localStorage value
* @param key
* @returns {*}
*/
export const getLocalItem = (key: string) => {
const temp = window.localStorage.getItem(key)
return temp ? JSON.parse(temp) : null
}

View File

@@ -0,0 +1,26 @@
import Compress from '@yireen/squoosh-browser'
import {
defaultPreprocessorState,
defaultProcessorState,
encoderMap,
EncoderState
} from '@yireen/squoosh-browser/dist/client/lazy-app/feature-meta'
export enum CompressEncoderMap {
mozJPEG = 'mozJPEG',
avif = 'avif',
webP = 'webP'
}
export const compress = async (file: File, encoder: CompressEncoderMap) => {
const compress = new Compress(file, {
encoderState: {
type: encoder,
options: encoderMap[encoder].meta.defaultOptions
} as EncoderState,
processorState: defaultProcessorState,
preprocessorState: defaultPreprocessorState
})
return compress.process()
}

View File

@@ -0,0 +1,42 @@
import { ToUploadImageModel } from '../common/model/upload.model'
export default (): ToUploadImageModel => {
return {
uuid: '',
uploadStatus: {
progress: 0,
uploading: false
},
imgData: {
base64Content: '',
base64Url: ''
},
fileInfo: {
size: 0,
lastModified: 0
},
filename: {
name: '',
hash: '',
suffix: '',
prefixName: '',
now: '',
initName: '',
newName: 'xxx',
isHashRename: true,
isRename: false,
isPrefix: false
},
externalLink: {
github: '',
jsdelivr: '',
staticaly: '',
cloudflare: ''
}
}
}

View File

@@ -0,0 +1,64 @@
import { UploadedImageModel } from '../common/model/upload.model'
import { UserConfigInfoModel } from '../common/model/user-config-info.model'
import axios from '@/utils/axios'
import { deleteStatusEnum } from '../common/model/delete.model'
import { store } from '@/store'
let deleteIndex = 0
export async function deleteSingleImage(
imageObj: UploadedImageModel,
userConfigInfo: UserConfigInfoModel
): Promise<boolean> {
// eslint-disable-next-line no-param-reassign
imageObj.deleting = true
const { owner, selectedRepos } = userConfigInfo
return new Promise((resolve, reject) => {
axios
.delete(`/repos/${owner}/${selectedRepos}/contents/${imageObj.path}`, {
data: {
owner,
repo: selectedRepos,
path: imageObj.path,
message: 'delete picture via PicX(https://github.com/XPoet/picx)',
sha: imageObj.sha
}
})
.then((res) => {
if (res && res.status === 200) {
// eslint-disable-next-line no-param-reassign
imageObj.deleting = false
store.dispatch('UPLOADED_LIST_REMOVE', imageObj.uuid)
store.dispatch('DIR_IMAGE_LIST_REMOVE', imageObj)
resolve(true)
} else {
// eslint-disable-next-line no-param-reassign
imageObj.deleting = false
resolve(false)
}
})
.catch((err) => {
reject(err)
})
})
}
export async function delelteBatchImage(
imgCardArr: Array<UploadedImageModel>,
userConfigInfo: UserConfigInfoModel
) {
if (deleteIndex >= imgCardArr.length) {
return deleteStatusEnum.deleted
}
if (await deleteSingleImage(imgCardArr[deleteIndex], userConfigInfo)) {
if (deleteIndex < imgCardArr.length) {
deleteIndex += 1
if (await delelteBatchImage(imgCardArr, userConfigInfo)) {
deleteIndex = 0
return deleteStatusEnum.allDeleted
}
}
return deleteStatusEnum.deleted
}
return deleteStatusEnum.deleteFail
}

23
picx/src/utils/env.ts Normal file
View File

@@ -0,0 +1,23 @@
/* eslint-disable no-restricted-syntax */
import { Recordable, ViteEnv } from '@/common/model/vite-config.model'
// Read all environment variable configuration files to process.env
export default function wrapperEnv(envConf: Recordable): ViteEnv {
const ret: any = {}
for (const envName of Object.keys(envConf)) {
let realName = envConf[envName].replace(/\\n/g, '\n')
if (realName === 'true') {
realName = true
} else if (realName === 'false') {
realName = false
}
if (envName === 'VITE_PORT') {
realName = Number(realName)
}
ret[envName] = realName
process.env[envName] = realName
}
return ret
}

View File

@@ -0,0 +1,178 @@
import ExternalLinkType from '@/common/model/external-link.model'
import { UserConfigInfoModel } from '@/common/model/user-config-info.model'
import { getFilename } from '@/utils/file-handle-helper'
import { UploadedImageModel } from '@/common/model/upload.model'
/**
* 创建承载图片外链文本的 DOM 元素
*/
export const createExternalLinkDom = () => {
let externalLinkDom: any = document.querySelector('.temp-external-link-txt')
if (!externalLinkDom) {
externalLinkDom = document.createElement('textarea')
externalLinkDom.setAttribute('class', 'temp-external-link-txt')
externalLinkDom.style.position = 'absolute'
externalLinkDom.style.top = '-99999rem'
externalLinkDom.style.left = '-99999rem'
document.body.appendChild(externalLinkDom)
}
return externalLinkDom
}
/**
* 生成图片外链
* @param type
* @param content
* @param config
*/
export const generateExternalLink = (
type: ExternalLinkType,
content: any,
config: UserConfigInfoModel
): string => {
const staticalyLink: string = `https://cdn.staticaly.com/gh/${config.owner}/${config.selectedRepos}@${config.selectedBranch}/${content.path}`
const cloudflareLink: string = `https://git.poker/${config.owner}/${config.selectedRepos}/blob/${config.selectedBranch}/${content.path}?raw=true`
const jsdelivrLink: string = `https://cdn.jsdelivr.net/gh/${config.owner}/${config.selectedRepos}@${config.selectedBranch}/${content.path}`
const githubLink: string = decodeURI(content.download_url)
// eslint-disable-next-line default-case
switch (type) {
case ExternalLinkType.staticaly:
return staticalyLink
case ExternalLinkType.cloudflare:
return cloudflareLink
case ExternalLinkType.jsdelivr:
return jsdelivrLink
case ExternalLinkType.github:
return githubLink
default:
return githubLink
}
}
/**
* 图片外链转换为 Markdown 格式
* @param name 图片名
* @param url 图片外链
*/
export const formatMarkdown = (name: string, url: string): string => {
return `![${getFilename(name)}](${url})`
}
/**
* 复制图片外链
* @param img 图片对象
* @param type CDN 类型
*/
export const copyExternalLink = (img: UploadedImageModel, type: ExternalLinkType) => {
let externalLink = ''
let successInfo = ''
const { name, is_transform_md: isMD } = img
switch (type) {
case ExternalLinkType.jsdelivr:
if (isMD) {
externalLink = formatMarkdown(name, img.jsdelivr_cdn_url)
successInfo = 'Markdown 格式的 jsDelivr CDN'
} else {
externalLink = img.jsdelivr_cdn_url
successInfo = 'jsDelivr CDN'
}
break
case ExternalLinkType.staticaly:
if (isMD) {
externalLink = formatMarkdown(name, img.staticaly_cdn_url)
successInfo = 'Markdown 格式的 Staticaly CDN'
} else {
externalLink = img.staticaly_cdn_url
successInfo = 'Staticaly CDN'
}
break
case ExternalLinkType.cloudflare:
if (isMD) {
externalLink = formatMarkdown(name, img.cloudflare_cdn_url)
successInfo = 'Markdown 格式的 Cloudflare CDN'
} else {
externalLink = img.cloudflare_cdn_url
successInfo = 'Cloudflare CDN'
}
break
default:
if (isMD) {
externalLink = formatMarkdown(name, img.github_url)
successInfo = 'Markdown 格式的 GitHub'
} else {
externalLink = img.github_url
successInfo = 'GitHub'
}
}
const externalLinkDom: any = createExternalLinkDom()
externalLinkDom.value = externalLink
externalLinkDom.select()
document.execCommand('copy')
ElMessage.success(`${successInfo} 外链复制成功!`)
}
/**
* 批量复制图片外链
* @param imgCardList 图片列表
* @param type 当前选择的外链类型
*/
export const batchCopyExternalLink = (
imgCardList: Array<UploadedImageModel>,
type: ExternalLinkType
) => {
let externalLink = ''
const externalLinkDom: any = createExternalLinkDom()
externalLinkDom.value = ''
if (imgCardList?.length > 0) {
imgCardList.forEach((item: UploadedImageModel, index) => {
const isMD = item.is_transform_md
switch (type) {
case ExternalLinkType.jsdelivr:
externalLink = isMD
? formatMarkdown(item.name, item.jsdelivr_cdn_url)
: item.jsdelivr_cdn_url
break
case ExternalLinkType.staticaly:
externalLink = isMD
? formatMarkdown(item.name, item.staticaly_cdn_url)
: item.staticaly_cdn_url
break
case ExternalLinkType.cloudflare:
externalLink = isMD
? formatMarkdown(item.name, item.cloudflare_cdn_url)
: item.cloudflare_cdn_url
break
default:
externalLink = isMD
? formatMarkdown(item.name, item.github_url)
: item.github_url
}
if (index < imgCardList.length - 1) {
// eslint-disable-next-line prefer-template
externalLinkDom.value += externalLink + '\n'
} else {
externalLinkDom.value += externalLink
}
})
externalLinkDom.select()
document.execCommand('copy')
ElMessage.success(`批量复制图片链接成功`)
} else {
console.warn('请先选择图片')
}
}

View File

@@ -0,0 +1,50 @@
import { getUuid } from './common-utils'
/**
* get filename
* @param filename
*/
export const getFilename = (filename: string) => {
const splitIndex = filename.indexOf('.')
return filename.substr(0, splitIndex).trim().replace(/\s+/g, '-')
}
/**
* get filename suffix
* @param filename
*/
export const getFileSuffix = (filename: string) => {
const splitIndex = filename.lastIndexOf('.')
return filename.substr(splitIndex + 1, filename.length)
}
export const isImage = (suffix: string): boolean => {
return /(png|jpg|gif|jpeg|webp|avif|svg\+xml|image\/x-icon)$/.test(suffix)
}
/**
* get file size (KB)
* @param size
*/
export const getFileSize = (size: number) => {
return Number((size / 1024).toFixed(2))
}
/**
* filename handle
* @param filename
*/
export const filenameHandle = (filename: string | undefined) => {
if (filename) {
return {
name: getFilename(filename),
hash: getUuid(),
suffix: getFileSuffix(filename)
}
}
return {
name: '',
hash: '',
suffix: ''
}
}

View File

@@ -0,0 +1,42 @@
import { computed } from 'vue'
import { UploadedImageModel } from '@/common/model/upload.model'
import { getUuid } from '@/utils/common-utils'
import { generateExternalLink } from '@/utils/external-link-handler'
import ExternalLinkType from '@/common/model/external-link.model'
import { store } from '@/store'
const userConfigInfo = computed(() => store.getters.getUserConfigInfo).value
export default function structureImageObject(
item: any,
selectedDir: string
): UploadedImageModel {
return {
type: 'image',
uuid: getUuid(),
dir: selectedDir,
name: item.name,
path: item.path,
sha: item.sha,
deleting: false,
is_transform_md: false,
size: item.size,
checked: false,
github_url: generateExternalLink(ExternalLinkType.github, item, userConfigInfo),
jsdelivr_cdn_url: generateExternalLink(
ExternalLinkType.jsdelivr,
item,
userConfigInfo
),
staticaly_cdn_url: generateExternalLink(
ExternalLinkType.staticaly,
item,
userConfigInfo
),
cloudflare_cdn_url: generateExternalLink(
ExternalLinkType.cloudflare,
item,
userConfigInfo
)
}
}

View File

@@ -0,0 +1,59 @@
import { getType } from './common-utils'
/**
* 根据 object 每个 key 上值的数据类型,赋对应的初始值
* @param object
*/
export const cleanObject = (object: any) => {
// eslint-disable-next-line guard-for-in,no-restricted-syntax
for (const key in object) {
// eslint-disable-next-line default-case
switch (getType(object[key])) {
case 'object':
cleanObject(object[key])
break
case 'string':
// eslint-disable-next-line no-param-reassign
object[key] = ''
break
case 'array':
// eslint-disable-next-line no-param-reassign
object[key] = []
break
case 'number':
// eslint-disable-next-line no-param-reassign
object[key] = 0
break
case 'boolean':
// eslint-disable-next-line no-param-reassign
object[key] = false
break
}
}
}
/**
* 将 obj2 对象的值深度赋值给 obj1 对象
* @param obj1{Object}
* @param obj2{Object}
*/
export const deepAssignObject = (obj1: object, obj2: object): any => {
// eslint-disable-next-line no-restricted-syntax
for (const key in obj2) {
// @ts-ignore
if (getType(obj2[key]) !== 'object') {
if (obj1) {
// @ts-ignore
// eslint-disable-next-line no-param-reassign
obj1[key] = obj2[key]
}
} else {
// @ts-ignore
deepAssignObject(obj1[key], obj2[key])
}
}
}

28
picx/src/utils/paste.ts Normal file
View File

@@ -0,0 +1,28 @@
import selectedFileHandle from './selected-file-handle'
const onPaste = (e: any, maxsize: number): Promise<any> | null => {
if (!(e.clipboardData && e.clipboardData.items)) {
return null
}
// eslint-disable-next-line consistent-return
return new Promise((resolve) => {
// eslint-disable-next-line no-plusplus
for (let i = 0, len = e.clipboardData.items.length; i < len; i++) {
const item = e.clipboardData.items[i]
if (item.kind === 'file') {
const pasteFile = item.getAsFile()
selectedFileHandle(pasteFile, maxsize)?.then((result) => {
if (!result) {
return
}
const { base64, originalFile, compressFile } = result
resolve({ base64, originalFile, compressFile })
})
}
}
})
}
export default onPaste

View File

@@ -0,0 +1,3 @@
import { registerSW } from 'virtual:pwa-register'
registerSW()

View File

@@ -0,0 +1,56 @@
import { computed } from 'vue'
import { store } from '@/store'
import createToUploadImageObject from '@/utils/create-to-upload-image'
import { filenameHandle } from './file-handle-helper'
/**
* 根据图片链接获取图片 base64 编码
* @param url 图片路径
* @param ext 图片格式
*/
export function getBase64ByImageUrl(url: string, ext: string): Promise<string | null> {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
const img = new Image()
img.crossOrigin = 'Anonymous'
img.src = url
return new Promise((resolve) => {
img.onload = () => {
const { width } = img
const { height } = img
canvas.width = width // 指定画板的高度,自定义
canvas.height = height // 指定画板的宽度,自定义
ctx?.drawImage(img, 0, 0, width, height) // 参数可自定义
const dataURL: string = canvas.toDataURL(`image/${ext}`)
resolve(dataURL)
}
})
}
// 获取图片对象
export function getImage(base64Data: string, file: any) {
const userSettings = computed(() => store.getters.getUserSettings).value
const curImg = createToUploadImageObject()
curImg.imgData.base64Url = base64Data
// eslint-disable-next-line prefer-destructuring
curImg.imgData.base64Content = base64Data.split(',')[1]
const { name, hash, suffix } = filenameHandle(file.name)
curImg.uuid = hash
curImg.fileInfo.size = file.size
curImg.fileInfo.originSize = file.size
curImg.fileInfo.lastModified = file.lastModified
curImg.filename.name = name
curImg.filename.hash = hash
curImg.filename.suffix = suffix
curImg.filename.now = userSettings.defaultHash
? `${name}.${hash}.${suffix}`
: `${name}.${suffix}`
curImg.filename.initName = name
curImg.filename.isHashRename = userSettings.defaultHash
return curImg
}

View File

@@ -0,0 +1,67 @@
import { store } from '@/store'
import { compress } from './compress'
import { getFileSize, isImage } from './file-handle-helper'
export type handleResult = { base64: string; originalFile: File; compressFile?: File }
const selectedFileHandle = async (
file: File,
maxsize: number
): Promise<handleResult | null> => {
if (!file) {
return null
}
if (!isImage(file.type)) {
ElMessage.error('该文件格式不支持!')
return null
}
let compressFile: NonNullable<File>
const { isCompress, compressEncoder } = store.getters.getUserSettings
const isGif = file.type === 'image/gif'
if (!isGif && isCompress) {
const loadingInstance = ElLoading.service({
target: '.upload-area',
text: '正在压缩图片'
})
compressFile = await compress(file, compressEncoder)
loadingInstance.close()
}
return new Promise((resolve) => {
const reader = new FileReader()
reader.readAsDataURL(!isGif && isCompress ? compressFile : file)
reader.onload = (e: ProgressEvent<FileReader>) => {
const base64: any = e.target?.result
const curImgSize = getFileSize(base64.length)
if (curImgSize >= maxsize) {
// 给出提示,引导用户自行去压缩图片
ElMessageBox.confirm(
`当前图片 ${(curImgSize / 1024).toFixed(
2
)} MCDN 只能加速小于 50 MB 的图片,建议使用第三方工具 TinyPNG 压缩`,
'图片过大,禁止上传',
{
confirmButtonText: '前往 TinyPNG',
cancelButtonText: '放弃上传'
}
)
.then(() => {
window.open('https://tinypng.com/')
})
.catch(() => {
console.log('放弃上传')
})
} else {
resolve({
base64,
originalFile: file,
compressFile: !isGif && isCompress ? compressFile : file
})
}
}
})
}
export default selectedFileHandle

View File

@@ -0,0 +1,51 @@
import { watch, nextTick } from 'vue'
import { useStore } from '@/store'
import { UserSettingsModel } from '@/common/model/user-settings.model'
const setThemeMode = () => {
const store = useStore()
const setBodyClassName = async (theme: 'dark' | 'light') => {
await nextTick(() => {
const body = document.getElementsByTagName('html')[0]
if (theme === 'dark') {
body.classList.remove('light')
body.classList.add('dark')
} else {
body.classList.remove('dark')
body.classList.add('light')
}
})
}
const autoThemeModeTimeHandle = (autoLightThemeTime: string[]) => {
const getTimestamp = (i: number) => {
const D = new Date()
const yyyy = D.getFullYear()
const mm = D.getMonth() + 1
const dd = D.getDate()
return new Date(`${yyyy}/${mm}/${dd} ${autoLightThemeTime[i]}:00`).getTime()
}
const now = Date.now()
return getTimestamp(0) <= now && now <= getTimestamp(1)
}
const setThemeByConfigFn = (settings: UserSettingsModel) => {
const { themeMode, autoLightThemeTime } = settings
if (themeMode === 'auto') {
setBodyClassName(autoThemeModeTimeHandle(autoLightThemeTime) ? 'light' : 'dark')
} else {
setBodyClassName(themeMode)
}
}
watch(
(): UserSettingsModel => store.getters.getUserSettings,
(newValue) => {
setThemeByConfigFn(newValue)
},
{ deep: true, immediate: true }
)
}
export default setThemeMode

View File

@@ -0,0 +1,26 @@
export default class TimeHelper {
private static zerofill(n: number) {
return n < 10 ? `0${n}` : n
}
static getYyyyMmDd(now: number = Date.now()) {
const date: Date = new Date(now)
const yyyy = date.getFullYear()
const MM = date.getMonth() + 1
const DD = date.getDate()
return `${yyyy}${this.zerofill(MM)}${this.zerofill(DD)}`
}
static formatTimestamp(now: number = Date.now()) {
const date: Date = new Date(now)
const YYYY = date.getFullYear()
const MM = date.getMonth() + 1
const DD = date.getDate()
const hh = date.getHours()
const mm = date.getMinutes()
const ss = date.getSeconds()
return `${YYYY}-${this.zerofill(MM)}-${this.zerofill(DD)} ${this.zerofill(
hh
)}:${this.zerofill(mm)}:${this.zerofill(ss)}`
}
}

View File

@@ -0,0 +1,134 @@
import { UserConfigInfoModel } from '@/common/model/user-config-info.model'
import { ToUploadImageModel, UploadedImageModel } from '@/common/model/upload.model'
import axios from '@/utils/axios'
import { store } from '@/store'
import { generateExternalLink } from '@/utils/external-link-handler'
import ExternalLinkType from '@/common/model/external-link.model'
export const uploadUrlHandle = (
config: UserConfigInfoModel,
filename: string
): string => {
let path = ''
if (config.selectedDir !== '/') {
path = `${config.selectedDir}/`
}
return `/repos/${config.owner}/${config.selectedRepos}/contents/${path}${filename}`
}
export function uploadImage_single(
userConfigInfo: UserConfigInfoModel,
img: ToUploadImageModel
): Promise<Boolean> {
const { selectedBranch, email, owner } = userConfigInfo
// eslint-disable-next-line no-param-reassign
img.uploadStatus.uploading = true
const data: any = {
message: 'Upload picture via PicX(https://github.com/XPoet/picx)',
branch: selectedBranch,
content: img.imgData.base64Content
}
if (email) {
data.committer = {
name: owner,
email
}
}
return new Promise((resolve, reject) => {
axios
.put(uploadUrlHandle(userConfigInfo, img.filename.now), data)
.then((res) => {
if (res && res.status === 201) {
// eslint-disable-next-line no-use-before-define
uploadedHandle(res, img, userConfigInfo)
store.dispatch('TO_UPLOAD_IMAGE_UPLOADED', img.uuid)
resolve(true)
} else {
// eslint-disable-next-line no-param-reassign
img.uploadStatus.uploading = false
resolve(false)
}
})
.catch((error) => {
reject(error)
})
})
}
function uploadedHandle(
res: any,
img: ToUploadImageModel,
userConfigInfo: UserConfigInfoModel
) {
const userSettings = store.getters.getUserSettings
// 上传状态处理
// eslint-disable-next-line no-param-reassign
img.uploadStatus.progress = 100
// eslint-disable-next-line no-param-reassign
img.uploadStatus.uploading = false
// 生成 GitHub 外链
// eslint-disable-next-line no-param-reassign
img.externalLink.github = generateExternalLink(
ExternalLinkType.github,
res.data.content,
userConfigInfo
)
// 生成 jsDelivr CDN 外链
// eslint-disable-next-line no-param-reassign
img.externalLink.jsdelivr = generateExternalLink(
ExternalLinkType.jsdelivr,
res.data.content,
userConfigInfo
)
// 生成 Staticaly CDN 外链
// eslint-disable-next-line no-param-reassign
img.externalLink.staticaly = generateExternalLink(
ExternalLinkType.staticaly,
res.data.content,
userConfigInfo
)
// 生成 Cloudflare CDN 外链
// eslint-disable-next-line no-param-reassign
img.externalLink.cloudflare = generateExternalLink(
ExternalLinkType.cloudflare,
res.data.content,
userConfigInfo
)
const item: UploadedImageModel = {
checked: false,
type: 'image',
uuid: img.uuid,
dir: userConfigInfo.selectedDir,
name: res.data.content.name,
path: res.data.content.path,
sha: res.data.content.sha,
github_url: img.externalLink.github,
jsdelivr_cdn_url: img.externalLink.jsdelivr,
staticaly_cdn_url: img.externalLink.staticaly,
cloudflare_cdn_url: img.externalLink.cloudflare,
is_transform_md: userSettings.defaultMarkdown,
deleting: false,
size: img.fileInfo.size
}
// eslint-disable-next-line no-param-reassign
img.uploadedImg = item
// uploadedList 增加图片
store.dispatch('UPLOADED_LIST_ADD', item)
// dirImageList 增加目录
store.dispatch('DIR_IMAGE_LIST_ADD_DIR', userConfigInfo.selectedDir)
// dirImageList 增加图片
store.dispatch('DIR_IMAGE_LIST_ADD_IMAGE', item)
}

View File

@@ -0,0 +1,25 @@
.feedback-page-container {
.help-info-item {
font-size: 16rem;
padding: 6rem;
display: flex;
align-items: center;
margin-bottom: 10rem;
&:last-child {
margin-bottom: 0;
}
}
.description {
font-weight: bold;
line-height: 28rem;
}
.red-text {
color: #de1a1a;
}
}

View File

@@ -0,0 +1,77 @@
<template>
<div class="page-container feedback-page-container">
<div class="help-info-item description">
PicX 是一款基于 GitHub API & jsDelivr 开发的具有 CDN 加速功能的图床工具
<br />
无需下载无需安装打开网站即用免费极速稳定
</div>
<div class="help-info-item">建议将本站添加至浏览器收藏夹方便下次使用 😊</div>
<div class="help-info-item">
作者
<el-link type="primary" href="https://xpoet.cn/" target="_blank">@XPoet</el-link>
</div>
<div class="help-info-item">
仓库
<el-link type="primary" href="https://github.com/XPoet/picx" target="_blank">
https://github.com/XPoet/picx
</el-link>
</div>
<div class="help-info-item">
教程
<el-link
type="primary"
href="https://github.com/XPoet/picx/blob/master/README.md"
target="_blank"
>
https://github.com/XPoet/picx/blob/master/README.md
</el-link>
</div>
<div class="help-info-item">
在使用过程中遇到问题请仔细阅读文档或者给作者提&nbsp;
<el-link
type="primary"
style="font-size: 16rem"
href="https://github.com/XPoet/picx/issues"
target="_blank"
>
Issue
</el-link>
&nbsp;
</div>
<div class="help-info-item red-text">
图片上传缓慢或加载不出来等情况可借助&nbsp;
<el-link
style="font-size: 16rem"
type="primary"
href="https://github.com/Alvin9999/new-pac/wiki"
target="_blank"
>VPN 工具
</el-link>
&nbsp;
</div>
<div class="help-info-item">
<strong>
郑重声明请勿通过本站上传违反你当地法律的图片所造成的一切后果与本站无关
</strong>
</div>
</div>
</template>
<script>
import { defineComponent } from 'vue'
export default defineComponent({
name: 'about'
})
</script>
<style scoped lang="stylus">
@import "about.styl"
</style>

View File

@@ -0,0 +1,13 @@
.config-page-container {
.operation {
text-align right
.el-button {
margin-left 20rem
&:first-child {
margin-left 0
}
}
}
}

View File

@@ -0,0 +1,498 @@
<template>
<div class="page-container config-page-container">
<!-- Token -->
<el-form label-width="70rem" :label-position="labelPosition" class="config-form">
<el-form-item label="Token">
<el-input
v-model="userConfigInfo.token"
clearable
:autofocus="!userConfigInfo.token"
type="password"
show-password
placeholder="请输入 GitHub Token"
></el-input>
</el-form-item>
<el-form-item class="operation">
<el-button
plain
type="primary"
native-type="submit"
@click.prevent="getUserInfo()"
>
确认 Token
</el-button>
</el-form-item>
</el-form>
<!-- 基本信息 -->
<el-form
label-width="70rem"
:label-position="labelPosition"
v-if="userConfigInfo.token"
v-loading="loading"
element-loading-text="加载中..."
>
<el-form-item v-if="userConfigInfo.owner" label="用户名">
<el-input v-model="userConfigInfo.owner" readonly></el-input>
</el-form-item>
<el-form-item v-if="userConfigInfo.email" label="邮箱">
<el-input v-model="userConfigInfo.email" readonly></el-input>
</el-form-item>
<el-form-item v-if="userConfigInfo.reposList.length" label="选择仓库">
<el-select
v-model="userConfigInfo.selectedRepos"
filterable
style="width: 100%"
placeholder="请选择图床仓库..."
@change="selectRepos"
>
<el-option
v-for="(repos, index) in userConfigInfo.reposList"
:key="index"
:label="repos.label"
:value="repos.value"
>
</el-option>
</el-select>
</el-form-item>
</el-form>
<!-- 分支 -->
<el-form
label-width="70rem"
:label-position="labelPosition"
v-if="userConfigInfo.selectedRepos && userConfigInfo.branchList.length"
v-loading="branchLoading"
element-loading-text="加载中..."
>
<!-- 因未验证 API 是否能创建空分支暂时不开启分支选择方式 && 0 -->
<el-form-item v-if="userConfigInfo.selectedRepos && 0" label="分支方式">
<el-radio-group v-model="userConfigInfo.branchMode" @change="branchModeChange">
<el-tooltip
v-if="userConfigInfo.branchList.length"
:content="'选择 ' + userConfigInfo.selectedRepos + ' 仓库下的一个分支'"
placement="top"
>
<el-radio label="reposBranch">
选择 {{ userConfigInfo.selectedRepos }} 仓库下的分支
</el-radio>
</el-tooltip>
<el-tooltip content="手动创建一个新分支" placement="top">
<el-radio label="newBranch">新建分支</el-radio>
</el-tooltip>
</el-radio-group>
</el-form-item>
<el-form-item
v-if="
userConfigInfo.branchList.length > 1 &&
userConfigInfo.branchMode === 'reposBranch'
"
label="选择分支"
>
<el-select
v-model="userConfigInfo.selectedBranch"
filterable
style="width: 100%"
placeholder="请选择分支..."
@change="selectBranch"
>
<el-option
v-for="(repos, reposIndex) in userConfigInfo.branchList"
:key="reposIndex"
:label="repos.label"
:value="repos.value"
>
</el-option>
</el-select>
</el-form-item>
<el-form-item v-if="userConfigInfo.branchMode === 'newBranch'" label="新建分支">
<el-input
v-model="userConfigInfo.selectedBranch"
@input="persistUserConfigInfo()"
clearable
placeholder="请输入新建的分支..."
></el-input>
</el-form-item>
</el-form>
<!-- 目录 -->
<el-form
label-width="70rem"
:label-position="labelPosition"
v-if="userConfigInfo.selectedBranch"
v-loading="dirLoading"
element-loading-text="加载中..."
>
<el-form-item v-if="userConfigInfo.selectedBranch" label="目录方式">
<el-radio-group v-model="userConfigInfo.dirMode" @change="dirModeChange">
<el-tooltip content="手动输入一个新目录" placement="top" :offset="-1">
<el-radio label="newDir">新建目录</el-radio>
</el-tooltip>
<el-tooltip
:content="'图片存储在 ' + userConfigInfo.selectedBranch + ' 分支的根目录下'"
placement="top"
:offset="-1"
>
<el-radio label="rootDir">根目录</el-radio>
</el-tooltip>
<el-tooltip
:content="'根据日期自动创建格式 YYYYMMDD 的目录'"
placement="top"
:offset="-1"
>
<el-radio label="autoDir">自动目录</el-radio>
</el-tooltip>
<el-tooltip
v-if="
userConfigInfo.dirList.length && userConfigInfo.branchMode !== 'newBranch'
"
:content="'选择 ' + userConfigInfo.selectedBranch + ' 分支下的一个目录'"
placement="top"
:offset="-1"
>
<el-radio label="reposDir">
选择 {{ userConfigInfo.selectedRepos }} 仓库目录
</el-radio>
</el-tooltip>
</el-radio-group>
</el-form-item>
<el-form-item v-if="userConfigInfo.dirMode === 'autoDir'" label="自动目录">
<el-input v-model="userConfigInfo.selectedDir" readonly></el-input>
</el-form-item>
<el-form-item v-if="userConfigInfo.dirMode === 'rootDir'" label="根目录">
<el-input v-model="userConfigInfo.selectedDir" readonly></el-input>
</el-form-item>
<el-form-item v-if="userConfigInfo.dirMode === 'newDir'" label="新建目录">
<el-input
v-model="userConfigInfo.selectedDir"
@input="persistUserConfigInfo()"
clearable
placeholder="请输入新建的目录..."
></el-input>
</el-form-item>
<el-form-item
v-if="
userConfigInfo.dirList.length &&
userConfigInfo.dirMode === 'reposDir' &&
userConfigInfo.branchMode !== 'newBranch'
"
label="选择目录"
>
<el-cascader
style="width: 100%"
:props="cascaderProps"
:key="elCascaderKey"
v-model="userConfigInfo.selectedDirList"
filterable
placeholder="请选择一个目录..."
clearable
@change="cascaderChange"
/>
</el-form-item>
</el-form>
<!-- 操作(重置、完成配置) -->
<el-form label-width="70rem" :label-position="labelPosition">
<el-form-item class="operation">
<el-button plain type="warning" @click="reset()" v-if="userConfigInfo.owner">
重置
</el-button>
<el-button
plain
type="success"
@click="goUpload"
v-if="userConfigInfo.selectedRepos"
>
完成配置
</el-button>
</el-form-item>
</el-form>
</div>
</template>
<script lang="ts" setup>
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from '@/store'
import { DirModeEnum } from '@/common/model/dir.model'
import { BranchModeEnum } from '@/common/model/user-config-info.model'
import axios from '@/utils/axios'
import TimeHelper from '@/utils/time-helper'
import { getDirListByPath } from '@/common/api'
const router = useRouter()
const store = useStore()
const userConfigInfo = computed(() => store.getters.getUserConfigInfo).value
const loggingStatus = computed(() => store.getters.getUserConfigInfo).value
const userSettings = computed(() => store.getters.getUserSettings).value
const loading = ref(false)
const dirLoading = ref(false)
const branchLoading = ref(false)
const labelPosition = computed(() => {
return userSettings.elementPlusSize === 'default' ? 'top' : 'right'
})
const elCascaderKey = ref<string>('elCascaderKey')
function persistUserConfigInfo() {
store.dispatch('USER_CONFIG_INFO_PERSIST')
}
function saveUserInfo(res: any) {
userConfigInfo.loggingStatus = true
userConfigInfo.owner = res.data.login
userConfigInfo.name = res.data.name
userConfigInfo.email = res.data.email
userConfigInfo.avatarUrl = res.data.avatar_url
persistUserConfigInfo()
}
function getReposList(reposUrl: string) {
axios
.get(reposUrl, {
params: {
type: 'public',
sort: 'created',
per_page: 100
}
})
.then((res: any) => {
console.log('[getReposList] ', res)
if (res.status === 200 && res.data.length > 0) {
userConfigInfo.reposList = []
// eslint-disable-next-line no-restricted-syntax
for (const repos of res.data) {
if (!repos.fork && !repos.private) {
userConfigInfo.reposList.push({
value: repos.name,
label: repos.name,
desc: repos.description
})
}
}
loading.value = false
persistUserConfigInfo()
}
})
}
async function getDirList() {
dirLoading.value = true
userConfigInfo.dirList = await getDirListByPath()
persistUserConfigInfo()
dirLoading.value = false
}
function dirModeChange(dirMode: DirModeEnum) {
switch (dirMode) {
case DirModeEnum.rootDir:
// 根目录
userConfigInfo.selectedDir = '/'
break
case DirModeEnum.autoDir:
// 自动目录,根据当天日期自动生成
userConfigInfo.selectedDir = TimeHelper.getYyyyMmDd()
break
case DirModeEnum.newDir:
// 手动输入的新建目录
userConfigInfo.selectedDir = 'xxx'
break
case DirModeEnum.reposDir:
// 仓库目录
// eslint-disable-next-line no-case-declarations
const { dirList } = userConfigInfo
if (dirList.length) {
userConfigInfo.selectedDir = dirList[0].value
} else {
userConfigInfo.selectedDir = ''
}
break
default:
userConfigInfo.selectedDir = '/'
break
}
persistUserConfigInfo()
}
function getBranchList(repos: string) {
branchLoading.value = true
axios.get(`/repos/${userConfigInfo.owner}/${repos}/branches`).then((res: any) => {
console.log('[getBranchList] ', res)
if (res && res.status === 200) {
branchLoading.value = false
if (res.data.length > 0) {
// eslint-disable-next-line no-restricted-syntax
for (const item of res.data) {
userConfigInfo.branchList.push({
value: item.name,
label: item.name
})
}
userConfigInfo.branchList.reverse()
userConfigInfo.selectedBranch = userConfigInfo.branchList[0].value
userConfigInfo.branchMode = BranchModeEnum.reposBranch
getDirList()
} else {
userConfigInfo.selectedBranch = 'master'
userConfigInfo.branchMode = BranchModeEnum.newBranch
}
dirModeChange(userConfigInfo.dirMode)
persistUserConfigInfo()
}
})
}
function getUserInfo() {
if (userConfigInfo.token) {
loading.value = true
axios
.get('/user', {
headers: { Authorization: `token ${userConfigInfo.token}` }
})
.then((res: any) => {
console.log('[getUserInfo] ', res)
if (res && res.status === 200) {
saveUserInfo(res)
getReposList(res.data.repos_url)
} else {
loading.value = false
}
})
} else {
ElMessage.warning('Token 不能为空!')
}
}
function selectRepos(repos: string) {
userConfigInfo.branchList = []
userConfigInfo.dirList = []
getBranchList(repos)
persistUserConfigInfo()
}
async function selectBranch(branch: string) {
userConfigInfo.selectedBranch = branch
await getDirList()
elCascaderKey.value = userConfigInfo.selectedBranch
userConfigInfo.selectedDir = userConfigInfo.dirList[0].value
userConfigInfo.selectedDirList = [userConfigInfo.selectedDir]
persistUserConfigInfo()
}
function branchModeChange(mode: BranchModeEnum) {
const selBranch = userConfigInfo.selectedBranch
const bv = userConfigInfo.branchList[0].value
switch (mode) {
case BranchModeEnum.newBranch:
userConfigInfo.selectedBranch = 'xxx'
userConfigInfo.dirMode = DirModeEnum.newDir
userConfigInfo.selectedDir = 'xxx'
break
case BranchModeEnum.reposBranch:
if (selBranch !== bv) {
userConfigInfo.selectedBranch = bv
getDirList()
}
break
default:
userConfigInfo.selectedBranch = ''
break
}
persistUserConfigInfo()
}
function reset() {
loading.value = false
dirLoading.value = false
store.dispatch('LOGOUT')
}
function goUpload() {
const { selectedDir, dirMode } = userConfigInfo
let warningMessage: string = '目录不能为空!'
if (selectedDir === '') {
switch (dirMode) {
case DirModeEnum.newDir:
warningMessage = '请在输入框输入一个新目录!'
break
case DirModeEnum.reposDir:
warningMessage = `请选择 ${userConfigInfo.selectedRepos} 仓库下的一个目录!`
break
default:
warningMessage = '请在输入框输入一个新目录!'
break
}
ElMessage.warning(warningMessage)
} else {
router.push('/upload')
}
}
const cascaderProps = {
lazy: true,
checkStrictly: true,
async lazyLoad(node: any, resolve: any) {
const { level, pathLabels } = node
let dirs: any
if (level === 0) {
dirs = userConfigInfo.dirList
} else {
dirs = await getDirListByPath(pathLabels.join('/'))
}
if (dirs) {
resolve(
dirs.map((x: any) => ({
value: x.value,
label: x.label,
leaf: level >= 2
}))
)
} else {
resolve([])
}
}
}
function cascaderChange(e: string[]) {
userConfigInfo.selectedDirList = e
userConfigInfo.selectedDir = e.join('/')
persistUserConfigInfo()
}
watch(
() => loggingStatus,
(_n) => {
if (!_n) {
loading.value = false
dirLoading.value = false
}
}
)
</script>
<style scoped lang="stylus">
@import "config.styl"
</style>

View File

@@ -0,0 +1,70 @@
@import "../../style/base.styl"
$infoBarHeight = 50rem
.management-page-container {
padding-bottom 0 !important
.content-container {
position relative
width 100%
height 100%
padding-top $infoBarHeight
box-sizing border-box
.top {
position absolute
top 0
left 0
width 100%
height $infoBarHeight
box-sizing border-box
display flex
align-items center
justify-content space-between
font-size 14rem
padding-bottom 20rem
.right {
.btn-icon {
cursor pointer
font-size 22rem
margin-left 10rem
}
}
}
.bottom {
position relative
width 100%
height 100%
box-sizing border-box
border 1rem solid var(--border-color)
.image-list {
width 100%
//height 100%
//max-height calc(100% - 60rem)
margin 0
padding 2rem
list-style none
overflow-y auto
box-sizing border-box
li.image-item {
float left
box-sizing border-box
padding 10rem
&:last-child {
margin-right 0
}
}
}
}
}
}

View File

@@ -0,0 +1,57 @@
import { Store } from 'vuex'
import { DirObject } from '@/store/modules/dir-image-list/types'
function getContent(targetContent: any, dirList: string[], n: number): any {
if (targetContent) {
if (dirList.length === n) {
return targetContent
}
return getContent(
targetContent.childrenDirs?.find((v: any) => v.dir === dirList[n]),
dirList,
// eslint-disable-next-line no-param-reassign,no-plusplus
++n
)
}
return null
}
/**
* 获取当前目录下所有内容(子目录和图片)
* @param dirPath
* @param dirObj
*/
export const getDirContent = (dirPath: string, dirObj: DirObject) => {
if (dirPath === '/') {
return dirObj
}
const dirList: string[] = dirPath.split('/')
return getContent(dirObj, dirList, 0)
}
/**
* 过滤当前目录的内容(子目录或图片)
* @param dirPath
* @param content
* @param type
*/
export const filterDirContent = (dirPath: string, content: any, type: string): any => {
if (type === 'dir') {
return content.childrenDirs?.filter((x: any) => x.type === 'dir')
}
if (type === 'image') {
return content.imageList.filter((x: any) => x.type === 'image')
}
return []
}
export const dirModeHandle = (dir: string, store: Store<any>) => {
if (dir === '/') {
store.dispatch('SET_USER_CONFIG_INFO', {
dirMode: 'rootDir',
needPersist: false
})
}
}

View File

@@ -0,0 +1,201 @@
<template>
<div class="page-container management-page-container">
<div class="content-container">
<div class="top">
<div class="left">
<selected-info-bar />
</div>
<div class="right flex-start">
<el-tooltip
placement="top"
:content="listing ? '切换方块展示' : '切换列表展示'"
>
<el-icon class="btn-icon" @click.stop="toggleListing">
<Tickets v-if="listing" />
<Menu v-if="!listing" />
</el-icon>
</el-tooltip>
<el-tooltip placement="top" content="重新加载图片">
<el-icon class="btn-icon" @click.stop="reloadCurrentDirContent">
<Refresh />
</el-icon>
</el-tooltip>
</div>
</div>
<div class="bottom" v-loading="loadingImageList" element-loading-text="加载中...">
<image-selector
v-if="currentPathImageList.length"
:currentDirImageList="currentPathImageList"
@update:initImageList="currentPathImageList"
:key="renderKey"
></image-selector>
<ul
class="image-list"
:style="{
height: isShowBatchTools ? 'calc(100% - 50rem)' : '100%'
}"
>
<li class="image-item" v-if="userConfigInfo.selectedDir !== '/'">
<folder-card mode="back" />
</li>
<li class="image-item" v-for="(dir, index) in currentPathDirList" :key="index">
<folder-card :folder-obj="dir" />
</li>
<div class="clear"></div>
<li
class="image-item"
v-for="(image, index) in currentPathImageList"
:key="index"
:style="{
width: listing ? '50%' : '230rem',
height: listing ? '80rem' : '240rem'
}"
>
<image-card
:image-obj="image"
:listing="listing"
v-model="activeIndex"
:index="index"
/>
</li>
</ul>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, watch, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from '@/store'
import { getContentByReposPath } from '@/common/api'
import {
dirModeHandle,
filterDirContent,
getDirContent
} from '@/views/management/management.util'
import imageCard from '@/components/image-card/image-card.vue'
import selectedInfoBar from '@/components/selected-info-bar/selected-info-bar.vue'
import folderCard from '@/components/folder-card/folder-card.vue'
import imageSelector from '@/components/image-selector/image-selector.vue'
import { UploadedImageModel } from '@/common/model/upload.model'
const store = useStore()
const router = useRouter()
const userConfigInfo = computed(() => store.getters.getUserConfigInfo).value
const loggingStatus = computed(() => store.getters.getUserLoggingStatus).value
const dirObject = computed(() => store.getters.getDirObject).value
const renderKey = ref(new Date().getTime()) // key for update image-selector component
const loadingImageList = ref(false)
const listing = ref(false)
const activeIndex = ref<number>()
const currentPathDirList = ref([])
const currentPathImageList = ref([])
async function dirContentHandle(dir: string) {
loadingImageList.value = true
const dirContent = getDirContent(dir, dirObject)
if (dirContent) {
const dirs = filterDirContent(dir, dirContent, 'dir')
const images = filterDirContent(dir, dirContent, 'image')
if (!dirs.length && !images.length) {
await getContentByReposPath(dir)
} else {
currentPathDirList.value = dirs
currentPathImageList.value = images
store.commit('REPLACE_IMAGE_CARD', { checkedImgArr: currentPathImageList.value })
}
} else {
await getContentByReposPath(dir)
}
loadingImageList.value = false
}
async function initDirImageList() {
const { selectedDir, dirMode } = userConfigInfo
if (
(dirMode === 'newDir' || dirMode === 'autoDir') &&
!getDirContent(selectedDir, dirObject)
) {
userConfigInfo.selectedDir = '/'
userConfigInfo.dirMode = 'rootDir'
}
if (!dirObject.imageList.length && !dirObject.childrenDirs.length) {
await getContentByReposPath(userConfigInfo.selectedDir)
return
}
await dirContentHandle(userConfigInfo.selectedDir)
}
function toggleListing() {
listing.value = !listing.value
}
// 重新加载当前目录内容(网络请求)
async function reloadCurrentDirContent() {
const { selectedDir } = userConfigInfo
await store.dispatch('DIR_IMAGE_LIST_INIT_DIR', selectedDir)
loadingImageList.value = true
await getContentByReposPath(selectedDir)
loadingImageList.value = false
}
onMounted(() => {
initDirImageList()
})
watch(
() => loggingStatus,
(nv) => {
if (nv === false) {
router.push('/config')
}
}
)
watch(
() => userConfigInfo.selectedDir,
async (nDir) => {
dirModeHandle(nDir, store)
await dirContentHandle(nDir)
renderKey.value += 1
},
{ deep: true }
)
watch(
() => dirObject,
(nv: any) => {
const { selectedDir } = userConfigInfo
const dirContent = getDirContent(selectedDir, nv)
if (dirContent) {
currentPathDirList.value = filterDirContent(selectedDir, dirContent, 'dir')
currentPathImageList.value = filterDirContent(selectedDir, dirContent, 'image')
store.commit('REPLACE_IMAGE_CARD', { checkedImgArr: currentPathImageList.value })
}
},
{ deep: true }
)
const isShowBatchTools = ref(false)
watch(
() => currentPathImageList.value,
(nv: UploadedImageModel[]) => {
isShowBatchTools.value = nv.filter((x) => x.checked).length > 0
},
{ deep: true }
)
</script>
<style scoped lang="stylus">
@import 'management.styl';
</style>

View File

@@ -0,0 +1,44 @@
.setting-title {
font-size 16rem
font-weight bold
margin 40rem 0 20rem 0
&:first-child {
margin-top 0
}
}
.setting-list {
padding 0
margin 0
.setting-item {
margin-bottom 10rem
&.last-child {
margin-bottom 0
}
.prefix-input {
width calc(100% - 50rem)
margin-left 50rem
margin-top 15rem
}
.img-encoder-title {
margin-bottom 12rem
}
:deep() .el-radio-group {
display inline-block
.el-radio {
display block
}
}
}
}

View File

@@ -0,0 +1,149 @@
<template>
<div class="page-container settings-page-container">
<div class="setting-title">个性设置</div>
<ul class="setting-list">
<li class="setting-item">
<el-switch
v-model="userSettings.defaultHash"
@change="persistUserSettings"
active-text="上传时给图片名加上哈希码确保图片名唯一强烈建议开启"
></el-switch>
</li>
<li class="setting-item">
<el-switch
v-model="userSettings.defaultPrefix"
@change="persistUserSettings"
active-text="上传时给图片名加上配置的前缀示例abc-image.jpgabc- 为前缀"
></el-switch>
<el-input
class="prefix-input"
v-if="userSettings.defaultPrefix"
v-model="userSettings.prefixName"
placeholder="请输入命名前缀"
@input="persistUserSettings"
clearable
autofocus
></el-input>
</li>
<li class="setting-item">
<el-switch
v-model="userSettings.defaultMarkdown"
@change="persistUserSettings"
active-text="上传成功后复制的图片外链启用 Markdown 格式![ ... ]( ... )"
></el-switch>
</li>
</ul>
<div class="setting-title">CDN 提供商</div>
<ul class="setting-list">
<li class="setting-item">
<el-select
v-model="userSettings.externalLinkType"
placeholder="选择 CDN 提供商"
@change="saveUserSettings"
>
<el-option label="Staticaly" value="staticaly"></el-option>
<el-option label="Cloudflare" value="cloudflare"></el-option>
<el-option label="jsDelivr" value="jsdelivr"></el-option>
</el-select>
</li>
</ul>
<div class="setting-title">压缩设置</div>
<ul class="setting-list">
<li class="setting-item">
<el-switch
v-model="userSettings.isCompress"
@change="persistUserSettings"
active-text="是否压缩图片"
></el-switch>
</li>
<li class="setting-item">
<div class="img-encoder-title">选择图像编码器压缩算法</div>
<el-radio-group
:disabled="!userSettings.isCompress"
v-model="userSettings.compressEncoder"
@change="persistUserSettings"
>
<el-radio :label="compressEncoder.webP">
{{ compressEncoder.webP }} 压缩后图片格式为 webp大多数现代浏览器支持
</el-radio>
<el-radio :label="compressEncoder.mozJPEG">
{{ compressEncoder.mozJPEG }} 压缩后图片格式为 jpg兼容性最好
</el-radio>
<el-radio :label="compressEncoder.avif">
{{ compressEncoder.avif }}
压缩后图片格式为 avif压缩比最高目前仅谷歌浏览器支持
</el-radio>
</el-radio-group>
</li>
</ul>
<div class="setting-title">主题设置</div>
<ul class="setting-list">
<li class="setting-item">
<el-select
v-model="userSettings.themeMode"
placeholder="主题模式"
@change="saveUserSettings"
>
<el-option label="自动设置" value="auto"></el-option>
<el-option label="暗夜主题" value="dark"></el-option>
<el-option label="白昼主题" value="light"></el-option>
</el-select>
</li>
</ul>
<div class="setting-title" v-if="userSettings.themeMode === 'auto'">
设置白昼模式时间区间
</div>
<ul class="setting-list" v-if="userSettings.themeMode === 'auto'">
<li class="setting-item">
<el-form ref="form">
<el-form-item>
<el-time-select
v-model="userSettings.autoLightThemeTime[0]"
start="00:00"
step="00:30"
end="23:59"
@change="saveUserSettings"
></el-time-select>
<span class="time-middle-space"> ~ </span>
<el-time-select
v-model="userSettings.autoLightThemeTime[1]"
:start="userSettings.autoLightThemeTime[0]"
step="00:30"
end="23:59"
@change="saveUserSettings"
></el-time-select>
</el-form-item>
</el-form>
</li>
</ul>
</div>
</template>
<script lang="ts" setup>
import { computed } from 'vue'
import { store } from '@/store'
import { CompressEncoderMap } from '@/utils/compress'
const userSettings = computed(() => store.getters.getUserSettings).value
const persistUserSettings = () => {
store.dispatch('USER_SETTINGS_PERSIST')
}
const compressEncoder = CompressEncoderMap
const saveUserSettings = () => {
store.dispatch('SET_USER_SETTINGS', {
...userSettings
})
persistUserSettings()
}
</script>
<style scoped lang="stylus">
@import "settings.styl"
</style>

View File

@@ -0,0 +1,15 @@
.tutorials-page-container {
.step-content {
padding-top 30rem
display flex
justify-content center
text-align center
}
.btn-next-prev {
text-align center
padding-top 30rem
}
}

View File

@@ -0,0 +1,64 @@
<template>
<div class="page-container tutorials-page-container">
<el-steps :active="stepNum" finish-status="success" align-center>
<el-step title="创建 GitHub 仓库"></el-step>
<el-step title="获取 GitHub Token"></el-step>
<el-step title="配置图床"></el-step>
</el-steps>
<div class="step-content">
<Step1 v-if="stepNum === 0"></Step1>
<Step2 v-if="stepNum === 1"></Step2>
<Step3 v-if="stepNum === 2"></Step3>
<div v-if="stepNum === 3">
<h2>👍 恭喜你完成图床使用教程 🌟 快去试试吧~</h2>
<h2>https://github.com/XPoet/picx</h2>
</div>
</div>
<div class="btn-next-prev">
<el-button type="primary" @click="step('prev')" v-if="stepNum > 0">上一步 </el-button>
<el-button type="primary" @click="step('next')" v-if="stepNum < 3">下一步 </el-button>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, Ref } from 'vue'
import Step1 from '@/components/tutorials-step/step1.vue'
import Step2 from '@/components/tutorials-step/step2.vue'
import Step3 from '@/components/tutorials-step/step3.vue'
export default defineComponent({
name: 'tutorials',
components: {
Step1,
Step2,
Step3
},
setup() {
const stepNum: Ref<number> = ref(0)
const step = (type: string) => {
if (type === 'prev') {
// eslint-disable-next-line no-unused-expressions
stepNum.value < 0 ? (stepNum.value = 0) : (stepNum.value -= 1)
}
if (type === 'next') {
// eslint-disable-next-line no-unused-expressions
stepNum.value > 3 ? (stepNum.value = 3) : (stepNum.value += 1)
}
}
return {
stepNum,
step
}
}
})
</script>
<style scoped lang="stylus">
@import "tutorials.styl"
</style>

View File

@@ -0,0 +1,135 @@
@import "../../style/base.styl"
.upload-page-container {
width 100%
height 100%
display flex
justify-content space-between
.upload-page-left {
height 100%
box-sizing border-box
margin-right $component-interval
flex-shrink 0
.uploaded-item {
margin-bottom 20rem
&:last-child {
margin-bottom 0
}
}
}
.upload-page-right {
width 100%
height 100%
box-sizing border-box
overflow-y auto
.row-item {
width 100%
display flex
justify-content center
margin-bottom 16rem
box-sizing border-box
&:last-child {
margin-bottom 0
}
.content-box {
width 100%
max-width $content-max-width
margin 0 auto
box-sizing border-box
}
}
.upload-status {
position relative
width 100%
padding 10rem
background var(--second-background-color)
color #666
font-size 12rem
box-sizing border-box
.info-item {
margin-top 4rem
}
.file-status {
display flex
justify-content space-between
align-items center
}
.upload-tips {
display flex
align-items center
i {
margin-left 2rem
font-size 20rem
}
}
.wait-upload {
color var(--await-upload-color)
}
.uploading {
color var(--uploading-color)
}
.uploaded {
color var(--uploaded-color)
}
}
.external-link {
width 100%
.external-link-input {
margin-bottom 10rem
&:last-child {
margin-bottom 0
}
.el-input-group__append {
width 100rem
text-align-last justify
}
}
}
.upload-tools {
width 100%
.repos-dir-info {
margin-bottom 20rem
font-size 12rem
.repos-dir-info-item {
margin-right 10rem
&:last-child {
margin-right 0
}
}
}
}
}
}

View File

@@ -0,0 +1,151 @@
<template>
<div class="upload-page-container">
<div
class="upload-page-left page-container"
v-if="uploadedImageList.length && userSettings.elementPlusSize === 'large'"
:style="{
width: '280rem'
}"
>
<div class="uploaded-item" v-for="(item, index) in uploadedImageList" :key="index">
<image-card :image-obj="item" :is-uploaded="true" />
</div>
</div>
<div class="upload-page-right page-container">
<!-- 上传区域 -->
<div class="row-item">
<div class="content-box">
<upload-area :image-loading="imageLoading" ref="uploadAreaDom"></upload-area>
</div>
</div>
<!-- 待上传的图片列表 -->
<div class="row-item">
<div class="content-box">
<to-upload-image-card
ref="toUploadImageCardDom"
:loading-all-image="imageLoading"
/>
</div>
</div>
<!-- 重置 & 上传 -->
<div class="row-item">
<div class="content-box" style="text-align: right">
<el-button
plain
type="warning"
@click="resetUploadInfo"
v-if="toUploadImage.list.length"
>重置
</el-button>
<el-button plain type="primary" @click="uploadImage"> 上传 </el-button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, watch, ref, Ref } from 'vue'
import { useRouter } from 'vue-router'
import { useStore } from '@/store'
import imageCard from '@/components/image-card/image-card.vue'
import toUploadImageCard from '@/components/to-upload-image-card/to-upload-image-card.vue'
import uploadArea from '@/components/upload-area/upload-area.vue'
import { UploadStatusEnum } from '@/common/model/upload.model'
const store = useStore()
const router = useRouter()
const GitHubExternalLinkInput: Ref = ref<null | HTMLElement>(null)
const CDNExternalLinkInput: Ref = ref<null | HTMLElement>(null)
const toUploadImageCardDom: Ref = ref<null | HTMLElement>(null)
const uploadAreaDom: Ref = ref<null | HTMLElement>(null)
const userConfigInfo = computed(() => store.getters.getUserConfigInfo)
const userSettings = computed(() => store.getters.getUserSettings)
const logoutStatus = computed(() => store.getters.getUserLoggingStatus)
const uploadedImageList = computed(() => store.getters.getUploadedImageList)
const toUploadImage = computed(() => store.getters.getToUploadImage)
const imageLoading = ref(false)
const uploadImage = () => {
const { token, selectedRepos, selectedDir } = userConfigInfo.value
if (!token) {
ElMessage.error('请先完成图床配置!')
router.push('/config')
return
}
if (!selectedRepos) {
ElMessage.error('请选择一个仓库!')
router.push('/config')
return
}
if (!selectedDir) {
ElMessage.error('目录不能为空!')
router.push('/config')
return
}
if (toUploadImage.value.list.length === 0) {
ElMessage.error('图片不能为空!')
return
}
if (toUploadImage.value.list.length === toUploadImage.value.uploadedNumber) {
ElMessage.error('请选择要上传的图片!')
return
}
imageLoading.value = true
toUploadImageCardDom.value
.uploadImage_all(userConfigInfo.value)
.then((v: UploadStatusEnum) => {
// eslint-disable-next-line default-case
switch (v) {
// 单张图片上传成功
case UploadStatusEnum.uploaded:
// 所有图片上传成功
// eslint-disable-next-line no-fallthrough
case UploadStatusEnum.allUploaded:
imageLoading.value = false
store.dispatch('TO_UPLOAD_IMAGE_CLEAN_URL')
break
// 上传失败(网络错误等原因)
case UploadStatusEnum.uploadFail:
imageLoading.value = false
store.dispatch('TO_UPLOAD_IMAGE_LIST_FAIL')
break
}
})
.catch((e: any) => {
console.error('upload error: ', e)
imageLoading.value = false
})
}
const resetUploadInfo = () => {
imageLoading.value = false
store.dispatch('TO_UPLOAD_IMAGE_LOGOUT')
}
watch(
() => logoutStatus,
(_n) => {
// 如果退出登录,清空信息
// eslint-disable-next-line no-unused-expressions
!_n && resetUploadInfo()
}
)
</script>
<style lang="stylus">
@import "upload.styl"
</style>