Compare commits

..

9 Commits

36 changed files with 9466 additions and 205 deletions

503
.edgeone/functions/index.js Normal file
View File

@ -0,0 +1,503 @@
let global = globalThis;
class MessageChannel {
constructor() {
this.port1 = new MessagePort();
this.port2 = new MessagePort();
}
}
class MessagePort {
constructor() {
this.onmessage = null;
}
postMessage(data) {
if (this.onmessage) {
setTimeout(() => this.onmessage({ data }), 0);
}
}
}
global.MessageChannel = MessageChannel;
async function handleRequest(context){
let routeParams = {};
let pagesFunctionResponse = null;
const request = context.request;
const urlInfo = new URL(request.url);
if (urlInfo.pathname !== '/' && urlInfo.pathname.endsWith('/')) {
urlInfo.pathname = urlInfo.pathname.slice(0, -1);
}
let matchedFunc = false;
if('/api/clean-eo-cache' === urlInfo.pathname) {
matchedFunc = true;
(() => {
// functions/api/clean-eo-cache.js
async function qcloudV3Post(secretId, secretKey, service, bodyArray, headersArray) {
const HTTPRequestMethod = "POST";
const CanonicalURI = "/";
const CanonicalQueryString = "";
const sortHeadersArray = Object.keys(headersArray).sort().reduce((obj, key) => {
obj[key] = headersArray[key];
return obj;
}, {});
let SignedHeaders = "";
let CanonicalHeaders = "";
for (const key in sortHeadersArray) {
SignedHeaders += key.toLowerCase() + ";";
}
SignedHeaders = SignedHeaders.slice(0, -1);
for (const key in sortHeadersArray) {
CanonicalHeaders += `${key.toLowerCase()}:${sortHeadersArray[key].toLowerCase()}
`;
}
const HashedRequestPayload = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(JSON.stringify(bodyArray))
).then((hash) => Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join(""));
const CanonicalRequest = `${HTTPRequestMethod}
${CanonicalURI}
${CanonicalQueryString}
${CanonicalHeaders}
${SignedHeaders}
${HashedRequestPayload}`;
const RequestTimestamp = Math.floor(Date.now() / 1e3);
const formattedDate = new Date(RequestTimestamp * 1e3).toISOString().split("T")[0];
const Algorithm = "TC3-HMAC-SHA256";
const CredentialScope = `${formattedDate}/${service}/tc3_request`;
const HashedCanonicalRequest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(CanonicalRequest)
).then((hash) => Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join(""));
const StringToSign = `${Algorithm}
${RequestTimestamp}
${CredentialScope}
${HashedCanonicalRequest}`;
async function hmac(key, string) {
const cryptoKey = await crypto.subtle.importKey(
"raw",
typeof key === "string" ? new TextEncoder().encode(key) : key,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const signature = await crypto.subtle.sign(
"HMAC",
cryptoKey,
new TextEncoder().encode(string)
);
return new Uint8Array(signature);
}
const SecretDate = await hmac("TC3" + secretKey, formattedDate);
const SecretService = await hmac(SecretDate, service);
const SecretSigning = await hmac(SecretService, "tc3_request");
const Signature = Array.from(
new Uint8Array(
await crypto.subtle.sign(
"HMAC",
await crypto.subtle.importKey(
"raw",
SecretSigning,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
),
new TextEncoder().encode(StringToSign)
)
)
).map((b) => b.toString(16).padStart(2, "0")).join("");
const Authorization = `${Algorithm} Credential=${secretId}/${CredentialScope}, SignedHeaders=${SignedHeaders}, Signature=${Signature}`;
headersArray["X-TC-Timestamp"] = RequestTimestamp.toString();
headersArray["Authorization"] = Authorization;
return headersArray;
}
function onRequestOptions(context) {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type"
}
});
}
async function onRequestPost(context) {
try {
const data = await context.request.json();
const { secretId, secretKey, zoneId, type, targets } = data;
const service = "teo";
const host = "teo.tencentcloudapi.com";
const payload = {
ZoneId: zoneId,
Type: type,
Targets: targets
};
const headersPending = {
"Host": host,
"Content-Type": "application/json",
"X-TC-Action": "CreatePurgeTask",
"X-TC-Version": "2022-09-01",
"X-TC-Region": "ap-guangzhou"
};
const headers = await qcloudV3Post(secretId, secretKey, service, payload, headersPending);
const response = await fetch(`https://${host}`, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
const result = await response.json();
return new Response(JSON.stringify(result), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
}
});
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
}
});
}
}
function onRequest(context) {
return new Response(JSON.stringify({ error: "Only POST method is allowed" }), {
status: 405,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
}
});
}
pagesFunctionResponse = onRequest;
})();
}
if('/api/clean-eo-cache' === urlInfo.pathname && request.method === 'POST') {
matchedFunc = true;
(() => {
// functions/api/clean-eo-cache.js
async function qcloudV3Post(secretId, secretKey, service, bodyArray, headersArray) {
const HTTPRequestMethod = "POST";
const CanonicalURI = "/";
const CanonicalQueryString = "";
const sortHeadersArray = Object.keys(headersArray).sort().reduce((obj, key) => {
obj[key] = headersArray[key];
return obj;
}, {});
let SignedHeaders = "";
let CanonicalHeaders = "";
for (const key in sortHeadersArray) {
SignedHeaders += key.toLowerCase() + ";";
}
SignedHeaders = SignedHeaders.slice(0, -1);
for (const key in sortHeadersArray) {
CanonicalHeaders += `${key.toLowerCase()}:${sortHeadersArray[key].toLowerCase()}
`;
}
const HashedRequestPayload = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(JSON.stringify(bodyArray))
).then((hash) => Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join(""));
const CanonicalRequest = `${HTTPRequestMethod}
${CanonicalURI}
${CanonicalQueryString}
${CanonicalHeaders}
${SignedHeaders}
${HashedRequestPayload}`;
const RequestTimestamp = Math.floor(Date.now() / 1e3);
const formattedDate = new Date(RequestTimestamp * 1e3).toISOString().split("T")[0];
const Algorithm = "TC3-HMAC-SHA256";
const CredentialScope = `${formattedDate}/${service}/tc3_request`;
const HashedCanonicalRequest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(CanonicalRequest)
).then((hash) => Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join(""));
const StringToSign = `${Algorithm}
${RequestTimestamp}
${CredentialScope}
${HashedCanonicalRequest}`;
async function hmac(key, string) {
const cryptoKey = await crypto.subtle.importKey(
"raw",
typeof key === "string" ? new TextEncoder().encode(key) : key,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const signature = await crypto.subtle.sign(
"HMAC",
cryptoKey,
new TextEncoder().encode(string)
);
return new Uint8Array(signature);
}
const SecretDate = await hmac("TC3" + secretKey, formattedDate);
const SecretService = await hmac(SecretDate, service);
const SecretSigning = await hmac(SecretService, "tc3_request");
const Signature = Array.from(
new Uint8Array(
await crypto.subtle.sign(
"HMAC",
await crypto.subtle.importKey(
"raw",
SecretSigning,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
),
new TextEncoder().encode(StringToSign)
)
)
).map((b) => b.toString(16).padStart(2, "0")).join("");
const Authorization = `${Algorithm} Credential=${secretId}/${CredentialScope}, SignedHeaders=${SignedHeaders}, Signature=${Signature}`;
headersArray["X-TC-Timestamp"] = RequestTimestamp.toString();
headersArray["Authorization"] = Authorization;
return headersArray;
}
function onRequestOptions(context) {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type"
}
});
}
async function onRequestPost(context) {
try {
const data = await context.request.json();
const { secretId, secretKey, zoneId, type, targets } = data;
const service = "teo";
const host = "teo.tencentcloudapi.com";
const payload = {
ZoneId: zoneId,
Type: type,
Targets: targets
};
const headersPending = {
"Host": host,
"Content-Type": "application/json",
"X-TC-Action": "CreatePurgeTask",
"X-TC-Version": "2022-09-01",
"X-TC-Region": "ap-guangzhou"
};
const headers = await qcloudV3Post(secretId, secretKey, service, payload, headersPending);
const response = await fetch(`https://${host}`, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
const result = await response.json();
return new Response(JSON.stringify(result), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
}
});
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
}
});
}
}
function onRequest(context) {
return new Response(JSON.stringify({ error: "Only POST method is allowed" }), {
status: 405,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
}
});
}
pagesFunctionResponse = onRequestPost;
})();
}
if('/api/clean-eo-cache' === urlInfo.pathname && request.method === 'OPTIONS') {
matchedFunc = true;
(() => {
// functions/api/clean-eo-cache.js
async function qcloudV3Post(secretId, secretKey, service, bodyArray, headersArray) {
const HTTPRequestMethod = "POST";
const CanonicalURI = "/";
const CanonicalQueryString = "";
const sortHeadersArray = Object.keys(headersArray).sort().reduce((obj, key) => {
obj[key] = headersArray[key];
return obj;
}, {});
let SignedHeaders = "";
let CanonicalHeaders = "";
for (const key in sortHeadersArray) {
SignedHeaders += key.toLowerCase() + ";";
}
SignedHeaders = SignedHeaders.slice(0, -1);
for (const key in sortHeadersArray) {
CanonicalHeaders += `${key.toLowerCase()}:${sortHeadersArray[key].toLowerCase()}
`;
}
const HashedRequestPayload = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(JSON.stringify(bodyArray))
).then((hash) => Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join(""));
const CanonicalRequest = `${HTTPRequestMethod}
${CanonicalURI}
${CanonicalQueryString}
${CanonicalHeaders}
${SignedHeaders}
${HashedRequestPayload}`;
const RequestTimestamp = Math.floor(Date.now() / 1e3);
const formattedDate = new Date(RequestTimestamp * 1e3).toISOString().split("T")[0];
const Algorithm = "TC3-HMAC-SHA256";
const CredentialScope = `${formattedDate}/${service}/tc3_request`;
const HashedCanonicalRequest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(CanonicalRequest)
).then((hash) => Array.from(new Uint8Array(hash)).map((b) => b.toString(16).padStart(2, "0")).join(""));
const StringToSign = `${Algorithm}
${RequestTimestamp}
${CredentialScope}
${HashedCanonicalRequest}`;
async function hmac(key, string) {
const cryptoKey = await crypto.subtle.importKey(
"raw",
typeof key === "string" ? new TextEncoder().encode(key) : key,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
);
const signature = await crypto.subtle.sign(
"HMAC",
cryptoKey,
new TextEncoder().encode(string)
);
return new Uint8Array(signature);
}
const SecretDate = await hmac("TC3" + secretKey, formattedDate);
const SecretService = await hmac(SecretDate, service);
const SecretSigning = await hmac(SecretService, "tc3_request");
const Signature = Array.from(
new Uint8Array(
await crypto.subtle.sign(
"HMAC",
await crypto.subtle.importKey(
"raw",
SecretSigning,
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"]
),
new TextEncoder().encode(StringToSign)
)
)
).map((b) => b.toString(16).padStart(2, "0")).join("");
const Authorization = `${Algorithm} Credential=${secretId}/${CredentialScope}, SignedHeaders=${SignedHeaders}, Signature=${Signature}`;
headersArray["X-TC-Timestamp"] = RequestTimestamp.toString();
headersArray["Authorization"] = Authorization;
return headersArray;
}
function onRequestOptions(context) {
return new Response(null, {
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type"
}
});
}
async function onRequestPost(context) {
try {
const data = await context.request.json();
const { secretId, secretKey, zoneId, type, targets } = data;
const service = "teo";
const host = "teo.tencentcloudapi.com";
const payload = {
ZoneId: zoneId,
Type: type,
Targets: targets
};
const headersPending = {
"Host": host,
"Content-Type": "application/json",
"X-TC-Action": "CreatePurgeTask",
"X-TC-Version": "2022-09-01",
"X-TC-Region": "ap-guangzhou"
};
const headers = await qcloudV3Post(secretId, secretKey, service, payload, headersPending);
const response = await fetch(`https://${host}`, {
method: "POST",
headers,
body: JSON.stringify(payload)
});
const result = await response.json();
return new Response(JSON.stringify(result), {
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
}
});
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
}
});
}
}
function onRequest(context) {
return new Response(JSON.stringify({ error: "Only POST method is allowed" }), {
status: 405,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*"
}
});
}
pagesFunctionResponse = onRequestOptions;
})();
}
const params = {};
if (routeParams.id) {
if (routeParams.mode === 1) {
const value = urlInfo.pathname.match(routeParams.left);
for (let i = 1; i < value.length; i++) {
params[routeParams.id[i - 1]] = value[i];
}
} else {
const value = urlInfo.pathname.replace(routeParams.left, '');
const splitedValue = value.split('/');
if (splitedValue.length === 1) {
params[routeParams.id] = splitedValue[0];
} else {
params[routeParams.id] = splitedValue;
}
}
}
if(!matchedFunc){
pagesFunctionResponse = function() {
return new Response(null, {
status: 404,
headers: {
"content-type": "text/html; charset=UTF-8",
"x-edgefunctions-test": "Welcome to use Pages Functions.",
},
});
}
}
return pagesFunctionResponse({request, params, env: {} });
}addEventListener('fetch',event=>{return event.respondWith(handleRequest({request:event.request,params: {}, env: {} }))});

