1.init commit.

This commit is contained in:
atom 2023-02-03 15:32:07 +08:00
commit a7491ebcec
33 changed files with 1453 additions and 0 deletions

1
.env Normal file
View File

@ -0,0 +1 @@
VITE_APP_API_URL = .

1
.env.development Normal file
View File

@ -0,0 +1 @@
VITE_APP_API_URL = https://picx.s1s.workers.dev

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules
package-lock.json
.idea
yarn.lock
dist

201
LICENSE Normal file
View File

@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

41
README.md Normal file
View File

@ -0,0 +1,41 @@
# roim-picx
### 预览地址
[roim-picx](https://pigx.tuqu.me)
> 该系统仅作为预览使用,每天有使用限额,请勿大量上传图片。不要使用该图床的地址作为生产使用,因为要定时删除。
### 一款基于Cloudflare的Worker、R2、Pages实现的图床应用具有以下特点
* 10GB的免费存储空间
* 每月300W次的不计流量的图片访问每天10W的限制。
* 每月100W次的图片上传次数
* 不需要自己购买服务器克隆代码后部署CloudFlare即可使用。
* 独立部署不需要担心被第三方删除数据。
### 已实现功能
* 图片批量上传
* 图片列表查询
* 图片删除
* 目录创建
* 按目录查询
* 链接地址点击复制
### TODO
* 简单的身份认证功能,进入管理页面需要授权
* 上传时支持选择目录。
* 提供删除图片的访问链接
* 管理页面支持分页加载图片
### 使用教程
### 图床截图
![Upload](https://oss.tuqu.me/roim/blog/5.png)
![Preview](https://oss.tuqu.me/roim/blog/1.png)
![HTML](https://oss.tuqu.me/roim/blog/2.png)
![Markdown](https://oss.tuqu.me/roim/blog/3.png)
![Link](https://oss.tuqu.me/roim/blog/4.png)
![Manage](https://oss.tuqu.me/roim/blog/6.png)
### 项目参考来源
[1. cfworker-kv-image-hosting](https://github.com/realByg/cfworker-kv-image-hosting)
[2. HikariSearch](https://github.com/mixmoe/HikariSearch)

View File

@ -0,0 +1,21 @@
import { error } from 'itty-router-extras';
export interface Env {
BASE_URL: string
XK: KVNamespace
PICX: R2Bucket
}
export const onRequest: PagesFunction<Env> = async (context : EventContext) => {
const { router } = await import('./router').then(
async (module) => (await import('./routes'), module)
);
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const response: Response | undefined = await router.handle(context.request, context.env);
return response ?? error(404, 'not found');
} catch (err) {
return error(500, (err as Error).message);
}
};

3
functions/rest/router.ts Normal file
View File

@ -0,0 +1,3 @@
import { ThrowableRouter } from 'itty-router-extras';
export const router = ThrowableRouter<Request>({ base: '/rest' });

View File

@ -0,0 +1,141 @@
import { router } from '../router';
import { Env } from '../[[path]]'
import { json } from 'itty-router-extras';
import { Ok, Fail, Build, ImgItem, ImgList, ImgReq, Folder } from "../type";
import { checkFileType, getFileName, parseRange } from '../utils'
import { R2ListOptions } from "@cloudflare/workers-types";
// list image
router.post('/list', async (req : Request, env : Env) => {
const data = await req.json() as ImgReq
if (!data.limit) {
data.limit = 10
}
if (data.limit > 100) {
data.limit = 100
}
if (!data.delimiter) {
data.delimiter = "/"
}
let include = undefined
if (data.delimiter != "/") {
include = data.delimiter
}
// console.log(include)
const options = <R2ListOptions>{
limit: data.limit,
cursor: data.cursor,
delimiter: data.delimiter,
prefix: include
}
const list = await env.PICX.list(options)
// console.log(list)
const truncated = list.truncated ? list.truncated : false
const cursor = list.cursor
const objs = list.objects
const urls = objs.map(it => {
return <ImgItem> {
url: `${env.BASE_URL}/rest/${it.key}`,
key: it.key,
size: it.size
}
})
return json(Ok(<ImgList>{
list: urls,
next: truncated,
cursor: cursor,
prefixes: list.delimitedPrefixes
}))
})
// batch upload file
router.post('/upload', async (req: Request, env : Env) => {
const files = await req.formData()
const images = files.getAll("files")
const errs = []
const urls = Array<ImgItem>()
for (let item of images) {
const fileType = item.type
if (!checkFileType(fileType)) {
errs.push(`${fileType} not support.`)
continue
}
const time = new Date().getTime()
const filename = await getFileName(fileType, time)
const header = new Headers()
header.set("content-type", fileType)
header.set("content-length", `${item.size}`)
const object = await env.PICX.put(filename, item.stream(), {
httpMetadata: header,
}) as R2Object
if (object || object.key) {
urls.push({
key: object.key,
size: object.size,
url: `${env.BASE_URL}/rest/${object.key}`,
filename: item.name
})
}
}
return json(Build(urls, errs.toString()))
})
// 创建目录
router.post("/folder", async (req: Request, env: Env) => {
try {
const data = await req.json() as Folder
const regx = /^[A-Za-z_]+$/
if (!regx.test(data.name)) {
return json(Fail("Folder name error"))
}
await env.PICX.put(data.name + '/', "")
return json(Ok("Success"))
} catch (e) {
return json(Fail("Create folder fail"))
}
})
// delete image
router.delete("/", async (req : Request, env: Env) => {
const params = await req.json()
// console.log(params)
const keys = params.keys;
if (!keys || keys.length < 1) {
return json(Fail("not delete keys"))
}
const arr = keys.split(',')
try {
for (let it of arr) {
if(it && it.length) {
await env.PICX.delete(it)
}
}
} catch (e) {
console.log(`img delete error:${e.message}`,)
}
return json(Ok(keys))
})
// image detail
router.get("/:id+", async (req : Request, env : Env) => {
let id = req.params.id
const range = parseRange(req.headers.get('range'))
const object = await env.PICX.get(id, {
range,
onlyIf: req.headers,
})
if (object == null) {
return json(Fail("object not found"))
}
const headers = new Headers()
object.writeHttpMetadata(headers)
headers.set('etag', object.httpEtag)
if (range) {
headers.set("content-range", `bytes ${range.offset}-${range.end}/${object.size}`)
}
const status = object.body ? (range ? 206 : 200) : 304
return new Response(object.body, {
headers,
status
})
})

57
functions/rest/type.ts Normal file
View File

@ -0,0 +1,57 @@
export interface Result {
code: number
msg: string
data?: any
}
export interface ImgItem {
key : string
url : string
size: number
filename ?: string
}
export interface ImgList {
next: boolean
cursor ?: string
list : Array<ImgItem>
prefixes ?: Array<String>
}
export interface ImgReq {
limit: number,
cursor ?: string
delimiter ?: string
}
// 文件夹名称
export interface Folder {
name: string
}
export function Fail(msg : string) : Result {
return <Result> {
code: StatusCode.ERROR,
msg: msg,
data: null
}
}
export function Ok(data : any) : Result {
return <Result> {
code: StatusCode.OK,
msg: "ok",
data: data
}
}
export function Build(data : any, msg: string) : Result {
return <Result> {
code: StatusCode.OK,
msg: msg,
data: data
}
}
const StatusCode = {
OK: 200,
ERROR: 500
}
export default StatusCode

48
functions/rest/utils.ts Normal file
View File

@ -0,0 +1,48 @@
const supportFiles = [{type:'image/png',ext:'png'},{type:'image/jpeg',ext:'jpeg'},{type:'image/gif',ext:'gif'},{type:'image/webp',ext:'webp'},{type:'image/jpg',ext:'jpg'},{type:'image/x-icon',ext:'ico'},{type:'application/x-ico',ext:'ico'},{type:'image/vnd.microsoft.icon',ext:'ico'}]
const supportFile = 'image/png,image/jpeg,image/gif,image/webp,image/jpg,image/x-icon,application/x-ico,image/vnd.microsoft.icon'
// 字符串编码
export function randomString(value: number) {
let baseStr = 'Aa0Bb1Cc2Dd3Ee4Ff5Gg6Hh7Ii8Jj9KkLlMmNnOoPpQqRrSsTtUuVvWwXxYyZz';
const chars = baseStr.split('');
let maxPos = baseStr.length;
const uuid = [];
let q = value;
for(;q > 0;) {
let mod = q % maxPos;
q = (q - mod) / maxPos;
uuid.push(chars[mod]);
}
return uuid.join('');
}
// 解析range
export function parseRange(encoded: string | null): undefined | { offset: number, end: number, length: number } {
if (encoded === null) {
return
}
const parts = encoded.split("bytes=")[1]?.split("-") ?? []
if (parts.length !== 2) {
throw new Error('Not supported to skip specifying the beginning/ending byte at this time')
}
return {
offset: Number(parts[0]),
end: Number(parts[1]),
length: Number(parts[1]) + 1 - Number(parts[0]),
}
}
// 检查文件类是否支持
export function checkFileType(val : string) : boolean {
return supportFile.indexOf(val) > -1
}
// 获取文件名
export async function getFileName(val : string, time : number) : Promise<string> {
const types = supportFiles.filter(it => it.type === val)
if (!types || types.length < 1) {
return val
}
const rand = Math.floor(Math.random() * 100000)
return randomString(time + rand).concat(`.${types[0].ext}`)
}

14
index.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Loading</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

34
package.json Normal file
View File

@ -0,0 +1,34 @@
{
"name": "roim-picx",
"version": "1.0.1",
"scripts": {
"dev": "vite",
"build": "vite build",
"dev:worker": "wrangler pages dev ./public --kv=XK --r2=PICX",
"preview": "vite preview"
},
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.1.0",
"@fortawesome/free-brands-svg-icons": "^6.1.0",
"@fortawesome/free-regular-svg-icons": "^6.1.0",
"@fortawesome/free-solid-svg-icons": "^6.1.0",
"@fortawesome/vue-fontawesome": "^3.0.0-5",
"axios": "^0.25.0",
"copy-to-clipboard": "^3.3.1",
"element-plus": "^2.2.23",
"itty-router": "^3.0.11",
"itty-router-extras": "^0.4.2",
"vue": "^3.2.47",
"vue-router": "^4.1.6"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20230115.0",
"@vitejs/plugin-vue": "^3.2.0",
"autoprefixer": "^10.4.2",
"postcss": "^8.4.5",
"tailwindcss": "^3.2.4",
"typescript": "^4.4.4",
"vite": "^3.2.5",
"wrangler": "^2.9.0"
}
}

6
postcss.config.js Normal file
View File

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

66
src/App.vue Normal file
View File

@ -0,0 +1,66 @@
<template>
<el-config-provider :locale="zhCn">
<div class="w-full h-screen overflow-x-hidden overflow-y-auto">
<el-scrollbar>
<div
class="w-full h-16 bg-rose-100/50 shadow-sm sticky left-0 top-0 backdrop-blur-sm z-10"
>
<div class="mx-auto max-w-6xl px-4 h-full flex items-center">
<img src="./assets/picx-logo.png" class="w-8 h-8 block mr-2" />
<div class="text-lg">
{{ appName }}
</div>
<div class="flex-1"></div>
<div
:class="{
'bg-rose-300': $route.path === '/up'
}"
class="px-3 py-2 rounded-md mr-2 text-gray-800 text-sm cursor-pointer"
@click="router.push('/up')"
>
<font-awesome-icon :icon="faUpload" :class="[$route.path === '/up' ? 'text-white' : 'text-gray-500']" />
<span class="hidden md:inline-block pl-2">上传</span>
</div>
<div
:class="{
'bg-rose-300': $route.path === '/'
}"
class="px-3 py-2 rounded-md text-gray-800 text-sm cursor-pointer"
@click="router.push('/')"
>
<font-awesome-icon :icon="faCog" :class="[$route.path === '/' ? 'text-white' : 'text-gray-500']" />
<span class="hidden md:inline-block pl-2">管理</span>
</div>
</div>
</div>
<div class="min-h-[calc(100vh-64px-64px)] overflow-auto">
<router-view />
</div>
<div class="w-full h-16 flex items-center justify-center text-gray-500 text-sm">
<a :href="repoLink" target="_blank" class="underline">
{{ repoName }}
</a>
</div>
</el-scrollbar>
</div>
</el-config-provider>
</template>
<script setup lang="ts">
import { faCog, faUpload } from '@fortawesome/free-solid-svg-icons'
import { useRouter } from 'vue-router'
import { ElScrollbar, ElConfigProvider } from 'element-plus'
import zhCn from 'element-plus/lib/locale/lang/zh-cn'
const repoLink = 'https://roim.app'
const repoName = 'roim-picx'
const appName = 'PICX'
document.title = appName
const router = useRouter()
</script>

11
src/app.css Normal file
View File

@ -0,0 +1,11 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply scroll-smooth text-base bg-gray-100 select-none overflow-x-hidden;
}
.area-disabled {
@apply !opacity-60 active:!pointer-events-none !cursor-not-allowed;
}

BIN
src/assets/pic.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

108
src/components/ImageBox.vue Normal file
View File

@ -0,0 +1,108 @@
<template>
<div
class="w-full bg-rose-100 rounded-md shadow-sm overflow-hidden relative"
v-if="!imageError"
>
<loading-overlay :loading="loading" />
<el-image
class="block w-full h-32 lg:h-60"
:src="src"
fit="cover"
hide-on-click-modal
lazy
@error="imageError = true"
@load="loading = false"
:preview-src-list="[src]"
/>
<div class="w-full absolute left-0 bottom-0 bg-slate-800/70 backdrop-blur-sm">
<div class="p-2">
<div class="w-full flex items-center text-white">
<div class="flex-1 w-full truncate">
<el-tooltip :content="name" placement="top-start">
{{ name }}
</el-tooltip>
</div>
<div
v-if="mode === 'converted'"
class="w-6 h-6 flex items-center justify-center cursor-pointer"
@click="emit('delete')"
>
<font-awesome-icon :icon="faTimesCircle" />
</div>
</div>
<span class="text-xs text-gray-300 flex items-center">
{{ formatBytes(size) }}
<el-divider v-if="uploadedAt" direction="vertical" />
<span v-if="uploadedAt">
{{ new Date(uploadedAt).toLocaleDateString() }}
</span>
</span>
</div>
<div v-if="mode === 'uploaded'">
<el-divider class="m-0" />
<div class="w-full flex text-white h-9 text-center text-sm">
<el-tooltip :content="src" placement="top-start">
<div
class="flex-1 flex items-center justify-center cursor-pointer"
@click="copyLink(src)"
>
<font-awesome-icon :icon="faCopy" class="mr-2" />
链接
</div>
</el-tooltip>
<el-divider direction="vertical" class="h-full" />
<el-popconfirm
title="确认删除图片吗?"
confirm-button-type="danger"
@confirm="
() => {
// (e: Event) => boolean ???
loading = true
emit('delete')
return true
}
"
>
<template #reference>
<div class="flex-1 flex items-center justify-center cursor-pointer">
<font-awesome-icon :icon="faTrashAlt" class="mr-2" />
删除
</div>
</template>
</el-popconfirm>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { faTimesCircle, faTrashAlt, faCopy } from '@fortawesome/free-regular-svg-icons'
import copy from 'copy-to-clipboard'
import formatBytes from '../utils/format-bytes'
import {ElTooltip, ElDivider, ElPopconfirm, ElImage, ElMessage} from 'element-plus'
import { ref } from 'vue'
import LoadingOverlay from '../components/LoadingOverlay.vue'
const props = defineProps<{
src: string
name: string
size: number
mode: 'converted' | 'uploaded'
uploadedAt?: number
expiresAt?: number
}>()
const emit = defineEmits(['delete'])
const imageError = ref(false)
const loading = ref(true)
const copyLink = (link : string) => {
const res = copy(link)
if (res) {
ElMessage.success('链接复制成功')
} else {
ElMessage.success('链接复制失败')
}
}
</script>

View File

@ -0,0 +1,82 @@
<template>
<el-card class="box-card mt-4" v-for="it in imageList" :key="it.key">
<template #header>
<div class="card-header">
<span>{{ it.filename }}</span>
</div>
</template>
<div class="text item">
<div class="lg:flex items-center justify-start">
<el-image
class="block w-48 h-48 lg:mr-6 mb-2 lg:mb-0 mx-auto"
:src="it.url"
fit="cover"
hide-on-click-modal
lazy
@error="imageError = true"
@load="loading = false"
:preview-src-list="[it.url]"
/>
<div class="link-list">
<div class="w-full mb-2">
<label for="htmlLink" class="block text-sm font-medium text-gray-700"> HTML </label>
<div class="mt-1">
<input id="htmlLink" :value="htmlLink(it.url, it.filename)" name="htmlLink" class="cursor-pointer focus:node border border-gray-300 flex-1 block w-full rounded-md px-2 py-1" readonly placeholder="html link" @click="copyLink" />
</div>
</div>
<div class="w-full mb-2">
<label for="Markdown" class="block text-sm font-medium text-gray-700"> Markdown </label>
<div class="mt-1">
<input id="Markdown" :value="markdownLink(it.url, it.filename)" name="Markdown" class="cursor-pointer focus:none border border-gray-300 flex-1 block w-full rounded-md px-2 py-1" readonly placeholder="markdown link" @click="copyLink" />
</div>
</div>
<div class="w-full mb-2">
<label for="LINK" class="block text-sm font-medium text-gray-700"> LINK </label>
<div class="mt-1">
<input id="LINK" :value="it.url" name="LINK" class="cursor-pointer focus:none border border-gray-300 flex-1 block w-full rounded-md px-2 py-1" placeholder="link" @click="copyLink" readonly />
</div>
</div>
</div>
</div>
</div>
</el-card>
</template>
<script setup lang="ts">
import type { ImgItem } from '../utils/types'
import copy from 'copy-to-clipboard'
import { ElCard, ElImage, ElMessage } from 'element-plus'
import { ref } from 'vue'
const props = defineProps<{
imageList: ImgItem[]
}>()
const imageError = ref(false)
const loading = ref(false)
const markdownLink = (link: String, filename: String) => {
return `![${filename}](${link})`
}
const htmlLink = (link: String, filename: String) => {
return `<a href="${link}" target="_blank" title="${filename}"><img src="${link}"></a>`
}
const copyLink = (event: any) => {
// console.log(event.target.value)
const res = copy(event.target.value)
if (res) {
ElMessage.success('链接复制成功')
} else {
ElMessage.success('链接复制失败')
}
}
</script>
<style scoped>
.link-list {
width: calc(100% - 13.5rem);
}
@media screen and (max-width: 1024px){
.link-list {
width: 100%;
}
}
</style>

View File

@ -0,0 +1,16 @@
<template>
<div
v-if="props.loading"
class="backdrop-blur-sm left-0 top-0 h-full w-full absolute flex items-center justify-around z-50 cursor-not-allowed"
>
<font-awesome-icon :icon="faSpinner" class="text-4xl text-gray-400 animate-spin" />
</div>
</template>
<script setup lang="ts">
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
const props = defineProps<{
loading: boolean
}>()
</script>

View File

@ -0,0 +1,84 @@
<template>
<el-tabs v-model="activeName" class="demo-tabs" @tab-click="handleClick" type="border-card">
<el-tab-pane label="Preview" name="first">
<image-item :image-list="imageList" ref="imageItem" />
</el-tab-pane>
<el-tab-pane label="HTML" name="second">
<div class="text-sm text-gray-600 p-2 bg-gray-100 max-w-full overflow-auto whitespace-pre" @click="copyLink">
{{ htmlLinks() }}
</div>
</el-tab-pane>
<el-tab-pane label="Markdown" name="third">
<div class="text-sm text-gray-600 p-2 bg-gray-100 max-w-full overflow-auto whitespace-pre" @click="copyLink">
{{ markdownLinks() }}
</div>
</el-tab-pane>
<el-tab-pane label="Link" name="fourth">
<div class="text-sm text-gray-600 p-2 bg-gray-100 max-w-full overflow-auto whitespace-pre" @click="copyLink">
{{ viewLinks() }}
</div>
</el-tab-pane>
</el-tabs>
</template>
<script setup lang="ts">
import { ElCard, ElImage, ElMessage, ElTabs, ElTabPane } from 'element-plus'
import { ref } from 'vue'
import type { TabsPaneContext } from 'element-plus'
import ImageItem from '../components/ImageItem.vue'
import type { ImgItem } from '../utils/types'
import copy from 'copy-to-clipboard'
const props = defineProps<{
imageList: ImgItem[]
}>()
const activeName = ref('first')
const htmlLinks = () => {
let text = ''
const length = props.imageList.length
for(let i = 0; i < length; i++) {
const it = props.imageList[i]
text += `<a href="${it.url}" target="_blank"><img src="${it.url}"></a>\n`
}
return text
}
const viewLinks = () => {
let text = ''
const length = props.imageList.length
for(let i = 0; i < length; i++) {
const it = props.imageList[i]
text += `${it.url}\n`
}
return text
}
const markdownLinks = () => {
let text = ''
const length = props.imageList.length
for(let i = 0; i < length; i++) {
const it = props.imageList[i]
text += `![${it.filename}](${it.url})\n`
}
return text
}
const copyLink = (event: any) => {
// console.log(event.target.innerText)
const res = copy(event.target.innerText)
if (res) {
ElMessage.success('链接复制成功')
} else {
ElMessage.success('链接复制失败')
}
}
const handleClick = (tab: TabsPaneContext, event: Event) => {
// console.log(tab, event)
}
</script>
<style scoped>
.demo-tabs > .el-tabs__content {
padding: 32px;
color: #6b778c;
font-size: 32px;
font-weight: 600;
}
</style>

16
src/env.d.ts vendored Normal file
View File

@ -0,0 +1,16 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import { DefineComponent } from 'vue'
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types
const component: DefineComponent<{}, {}, any>
export default component
}
interface ImportMetaEnv {
readonly VITE_APP_API_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

8
src/main.ts Normal file
View File

@ -0,0 +1,8 @@
import { createApp } from 'vue'
import App from './App.vue'
import router from './plugins/router'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import './app.css'
import 'element-plus/dist/index.css'
createApp(App).use(router).component('font-awesome-icon', FontAwesomeIcon).mount('#app')

21
src/plugins/router.ts Normal file
View File

@ -0,0 +1,21 @@
import { createRouter, createWebHistory } from 'vue-router'
const router = createRouter({
history: createWebHistory(),
routes: [
{
path: '/',
component: () => import('../views/ManageImages.vue')
},
{
path: '/up',
component: () => import('../views/UploadImages.vue')
},
{
path: '/:path(.*)',
redirect: '/'
}
]
})
export default router

11
src/utils/format-bytes.ts Normal file
View File

@ -0,0 +1,11 @@
const k = 1024
const dm = 2 < 0 ? 0 : 2
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']
const formatBytes = (bytes: number) => {
if (bytes === 0) return '0 B'
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
export default formatBytes

25
src/utils/request.ts Normal file
View File

@ -0,0 +1,25 @@
import axios from 'axios'
import { ElNotification as elNotify } from 'element-plus'
import {ImgList, ImgDel, ImgReq, Folder, ImgItem} from "./types";
const request = axios.create({
baseURL: import.meta.env.VITE_APP_API_URL,
timeout: 200000
})
request.interceptors.response.use(
(response) => response.data.data,
(error) => {
elNotify({
message: error?.response?.data || String(error),
duration: 0,
type: 'error'
})
return Promise.reject(error)
}
)
export const requestListImages = (data : ImgReq): Promise<ImgList> => request.post('/rest/list', data)
export const requestUploadImages = (data: FormData) : Promise<ImgItem[]> => request.post('/rest/upload', data)
export const createFolder = (data: Folder) => request.post('/rest/folder', data)
export const requestDeleteImage = (data: ImgDel) => request.delete('/rest', { data })

41
src/utils/types.ts Normal file
View File

@ -0,0 +1,41 @@
export type ConvertedImage = {
file: File
tmpSrc: string
}
export type UploadedImage = {
src: string
size: number
name: string
uploadedAt: number
expiresAt: number
}
export interface ImgItem {
key : string
url : string
size: number
filename ?: string
}
export interface ImgList {
next: boolean
cursor ?: string
list : Array<ImgItem>
prefixes ?: Array<String>
}
export interface ImgDel {
keys : string
}
export interface ImgReq {
cursor?: string
delimiter?: string
limit: Number
}
export interface Folder {
name: string
}

124
src/views/ManageImages.vue Normal file
View File

@ -0,0 +1,124 @@
<template>
<div class="mx-auto max-w-6xl my-4 px-4 relative">
<loading-overlay :loading="loading" />
<div class="flex items-center justify-between mb-4">
<div>
<div class="text-gray-800 text-lg">管理图片</div>
<div class="text-sm text-gray-500">
已上传 {{ uploadedImages.length }} 张图片 {{ formatBytes(imagesTotalSize) }}
</div>
</div>
<div class="flex items-center justify-start">
<font-awesome-icon :icon="faFolderPlus" class="text-xl cursor-pointer text-3xl text-amber-300 mr-2" @click="addFolder" />
<font-awesome-icon
:icon="faRedoAlt"
class="text-xl cursor-pointer text-indigo-400"
@click="listImages"
/>
</div>
</div>
<div class="my-2 flex items-center justify-start flex-wrap">
<div v-for="it in prefixes" class="px-4 py-2 items-center flex rounded-lg bg-white shadow-md cursor-pointer mx-1" @click="changeFolder(it)">
<font-awesome-icon :icon="faFolder" class="text-3xl text-amber-500" />
<span v-if="it !== '/'" class="pl-2 text-gray-600"> {{ it.replace("/", "") }}</span>
<span v-else class="pl-2 text-gray-600"> {{ it }}</span>
</div>
</div>
<div class="grid gap-4 grid-cols-4">
<transition-group name="el-fade-in-linear">
<div
class="col-span-3 md:col-span-1"
v-for="item in uploadedImages"
:key="item.url"
>
<image-box
:src="item.url"
:name="item.key"
:size="item.size"
@delete="deleteImage(item.key)"
mode="uploaded"
/>
</div>
</transition-group>
</div>
</div>
</template>
<script setup lang="ts">
import { requestListImages, requestDeleteImage, createFolder } from '../utils/request'
import LoadingOverlay from '../components/LoadingOverlay.vue'
import formatBytes from '../utils/format-bytes'
import { computed, onMounted, ref } from 'vue'
import type { ImgItem, ImgReq, Folder } from '../utils/types'
import ImageBox from '../components/ImageBox.vue'
import { ElMessageBox, ElMessage } from 'element-plus'
import { faRedoAlt, faFolder, faFolderPlus } from '@fortawesome/free-solid-svg-icons'
import {FontAwesomeIcon} from "@fortawesome/vue-fontawesome";
const loading = ref(false)
const delimiter = ref('/')
const uploadedImages = ref<ImgItem[]>([])
const prefixes = ref<String[]>([])
const imagesTotalSize = computed(() =>
uploadedImages.value.reduce((total, item) => total + item.size, 0)
)
const changeFolder = (path : string) => {
console.log(path)
delimiter.value = path
listImages()
}
const addFolder = () => {
ElMessageBox.prompt('请输入目录名称,仅支持英文名称', '新增目录', {
confirmButtonText: '创建',
cancelButtonText: '取消',
inputPattern: /^[A-Za-z_]+$/,
inputErrorMessage: '无效的目录名称',
}).then(({ value }) => {
loading.value = true
createFolder(<Folder> {
name: value
}).then((res) => {
console.log(res)
ElMessage.success('文件见创建成功')
listImages()
}).catch(() => {
ElMessage.error('文件见创建失败')
}).finally(() => {
loading.value = false
})
}).catch(() => {})
}
const listImages = () => {
loading.value = true
requestListImages(<ImgReq> {
limit: 100,
delimiter: delimiter.value
}).then((data) => {
uploadedImages.value = data.list
if (data.prefixes && data.prefixes.length) {
prefixes.value = data.prefixes
if (delimiter.value !== '/') {
prefixes.value = ['/', ...data.prefixes]
}
} else {
prefixes.value = ['/']
}
}).catch(() => {})
.finally(() => {
loading.value = false
})
}
onMounted(() => {
listImages()
})
const deleteImage = (src: string) => {
requestDeleteImage({
keys: src
}).then((res) => {
uploadedImages.value = uploadedImages.value.filter((item) => item.key !== res)
})
}
</script>

221
src/views/UploadImages.vue Normal file
View File

@ -0,0 +1,221 @@
<template>
<div class="mx-auto max-w-6xl my-4 px-4">
<div class="text-gray-800 text-lg">上传图片</div>
<div class="mb-4 text-sm text-gray-500">
每张图片大小不超过 {{ formatBytes(imageSizeLimit) }}
</div>
<div class="border-2 border-dashed border-slate-400 rounded-md relative">
<loading-overlay :loading="loading" />
<div
class="grid p-4 gap-4 grid-cols-4 min-h-[240px]"
@drop.prevent="onFileDrop"
@dragover.prevent
>
<div
v-if="convertedImages.length === 0"
class="absolute -z-10 left-0 top-0 w-full h-full flex items-center justify-center"
>
<div class="text-gray-500">
<font-awesome-icon :icon="faCopy" />
粘贴或拖动图片至此处
</div>
</div>
<transition-group name="el-fade-in-linear">
<div
class="col-span-3 md:col-span-1"
v-for="item in convertedImages"
:key="item.tmpSrc"
>
<image-box
:src="item.tmpSrc"
:size="item.file.size"
:name="item.file.name"
@delete="removeImage(item.tmpSrc)"
mode="converted"
/>
</div>
</transition-group>
</div>
</div>
<div class="w-full rounded-md shadow-sm overflow-hidden mt-4 grid grid-cols-8">
<div class="md:col-span-1 col-span-8">
<div
class="w-full h-10 bg-blue-500 cursor-pointer flex items-center justify-center text-white"
:class="{
'area-disabled': loading
}"
@click="input?.click()"
>
<font-awesome-icon :icon="faImages" class="mr-2" />
选择图片
</div>
</div>
<div class="md:col-span-5 col-span-8">
<div class="w-full h-10 bg-slate-200 leading-10 px-4 text-center md:text-left">
已选择 {{ convertedImages.length }} {{ formatBytes(imagesTotalSize) }}
</div>
</div>
<div class="md:col-span-1 col-span-3">
<div
class="w-full bg-red-500 cursor-pointer h-10 flex items-center justify-center text-white"
:class="{
'area-disabled': loading
}"
@click="clearInput"
>
<font-awesome-icon :icon="faTrashAlt" class="mr-2" />
清除
</div>
</div>
<div class="md:col-span-1 col-span-5">
<div
class="w-full h-10 flex items-center justify-center text-white bg-green-500 cursor-pointer"
:class="{
'area-disabled': convertedImages.length === 0 || loading
}"
@click="uploadImages"
>
<font-awesome-icon :icon="faUpload" class="mr-2" />
上传
</div>
</div>
</div>
<result-list v-show="imgResultList && imgResultList.length" :image-list="imgResultList" ref="resultList" class="mt-4" />
</div>
<input
ref="input"
type="file"
accept="image/*"
class="hidden"
multiple
@change="onInputChange"
/>
</template>
<script setup lang="ts">
import { faImages, faTrashAlt, faCopy } from '@fortawesome/free-regular-svg-icons'
import { faUpload } from '@fortawesome/free-solid-svg-icons'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import LoadingOverlay from '../components/LoadingOverlay.vue'
import formatBytes from '../utils/format-bytes'
import {ElNotification as elNotify } from 'element-plus'
import { requestUploadImages } from '../utils/request'
import { useRouter } from 'vue-router'
import ImageBox from '../components/ImageBox.vue'
import ResultList from '../components/ResultList.vue'
import type { ConvertedImage, ImgItem } from '../utils/types'
const convertedImages = ref<ConvertedImage[]>([])
const imgResultList = ref<ImgItem[]>([])
const imagesTotalSize = computed(() =>
convertedImages.value.reduce((total, item) => total + item.file.size, 0)
)
const imageSizeLimit = 20 * 1024 * 1024
const input = ref<HTMLInputElement>()
const loading = ref(false)
const router = useRouter()
const onInputChange = () => {
appendConvertedImages(input.value?.files)
}
const onFileDrop = (e: DragEvent) => {
appendConvertedImages(e.dataTransfer?.files)
}
const onPaste = (e: ClipboardEvent) => {
appendConvertedImages(e.clipboardData?.files)
}
onMounted(() => {
document.onpaste = onPaste
})
onUnmounted(() => {
document.onpaste = null
convertedImages.value.forEach((item) => URL.revokeObjectURL(item.tmpSrc))
})
const clearInput = () => {
convertedImages.value = []
imgResultList.value = []
}
const appendConvertedImages = async (files: FileList | null | undefined) => {
if (!files) return
loading.value = true
for (let i = 0; i < files.length; i++) {
const file = files.item(i)
if (!file) return
if (file.size > imageSizeLimit) {
elNotify({
message: `${file.name} 文件过大`,
type: 'error'
})
continue
}
if (!file.type.startsWith('image/')) {
elNotify({
message: `${file.name} 不是图片文件`,
type: 'error'
})
continue
}
convertedImages.value = [
...convertedImages.value,
{
file,
tmpSrc: URL.createObjectURL(file)
}
]
}
loading.value = false
}
const removeImage = (tmpSrc: string) => {
convertedImages.value = convertedImages.value.filter((item) => item.tmpSrc !== tmpSrc)
URL.revokeObjectURL(tmpSrc)
}
const uploadImages = () => {
loading.value = true
const formData = new FormData()
for (let item of convertedImages.value) {
formData.append('files', item.file)
}
requestUploadImages(formData)
.then((res) => {
elNotify({
title: '上传完成',
message: `${convertedImages.value.length} 张图片,${formatBytes(
imagesTotalSize.value
)}`,
type: 'success'
})
convertedImages.value = []
imgResultList.value = res
// console.log(res)
// router.push('/')
})
.catch(() => {})
.finally(() => {
loading.value = false
})
}
</script>
<style>
.remove-now-btn .el-picker-panel__footer button:first-child {
display: none;
}
</style>

8
tailwind.config.js Normal file
View File

@ -0,0 +1,8 @@
module.exports = {
content: ['./index.html', './src/**/*.vue'],
theme: {
extend: {}
},
plugins: [],
important: true
}

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "esnext",
"useDefineForClassFields": true,
"module": "esnext",
"moduleResolution": "node",
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"lib": [
"esnext",
"dom"
]
},
"types": [
"@cloudflare/workers-types"
],
"include": [
"src/**/*.ts",
"src/**/*.d.ts",
"src/**/*.tsx",
"src/**/*.vue"
],
"exclude": ["functions/**/*"]
}

11
vite.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
server: {
port: 8990,
host: "0.0.0.0"
}
})