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