29
.edgeone/meta.json Normal file
View File

@ -0,0 +1,29 @@
{
"conf": {},
"routes": [
{
"routePath": "/api/clean-eo-cache",
"mountPath": "/api",
"method": "",
"module": [
"api/clean-eo-cache.js:onRequest"
]
},
{
"routePath": "/api/clean-eo-cache",
"mountPath": "/api",
"method": "POST",
"module": [
"api/clean-eo-cache.js:onRequestPost"
]
},
{
"routePath": "/api/clean-eo-cache",
"mountPath": "/api",
"method": "OPTIONS",
"module": [
"api/clean-eo-cache.js:onRequestOptions"
]
}
]
}

4
.edgeone/project.json Normal file
View File

@ -0,0 +1,4 @@
{
"Name": "eoccc",
"ProjectId": "pages-zzbzb54ax2ni"
}

41
.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

684
app/form.tsx Normal file
View File

@ -0,0 +1,684 @@
"use client";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useToast } from "@/components/ui/use-toast";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Save, Trash, CheckCircle2, XCircle, Pencil } from "lucide-react";
import Link from "next/link";
interface SavedConfig {
id: string;
name: string;
secretId: string;
secretKey: string;
zoneId: string;
}
interface EOApiResponse {
Response: {
RequestId: string;
JobId: string;
FailedList: string[];
Error?: {
Message: string;
Code: string;
};
};
}
export function CleanCacheForm() {
const [secretId, setSecretId] = useState("");
const [secretKey, setSecretKey] = useState("");
const [zoneId, setZoneId] = useState("");
const [loading, setLoading] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [result, setResult] = useState<EOApiResponse | null>(null);
const [savedConfigs, setSavedConfigs] = useState<SavedConfig[]>([]);
const [saveDialogOpen, setSaveDialogOpen] = useState(false);
const [configName, setConfigName] = useState("");
const [editingConfigId, setEditingConfigId] = useState<string | null>(null);
const [urls, setUrls] = useState("");
const [prefixes, setPrefixes] = useState("");
const [hosts, setHosts] = useState("");
const [tags, setTags] = useState("");
const { toast } = useToast();
useEffect(() => {
// 加载保存的配置列表
const configs = localStorage.getItem("savedConfigs");
if (configs) {
setSavedConfigs(JSON.parse(configs));
}
}, []);
const saveConfig = () => {
if (!configName.trim()) {
toast({
variant: "destructive",
title: "错误",
description: "请输入配置名称",
});
return;
}
if (!secretId || !secretKey || !zoneId) {
toast({
variant: "destructive",
title: "错误",
description: "请填写完整的配置信息!",
});
return;
}
let updatedConfigs: SavedConfig[];
if (editingConfigId) {
// 编辑现有配置
updatedConfigs = savedConfigs.map(config => {
if (config.id === editingConfigId) {
return {
...config,
name: configName,
secretId,
secretKey,
zoneId
};
}
return config;
});
toast({
title: "成功",
description: "配置已更新",
});
} else {
// 添加新配置
const newConfig: SavedConfig = {
id: Date.now().toString(),
name: configName,
secretId,
secretKey,
zoneId,
};
updatedConfigs = [...savedConfigs, newConfig];
toast({
title: "成功",
description: "配置已保存",
});
}
setSavedConfigs(updatedConfigs);
localStorage.setItem("savedConfigs", JSON.stringify(updatedConfigs));
setSaveDialogOpen(false);
setConfigName("");
setEditingConfigId(null);
};
const editConfig = (config: SavedConfig) => {
setConfigName(config.name);
setSecretId(config.secretId);
setSecretKey(config.secretKey);
setZoneId(config.zoneId);
setEditingConfigId(config.id);
setSaveDialogOpen(true);
};
const loadConfig = (config: SavedConfig) => {
setSecretId(config.secretId);
setSecretKey(config.secretKey);
setZoneId(config.zoneId);
toast({
title: "成功",
description: "配置已加载",
});
};
const deleteConfig = (id: string) => {
const updatedConfigs = savedConfigs.filter(config => config.id !== id);
setSavedConfigs(updatedConfigs);
localStorage.setItem("savedConfigs", JSON.stringify(updatedConfigs));
toast({
title: "成功",
description: "配置已删除",
});
};
const purgeUrls = async () => {
if (!urls.trim()) {
toast({
variant: "destructive",
title: "错误",
description: "请输入需要刷新的URL",
});
return;
}
await callApi("purge_url", urls.split("\n").map(t => t.trim()).filter(t => t));
};
const purgePrefixes = async () => {
if (!prefixes.trim()) {
toast({
variant: "destructive",
title: "错误",
description: "请输入需要刷新的目录",
});
return;
}
await callApi("purge_prefix", prefixes.split("\n").map(t => t.trim()).filter(t => t));
};
const purgeHosts = async () => {
if (!hosts.trim()) {
toast({
variant: "destructive",
title: "错误",
description: "请输入需要刷新的主机",
});
return;
}
await callApi("purge_host", hosts.split("\n").map(t => t.trim()).filter(t => t));
};
const purgeTags = async () => {
if (!tags.trim()) {
toast({
variant: "destructive",
title: "错误",
description: "请输入需要刷新的缓存标签",
});
return;
}
await callApi("purge_cache_tag", tags.split("\n").map(t => t.trim()).filter(t => t));
};
const purgeAll = async () => {
await callApi("purge_all", []);
};
const callApi = async (type: string, targets: string[], method = "invalidate") => {
if (!secretId || !secretKey || !zoneId) {
toast({
variant: "destructive",
title: "错误",
description: "请填写完整的配置信息!",
});
return;
}
setLoading(true);
try {
const response = await fetch("/api/eo-cleancache", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
secretId,
secretKey,
zoneId,
type,
targets,
method,
}),
});
const result = await response.json();
setResult(result);
setDialogOpen(true);
if (result.Response && !result.Response.Error) {
toast({
title: "成功",
description: "操作成功!",
});
} else {
toast({
variant: "destructive",
title: "失败",
description: `操作失败:${
result.Response?.Error?.Message || "未知错误"
}`,
});
}
} catch (error) {
toast({
variant: "destructive",
title: "错误",
description: `请求失败:${(error as Error).message}`,
});
setResult({
Response: {
RequestId: "",
JobId: "",
FailedList: [],
Error: {
Message: (error as Error).message,
Code: "RequestError"
}
}
});
setDialogOpen(true);
} finally {
setLoading(false);
}
};
return (
<Card className="bg-white/80 backdrop-blur-sm w-full max-w-4xl mx-auto">
<CardHeader>
<CardTitle>EdgeOne缓存刷新工具</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<Card>
<CardHeader className="pb-3">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4">
<div>
<CardTitle className="text-base"></CardTitle>
<CardDescription></CardDescription>
</div>
<Button
variant="outline"
onClick={() => {
setConfigName("");
setEditingConfigId(null);
setSaveDialogOpen(true);
}}
className="h-8 w-full sm:w-auto"
>
<Save className="w-4 h-4 mr-2" />
</Button>
</div>
</CardHeader>
<CardContent>
{savedConfigs.length === 0 ? (
<p className="text-sm text-muted-foreground"></p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2">
{savedConfigs.map((config) => (
<div
key={config.id}
className="flex items-center justify-between p-2 rounded-lg hover:bg-accent group"
>
<button
onClick={() => loadConfig(config)}
className="flex-1 text-left text-sm font-medium truncate"
>
{config.name}
</button>
<div className="flex opacity-0 group-hover:opacity-100 ml-2">
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
editConfig(config);
}}
className="h-6 w-6"
>
<Pencil className="w-4 h-4 text-muted-foreground" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={(e) => {
e.stopPropagation();
deleteConfig(config.id);
}}
className="h-6 w-6"
>
<Trash className="w-4 h-4 text-destructive" />
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<div className="space-y-4">
<div className="space-y-2">
<Label>SecretId</Label>
<Input
value={secretId}
onChange={(e) => setSecretId(e.target.value)}
placeholder="请输入 SecretId"
/>
<div className="text-sm text-muted-foreground space-y-1">
<Link
href="https://console.cloud.tencent.com/cam/capi"
target="_blank"
className="underline block"
>
国内站: https://console.cloud.tencent.com/cam/capi
</Link>
<Link
href="https://console.intl.cloud.tencent.com/cam/capi"
target="_blank"
className="underline block"
>
国际站: https://console.intl.cloud.tencent.com/cam/capi
</Link>
</div>
</div>
<div className="space-y-2">
<Label>SecretKey</Label>
<Input
type="password"
value={secretKey}
onChange={(e) => setSecretKey(e.target.value)}
placeholder="请输入 SecretKey"
/>
</div>
<div className="space-y-2">
<Label>ZoneId</Label>
<Input
value={zoneId}
onChange={(e) => setZoneId(e.target.value)}
placeholder="请输入 ZoneId"
/>
<div className="text-sm text-muted-foreground space-y-1">
<Link
href="https://console.cloud.tencent.com/edgeone/zones"
target="_blank"
className="underline block"
>
国内站: https://console.cloud.tencent.com/edgeone/zones
</Link>
<Link
href="https://console.intl.cloud.tencent.com/edgeone/zones"
target="_blank"
className="underline block"
>
国际站: https://console.intl.cloud.tencent.com/edgeone/zones
</Link>
</div>
</div>
</div>
<Tabs defaultValue="all" className="w-full">
<div className="overflow-x-auto">
<TabsList className="inline-flex w-max min-w-full sm:grid sm:grid-cols-5 h-auto p-1 gap-1">
<TabsTrigger
value="all"
className="flex-shrink-0 text-xs sm:text-sm px-3 py-2 whitespace-nowrap data-[state=active]:bg-background data-[state=active]:text-foreground"
>
</TabsTrigger>
<TabsTrigger
value="url"
className="flex-shrink-0 text-xs sm:text-sm px-3 py-2 whitespace-nowrap data-[state=active]:bg-background data-[state=active]:text-foreground"
>
URL刷新
</TabsTrigger>
<TabsTrigger
value="prefix"
className="flex-shrink-0 text-xs sm:text-sm px-3 py-2 whitespace-nowrap data-[state=active]:bg-background data-[state=active]:text-foreground"
>
</TabsTrigger>
<TabsTrigger
value="host"
className="flex-shrink-0 text-xs sm:text-sm px-3 py-2 whitespace-nowrap data-[state=active]:bg-background data-[state=active]:text-foreground"
>
Host刷新
</TabsTrigger>
<TabsTrigger
value="tag"
className="flex-shrink-0 text-xs sm:text-sm px-3 py-2 whitespace-nowrap data-[state=active]:bg-background data-[state=active]:text-foreground"
>
Cache Tag
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="all">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent>
<Button
className="w-full"
disabled={loading}
onClick={purgeAll}
>
</Button>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="url">
<Card>
<CardHeader>
<CardTitle>URL刷新</CardTitle>
<CardDescription>
URL地址
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Textarea
value={urls}
onChange={(e) => setUrls(e.target.value)}
placeholder="http://example.com/file1.jpg"
className="min-h-[120px] sm:min-h-[150px]"
/>
<Button
className="w-full"
disabled={loading}
onClick={purgeUrls}
>
URL
</Button>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="prefix">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Textarea
value={prefixes}
onChange={(e) => setPrefixes(e.target.value)}
placeholder="http://example.com/images/"
className="min-h-[120px] sm:min-h-[150px]"
/>
<Button
className="w-full"
disabled={loading}
onClick={purgePrefixes}
>
</Button>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="host">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Textarea
value={hosts}
onChange={(e) => setHosts(e.target.value)}
placeholder="www.example.com"
className="min-h-[120px] sm:min-h-[150px]"
/>
<Button
className="w-full"
disabled={loading}
onClick={purgeHosts}
>
</Button>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="tag">
<Card>
<CardHeader>
<CardTitle>Cache Tag刷新 ()</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Textarea
value={tags}
onChange={(e) => setTags(e.target.value)}
placeholder="tag1&#10;tag2&#10;tag3"
className="min-h-[120px] sm:min-h-[150px]"
/>
<Button
className="w-full"
disabled={loading}
onClick={purgeTags}
>
</Button>
</CardContent>
</Card>
</TabsContent>
</Tabs>
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-[90vw] sm:max-w-lg max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{result?.Response && !result.Response.Error ? (
<>
<CheckCircle2 className="w-5 h-5 text-green-500" />
<span></span>
</>
) : (
<>
<XCircle className="w-5 h-5 text-red-500" />
<span></span>
</>
)}
</DialogTitle>
</DialogHeader>
<div className="space-y-4">
{result?.Response && !result.Response.Error ? (
<div className="p-4 bg-green-50 text-green-700 rounded-lg">
<p className="font-medium"></p>
<div className="mt-2 space-y-1 text-sm">
<p>ID: {result.Response.JobId}</p>
<p>ID: {result.Response.RequestId}</p>
{result.Response.FailedList?.length === 0 && (
<p className="text-green-600"></p>
)}
</div>
</div>
) : (
<div className="p-4 bg-red-50 text-red-700 rounded-lg">
<p className="font-medium"></p>
<p className="text-sm mt-1">
{result?.Response?.Error?.Message || "未知错误"}
</p>
</div>
)}
<div className="bg-muted p-4 rounded-lg">
<p className="text-sm text-muted-foreground mb-2"></p>
<pre className="text-xs overflow-x-auto whitespace-pre-wrap">
{JSON.stringify(result, null, 2)}
</pre>
</div>
</div>
</DialogContent>
</Dialog>
<Dialog open={saveDialogOpen} onOpenChange={setSaveDialogOpen}>
<DialogContent className="max-w-[90vw] sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editingConfigId ? "编辑配置" : "保存配置"}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label></Label>
<Input
value={configName}
onChange={(e) => setConfigName(e.target.value)}
placeholder="请输入配置名称"
/>
</div>
<div className="space-y-2">
<Label>SecretId</Label>
<Input
value={secretId}
onChange={(e) => setSecretId(e.target.value)}
placeholder="请输入 SecretId"
/>
</div>
<div className="space-y-2">
<Label>SecretKey</Label>
<Input
type="password"
value={secretKey}
onChange={(e) => setSecretKey(e.target.value)}
placeholder="请输入 SecretKey"
/>
</div>
<div className="space-y-2">
<Label>ZoneId</Label>
<Input
value={zoneId}
onChange={(e) => setZoneId(e.target.value)}
placeholder="请输入 ZoneId"
/>
</div>
</div>
<DialogFooter className="flex-col sm:flex-row gap-2">
<Button variant="outline" onClick={() => {
setSaveDialogOpen(false);
setEditingConfigId(null);
}} className="w-full sm:w-auto">
</Button>
<Button onClick={saveConfig} className="w-full sm:w-auto">{editingConfigId ? "更新" : "保存"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
);
}

122
app/globals.css Normal file
View File

@ -0,0 +1,122 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.129 0.042 264.695);
--card: oklch(1 0 0);
--card-foreground: oklch(0.129 0.042 264.695);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.129 0.042 264.695);
--primary: oklch(0.208 0.042 265.755);
--primary-foreground: oklch(0.984 0.003 247.858);
--secondary: oklch(0.968 0.007 247.896);
--secondary-foreground: oklch(0.208 0.042 265.755);
--muted: oklch(0.968 0.007 247.896);
--muted-foreground: oklch(0.554 0.046 257.417);
--accent: oklch(0.968 0.007 247.896);
--accent-foreground: oklch(0.208 0.042 265.755);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.929 0.013 255.508);
--input: oklch(0.929 0.013 255.508);
--ring: oklch(0.704 0.04 256.788);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.984 0.003 247.858);
--sidebar-foreground: oklch(0.129 0.042 264.695);
--sidebar-primary: oklch(0.208 0.042 265.755);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.968 0.007 247.896);
--sidebar-accent-foreground: oklch(0.208 0.042 265.755);
--sidebar-border: oklch(0.929 0.013 255.508);
--sidebar-ring: oklch(0.704 0.04 256.788);
}
.dark {
--background: oklch(0.129 0.042 264.695);
--foreground: oklch(0.984 0.003 247.858);
--card: oklch(0.208 0.042 265.755);
--card-foreground: oklch(0.984 0.003 247.858);
--popover: oklch(0.208 0.042 265.755);
--popover-foreground: oklch(0.984 0.003 247.858);
--primary: oklch(0.929 0.013 255.508);
--primary-foreground: oklch(0.208 0.042 265.755);
--secondary: oklch(0.279 0.041 260.031);
--secondary-foreground: oklch(0.984 0.003 247.858);
--muted: oklch(0.279 0.041 260.031);
--muted-foreground: oklch(0.704 0.04 256.788);
--accent: oklch(0.279 0.041 260.031);
--accent-foreground: oklch(0.984 0.003 247.858);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.551 0.027 264.364);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.208 0.042 265.755);
--sidebar-foreground: oklch(0.984 0.003 247.858);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.984 0.003 247.858);
--sidebar-accent: oklch(0.279 0.041 260.031);
--sidebar-accent-foreground: oklch(0.984 0.003 247.858);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.551 0.027 264.364);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

