1.init commit.
This commit is contained in:
commit
a7491ebcec
|
@ -0,0 +1 @@
|
|||
VITE_APP_API_URL = https://picx.s1s.workers.dev
|
|
@ -0,0 +1,5 @@
|
|||
node_modules
|
||||
package-lock.json
|
||||
.idea
|
||||
yarn.lock
|
||||
dist
|
|
@ -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.
|
|
@ -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)
|
|
@ -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);
|
||||
}
|
||||
};
|
|
@ -0,0 +1,3 @@
|
|||
import { ThrowableRouter } from 'itty-router-extras';
|
||||
|
||||
export const router = ThrowableRouter<Request>({ base: '/rest' });
|
|
@ -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
|
||||
})
|
||||
})
|
|
@ -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
|
|
@ -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}`)
|
||||
}
|
|
@ -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>
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
|
@ -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>
|
|
@ -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;
|
||||
}
|
Binary file not shown.
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
After Width: | Height: | Size: 4.1 KiB |
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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
|
||||
}
|
|
@ -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')
|
|
@ -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
|
|
@ -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
|
|
@ -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 })
|
|
@ -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
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -0,0 +1,8 @@
|
|||
module.exports = {
|
||||
content: ['./index.html', './src/**/*.vue'],
|
||||
theme: {
|
||||
extend: {}
|
||||
},
|
||||
plugins: [],
|
||||
important: true
|
||||
}
|
|
@ -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/**/*"]
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
})
|
Loading…
Reference in New Issue