38
app/layout.tsx Normal file
View File

@ -0,0 +1,38 @@
import type { Metadata } from "next";
import "./globals.css";
import { Footer } from "@/components/ui/footer";
export const metadata: Metadata = {
title: "腾讯云EdgeOne缓存刷新工具",
description: "单页面清理腾讯云Edgeone缓存提供快速便捷的缓存刷新功能支持URL、目录、Host、全部以及基于缓存标签的刷新操作。数据保存在浏览器本地不会上传到任何服务器。",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const randomImage = `https://random-api.czl.net/pic/normal`;
return (
<html lang="zh-CN">
<body
style={{
backgroundImage: `url(${randomImage})`,
backgroundSize: "cover",
backgroundPosition: "center",
backgroundAttachment: "fixed",
backdropFilter: "blur(5px)"
}}
className="overflow-x-hidden"
>
<div className="min-h-screen flex flex-col">
<main className="flex-1 pb-20 px-4 py-8">
{children}
</main>
<Footer />
</div>
</body>
</html>
);
}

15
app/page.tsx Normal file
View File

@ -0,0 +1,15 @@
import { CleanCacheForm } from "./form";
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "腾讯云EdgeOne缓存刷新工具",
description: "单页面清理腾讯云Edgeone缓存提供快速便捷的缓存刷新功能支持URL、目录、Host、全部以及基于缓存标签的刷新操作。数据保存在浏览器本地不会上传到任何服务器。",
};
export default function CleanCachePage() {
return (
<div className="w-full max-w-4xl mx-auto flex items-start justify-center">
<CleanCacheForm />
</div>
);
}

21
components.json Normal file
View File

@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

59
components/ui/button.tsx Normal file
View File

@ -0,0 +1,59 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

92
components/ui/card.tsx Normal file
View File

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

143
components/ui/dialog.tsx Normal file
View File

@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

57
components/ui/footer.tsx Normal file
View File

@ -0,0 +1,57 @@
import Link from "next/link";
export function Footer() {
return (
<footer className="mt-auto bg-black/20 backdrop-blur-sm border-t border-white/10">
<div className="max-w-6xl mx-auto px-4 py-4">
<div className="flex flex-wrap items-center justify-center gap-2 sm:gap-4 text-xs sm:text-sm text-white/80">
<Link
href="https://www.sunai.net"
target="_blank"
rel="noopener noreferrer"
className="hover:text-white transition-colors duration-200 flex items-center gap-1 whitespace-nowrap"
>
<span>🌟</span>
<span>sunai论坛开发</span>
</Link>
<span className="text-white/40 hidden sm:inline">|</span>
<Link
href="https://github.com/woodchen-ink/Edgeone_CleanCache"
target="_blank"
rel="noopener noreferrer"
className="hover:text-white transition-colors duration-200 flex items-center gap-1 whitespace-nowrap"
>
<span></span>
<span>GitHub</span>
</Link>
<span className="text-white/40 hidden sm:inline">|</span>
<Link
href="https://woodchen.ink"
target="_blank"
rel="noopener noreferrer"
className="hover:text-white transition-colors duration-200 flex items-center gap-1 whitespace-nowrap"
>
<span>📝</span>
<span></span>
</Link>
<span className="text-white/40 hidden sm:inline">|</span>
<Link
href="https://onepage.czl.net"
target="_blank"
rel="noopener noreferrer"
className="hover:text-white transition-colors duration-200 flex items-center gap-1 whitespace-nowrap"
>
<span>🔧</span>
<span></span>
</Link>
</div>
</div>
</footer>
);
}

22
components/ui/input.tsx Normal file
View File

@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-gray-200 dark:border-gray-700 flex h-9 w-full min-w-0 rounded-md border bg-white dark:bg-gray-900 px-3 py-1 text-base shadow-xs transition-[color,box-shadow,border-color] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-blue-400 focus-visible:ring-blue-400/20 focus-visible:ring-[3px]",
"hover:border-gray-300 dark:hover:border-gray-600",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

24
components/ui/label.tsx Normal file
View File

@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }

25
components/ui/sonner.tsx Normal file
View File

@ -0,0 +1,25 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner, ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

66
components/ui/tabs.tsx Normal file
View File

@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }

129
components/ui/toast.tsx Normal file
View File

@ -0,0 +1,129 @@
"use client"
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

189
components/ui/use-toast.ts Normal file
View File

@ -0,0 +1,189 @@
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
type ActionType = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
}
let count = 0
function genId() {
count = (count + 1) % Number.MAX_VALUE
return count.toString()
}
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

16
eslint.config.mjs Normal file
View File

@ -0,0 +1,16 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig;

View File

@ -0,0 +1,162 @@
// 腾讯云 V3 签名算法
async function qcloudV3Post(secretId, secretKey, service, bodyArray, headersArray) {
const HTTPRequestMethod = "POST";
const CanonicalURI = "/";
const CanonicalQueryString = "";
const sortHeadersArray = Object.keys(headersArray)
.sort()
.reduce((obj, key) => {
obj[key] = headersArray[key];
return obj;
}, {});
let SignedHeaders = "";
let CanonicalHeaders = "";
for (const key in sortHeadersArray) {
SignedHeaders += key.toLowerCase() + ';';
}
SignedHeaders = SignedHeaders.slice(0, -1);
for (const key in sortHeadersArray) {
CanonicalHeaders += `${key.toLowerCase()}:${sortHeadersArray[key].toLowerCase()}\n`;
}
const HashedRequestPayload = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(JSON.stringify(bodyArray))
).then(hash => Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join(''));
const CanonicalRequest =
`${HTTPRequestMethod}\n${CanonicalURI}\n${CanonicalQueryString}\n${CanonicalHeaders}\n${SignedHeaders}\n${HashedRequestPayload}`;
const RequestTimestamp = Math.floor(Date.now() / 1000);
const formattedDate = new Date(RequestTimestamp * 1000).toISOString().split('T')[0];
const Algorithm = "TC3-HMAC-SHA256";
const CredentialScope = `${formattedDate}/${service}/tc3_request`;
const HashedCanonicalRequest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(CanonicalRequest)
).then(hash => Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join(''));
const StringToSign =
`${Algorithm}\n${RequestTimestamp}\n${CredentialScope}\n${HashedCanonicalRequest}`;
async function hmac(key, string) {
const cryptoKey = await crypto.subtle.importKey(
'raw',
typeof key === 'string' ? new TextEncoder().encode(key) : key,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
);
const signature = await crypto.subtle.sign(
'HMAC',
cryptoKey,
new TextEncoder().encode(string)
);
return new Uint8Array(signature);
}
const SecretDate = await hmac("TC3" + secretKey, formattedDate);
const SecretService = await hmac(SecretDate, service);
const SecretSigning = await hmac(SecretService, "tc3_request");
const Signature = Array.from(
new Uint8Array(
await crypto.subtle.sign(
'HMAC',
await crypto.subtle.importKey(
'raw',
SecretSigning,
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign']
),
new TextEncoder().encode(StringToSign)
)
)
).map(b => b.toString(16).padStart(2, '0')).join('');
const Authorization =
`${Algorithm} Credential=${secretId}/${CredentialScope}, SignedHeaders=${SignedHeaders}, Signature=${Signature}`;
headersArray["X-TC-Timestamp"] = RequestTimestamp.toString();
headersArray["Authorization"] = Authorization;
return headersArray;
}
// 处理 OPTIONS 请求
export function onRequestOptions() {
return new Response(null, {
headers: {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
}
});
}
// 处理 POST 请求
export async function onRequestPost(context) {
try {
const data = await context.request.json();
const { secretId, secretKey, zoneId, type, targets } = data;
const service = "teo";
const host = "teo.tencentcloudapi.com";
const payload = {
ZoneId: zoneId,
Type: type,
Targets: targets
};
const headersPending = {
'Host': host,
'Content-Type': 'application/json',
'X-TC-Action': 'CreatePurgeTask',
'X-TC-Version': '2022-09-01',
'X-TC-Region': 'ap-guangzhou',
};
const headers = await qcloudV3Post(secretId, secretKey, service, payload, headersPending);
const response = await fetch(`https://${host}`, {
method: 'POST',
headers: headers,
body: JSON.stringify(payload)
});
const result = await response.json();
return new Response(JSON.stringify(result), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
}
});
} catch (error) {
return new Response(JSON.stringify({ error: error.message }), {
status: 500,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
}
});
}
}
// 处理其他 HTTP 方法
export function onRequest() {
return new Response(JSON.stringify({ error: 'Only POST method is allowed' }), {
status: 405,
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
}
});
}

View File

@ -1,201 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>腾讯云EdgeOne缓存刷新工具</title>
<link rel="shortcut icon" href="https://i-aws.czl.net/r2/2023/06/20/649168ec9d6a8.ico">
<link rel="stylesheet" href="https://i-aws.czl.net/cdnjs/ajax/libs/mdui/2.1.3/mdui.min.css" />
<script src="https://i-aws.czl.net/cdnjs/ajax/libs/mdui/2.1.3/mdui.global.js"></script>
<link rel="stylesheet" href="https://i-aws.czl.net/g-f/frame/czlfonts/slice/font-noimportant.css" media="all">
<style>
body {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.config-section,
.operation-section {
margin-bottom: 20px;
padding: 15px;
border: 1px solid #ddd;
border-radius: 5px;
}
.input-group {
margin-bottom: 10px;
}
.input-group label {
display: inline-block;
width: 120px;
}
input[type="text"] {
width: 300px;
padding: 5px;
}
#result {
margin-top: 20px;
padding: 10px;
border: 1px solid #ddd;
border-radius: 4px;
min-height: 100px;
}
.help-text {
font-size: 12px;
color: #666;
margin-left: 120px;
margin-top: 5px;
}
</style>
</head>
<body>
<h1>腾讯云EdgeOne缓存刷新工具</h1>
<div class="config-section">
<h2>配置信息</h2>
<div class="input-group">
<mdui-text-field label="SecretId" type="text" id="secretId" ></mdui-text-field>
<div class="help-text">从腾讯云API密钥管理页面获取: <a href="https://console.cloud.tencent.com/cam/capi" target="_blank">https://console.cloud.tencent.com/cam/capi</a></div>
</div>
<div class="input-group">
<mdui-text-field label="SecretKey" type="text" id="secretKey"></mdui-text-field>
<div class="help-text">从腾讯云API密钥管理页面获取: <a href="https://console.cloud.tencent.com/cam/capi" target="_blank">https://console.cloud.tencent.com/cam/capi</a></div>
</div>
<div class="input-group">
<mdui-text-field label="ZoneId" type="text" id="zoneId"></mdui-text-field>
<div class="help-text">从EdgeOne控制台站点信息中获取: <a href="https://console.cloud.tencent.com/edgeone/zones" target="_blank">https://console.cloud.tencent.com/edgeone/zones</a></div>
</div>
</div>
<div class="operation-section">
<h2>操作</h2>
<div class="input-group">
<mdui-text-field type="text" id="targets" label="目标地址/标签" placeholder="多个地址用逗号分隔">
</div>
<div>
<h3>快速示例:</h3>
<mdui-button variant="text" onclick="fillExample('url')">URL刷新示例</mdui-button>
<mdui-button variant="text" onclick="fillExample('prefix')">目录刷新示例</mdui-button>
<mdui-button variant="text" onclick="fillExample('host')">Host刷新示例</mdui-button>
<mdui-button variant="text" onclick="fillExample('all')">全部刷新示例</mdui-button>
<mdui-button variant="text" onclick="fillExample('tag')">Cache Tag示例</mdui-button>
</div>
<div style="margin-top: 10px;">
<mdui-button variant="elevated" onclick="purgeUrl()">URL刷新</mdui-button>
<mdui-button variant="elevated" onclick="purgePrefix()">目录刷新</mdui-button>
<mdui-button variant="elevated" onclick="purgeHost()">Host刷新</mdui-button>
<mdui-button variant="elevated" onclick="purgeAll()">刷新全部</mdui-button>
<mdui-button variant="elevated" onclick="purgeCacheTag()">Cache Tag刷新</mdui-button>
</div>
</div>
<div id="result">
<h3>执行结果:</h3>
<pre id="resultContent"></pre>
</div>
<script>
// 页面加载时恢复保存的配置
window.onload = function () {
document.getElementById('secretId').value = localStorage.getItem('secretId') || '';
document.getElementById('secretKey').value = localStorage.getItem('secretKey') || '';
document.getElementById('zoneId').value = localStorage.getItem('zoneId') || '';
}
// 保存配置
function saveConfig() {
localStorage.setItem('secretId', document.getElementById('secretId').value);
localStorage.setItem('secretKey', document.getElementById('secretKey').value);
localStorage.setItem('zoneId', document.getElementById('zoneId').value);
}
// 监听输入变化保存配置
document.getElementById('secretId').addEventListener('change', saveConfig);
document.getElementById('secretKey').addEventListener('change', saveConfig);
document.getElementById('zoneId').addEventListener('change', saveConfig);
// 获取配置信息
function getConfig() {
return {
secretId: document.getElementById('secretId').value,
secretKey: document.getElementById('secretKey').value,
zoneId: document.getElementById('zoneId').value,
targets: document.getElementById('targets').value.split(',').map(t => t.trim()).filter(t => t)
};
}
// API调用函数
async function callApi(type, method = 'invalidate') {
const config = getConfig();
if (!config.secretId || !config.secretKey || !config.zoneId) {
alert('请填写完整的配置信息!');
return;
}
const payload = {
secretId: config.secretId,
secretKey: config.secretKey,
zoneId: config.zoneId,
type: type,
targets: config.targets,
method: method
};
try {
const response = await fetch('https://eo-cleancache.czl.net/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload)
});
const result = await response.json();
document.getElementById('resultContent').textContent = JSON.stringify(result, null, 2);
} catch (error) {
document.getElementById('resultContent').textContent = '错误:' + error.message;
}
}
function purgeUrl() {
callApi('purge_url');
}
function purgePrefix() {
callApi('purge_prefix');
}
function purgeHost() {
callApi('purge_host');
}
function purgeAll() {
callApi('purge_all');
}
function purgeCacheTag() {
callApi('purge_cache_tag');
}
</script>
<script>
function fillExample(type) {
const examples = {
'url': 'https://test.czl.net/123.txt',
'prefix': 'https://test.czl.net/book/',
'host': 'test.czl.net',
'all': '',
'tag': 'tag1'
};
document.getElementById('targets').value = examples[type];
}
</script>
</body>
</html>

6
lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

8
next.config.ts Normal file
View File

@ -0,0 +1,8 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
output: "export",
};
export default nextConfig;

6909
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "eoccc",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-toast": "^1.2.14",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.525.0",
"next": "15.3.5",
"next-themes": "^0.4.6",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sonner": "^2.0.6",
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.3.5",
"tailwindcss": "^4",
"tw-animate-css": "^1.3.5",
"typescript": "^5"
}
}

5
postcss.config.mjs Normal file
View File

@ -0,0 +1,5 @@
const config = {
plugins: ["@tailwindcss/postcss"],
};
export default config;

1
public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

1
public/window.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -1,19 +1,23 @@
# 腾讯云EdgeOne缓存清理工具
直接使用链接: https://onepage.czl.net/tools/eo_cleancache.html
直接使用链接: https://edgeone-cleancache.czl.net/
## 介绍
这是一个用于清理 EdgeOne 缓存的 Cloudflare Worker + 单html页面 项目。
这是一个用于清理 EdgeOne 缓存的 Edgeone Pages 项目, 通过静态页面+边缘函数实现快捷清理edgeone缓存。
[![Use EdgeOne Pages to deploy](https://cdnstatic.tencentcs.com/edgeone/pages/deploy.svg)](https://edgeone.ai/pages/new?repository-url=https://github.com/woodchen-ink/Edgeone_CleanCache)
## 功能
- 提供了一个 API 接口,用于提交缓存清理任务。
- 支持多种缓存清理类型,如文件、目录、路径前缀等。
- 可以通过配置文件指定腾讯云 API 的密钥和其他参数。
- 保存多个配置到浏览器内存, 选择即用, 不需要反复填写.
- 全部代码开源, 放心使用, 自部署方便.
## 使用方法和部署方法
## 分步骤部署方法
看帖子: https://www.sunai.net/t/topic/181?u=wood
看帖子: https://www.sunai.net/t/topic/181
## 注意事项

27
tsconfig.json Normal file
View File

@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}