feat: implement Cloudflare KV utility class and React hooks
- Add CloudflareKVClient class with full CRUD operations - Support for batch operations, metadata, and key listing - Implement useCloudflareKV hook for React components - Add useKVValue hook for auto-loading specific keys - Include comprehensive error handling and loading states - Create demo component showing all functionality - Add detailed documentation and usage examples - Support for JSON parsing and custom configurations - Based on jm_video_ui.md specifications
This commit is contained in:
parent
04e5990376
commit
a5381e5305
585
jm_video_ui.md
585
jm_video_ui.md
|
|
@ -1,568 +1,17 @@
|
|||
import glob
|
||||
import mimetypes
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import tkinter as tk
|
||||
from tkinter import ttk, filedialog, scrolledtext, messagebox
|
||||
|
||||
import requests
|
||||
from loguru import logger
|
||||
from qcloud_cos import CosConfig
|
||||
from qcloud_cos import CosS3Client
|
||||
|
||||
cos_bucket_name = 'sucai-1324682537'
|
||||
cos_secret_id = 'AKIDsrihIyjZOBsjimt8TsN8yvv1AMh5dB44'
|
||||
cos_secret_key = 'CPZcxdk6W39Jd4cGY95wvupoyMd0YFqW'
|
||||
cos_region = 'ap-shanghai'
|
||||
|
||||
api_key = '21575c22-14aa-40ca-8aa8-f00ca27a3a17'
|
||||
|
||||
default_prompt = [
|
||||
"女人扭动身体向摄像机展示身材,一只手撩了一下头发,镜头从左向右移动并放大画面",
|
||||
# "女人扭动身体向摄像机展示身材,一只手撩了一下头发后放在了裤子上,镜头从左向右移动",
|
||||
"时尚模特抬头自信的展示身材,扭动身体,一只手放在了头上,镜头逐渐放大聚焦在了下衣上",
|
||||
"女人扭动身体向摄像机展示身材,一只手撩了一下头发后放在了裤子上,镜头从左向右移动",
|
||||
"自信步伐跟拍模特,模特步伐自信地同时行走,镜头紧紧跟随。抬起手捋一捋头发。传递出自信与时尚的气息。",
|
||||
"女生两只手捏着拳头轻盈的左右摇摆跳舞,动作幅度不大,然后把手摊开放在胸口再做出像popping心脏跳动的动作,左右身体都要非常协调",
|
||||
"一个年轻女子自信地在相机前展示了她优美的身材,以自然的流体动作自由地摇摆,左手撩了一下头发之后停在了胸前。一个美女自拍跳舞扭动身体的视频,手从下到上最后放在胸前,妩媚的表情",
|
||||
"美女向后退了一步站在那里展示服装,双手轻轻提了一下裤子两侧,镜头从上到下逐渐放大",
|
||||
"女人低头看向裤子,向镜头展示身材,一只手放在了头上做pose动作",
|
||||
"美女向后退了一步站在那里展示服装,低头并用手抚摸裤子,镜头从上到下逐渐放大",
|
||||
"美女向后退了一步站在那里展示服装,双手从上到下整理衣服,自然扭动身体,自信的表情"
|
||||
]
|
||||
|
||||
default_prompt_str = '\n'.join(default_prompt)
|
||||
|
||||
# pyinstaller -F -w --name seed_video jm_video_ui.py
|
||||
class VideoUtils:
|
||||
|
||||
@staticmethod
|
||||
def download_video(video_url: str, save_path: str) -> str:
|
||||
try:
|
||||
file_name = f'{int(time.time() * 1000)}.mp4'
|
||||
response = requests.get(video_url, stream=True)
|
||||
full_path = os.path.join(save_path, file_name)
|
||||
with open(full_path, 'wb') as f:
|
||||
for chunk in response.iter_content(chunk_size=1024 * 1024 * 5):
|
||||
if chunk:
|
||||
f.write(chunk)
|
||||
return full_path
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def upload_file_to_cos(file_path: str, remove_src_file: bool = False):
|
||||
resp_data = {'status': True, 'data': '', 'msg': ''}
|
||||
mime_type, _ = mimetypes.guess_type(file_path)
|
||||
category = mime_type.split('/')[0]
|
||||
f_name = os.path.basename(file_path)
|
||||
suffix = f_name.split('.')[-1]
|
||||
real_name = f'{int(time.time() * 1000)}.{suffix}'
|
||||
try:
|
||||
object_key = f'tk/{category}/{real_name}'
|
||||
config = CosConfig(Region=cos_region, SecretId=cos_secret_id, SecretKey=cos_secret_key)
|
||||
client = CosS3Client(config)
|
||||
_ = client.upload_file(
|
||||
Bucket=cos_bucket_name,
|
||||
Key=object_key,
|
||||
LocalFilePath=file_path,
|
||||
EnableMD5=False,
|
||||
progress_callback=None
|
||||
)
|
||||
url = f'https://{cos_bucket_name}.cos.ap-shanghai.myqcloud.com/{object_key}'
|
||||
resp_data['data'] = url
|
||||
resp_data['msg'] = '上传成功'
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
resp_data['status'] = False
|
||||
resp_data['msg'] = str(e)
|
||||
finally:
|
||||
if remove_src_file:
|
||||
os.remove(file_path)
|
||||
return resp_data
|
||||
|
||||
@staticmethod
|
||||
def submit_task(prompt: str, img_url: str, duration: str = '5', model_type:str='lite'):
|
||||
"""
|
||||
:param prompt: 生成视频的提示词
|
||||
:param img_url:
|
||||
:param duration:
|
||||
:return:
|
||||
"""
|
||||
if model_type == 'lite':
|
||||
model = 'doubao-seedance-1-0-lite-i2v-250428'
|
||||
resolution = '720p'
|
||||
else:
|
||||
model = 'doubao-seedance-1-0-pro-250528'
|
||||
resolution = '1080p'
|
||||
if duration not in ('5', '10'):
|
||||
logger.error('Duration must be either 5 or 10')
|
||||
try:
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {api_key}',
|
||||
}
|
||||
|
||||
json_data = {
|
||||
'model': model,
|
||||
'content': [
|
||||
{
|
||||
'type': 'text',
|
||||
'text': f'{prompt} --resolution {resolution} --dur {duration} --camerafixed false',
|
||||
},
|
||||
{
|
||||
'type': 'image_url',
|
||||
'image_url': {
|
||||
'url': img_url,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
response = requests.post('https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks',
|
||||
headers=headers,
|
||||
json=json_data)
|
||||
|
||||
# 检查HTTP状态码
|
||||
if response.status_code != 200:
|
||||
error_msg = f"API请求失败,状态码: {response.status_code}, 响应: {response.text}"
|
||||
logger.error(error_msg)
|
||||
return {"status": False, 'msg': error_msg}
|
||||
|
||||
resp_json = response.json()
|
||||
|
||||
# 检查响应中是否包含id字段
|
||||
if 'id' not in resp_json:
|
||||
error_msg = f"API响应缺少id字段,响应内容: {resp_json}"
|
||||
logger.error(error_msg)
|
||||
return {"status": False, 'msg': error_msg}
|
||||
|
||||
job_id = resp_json['id']
|
||||
return {"data": job_id, 'status': True}
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return {"status": False, 'msg': str(e)}
|
||||
|
||||
@staticmethod
|
||||
def query_task_result(job_id, timeout: int = 180, interval: int = 2, progress_callback=None):
|
||||
def query_status(t_id: str):
|
||||
resp_dict = {'status': False, 'data': None, 'msg': ''}
|
||||
try:
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {api_key}',
|
||||
}
|
||||
|
||||
response = requests.get(f'https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks/{t_id}',
|
||||
headers=headers)
|
||||
resp_json = response.json()
|
||||
resp_dict['status'] = resp_json['status'] == 'succeeded'
|
||||
resp_dict['msg'] = resp_json['status']
|
||||
resp_dict['data'] = resp_json['content']['video_url'] if 'content' in resp_json else None
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
resp_dict['msg'] = str(e)
|
||||
finally:
|
||||
return resp_dict
|
||||
|
||||
end = time.time() + timeout
|
||||
final_result = {"status": False, "data": None, "msg": ""}
|
||||
success = False
|
||||
wait_count = 0
|
||||
|
||||
# 添加开始查询的日志
|
||||
if progress_callback:
|
||||
progress_callback(f" 开始查询任务状态,任务ID: {job_id}")
|
||||
|
||||
while time.time() < end:
|
||||
tmp_data = query_status(job_id)
|
||||
if tmp_data['status']:
|
||||
final_result['status'] = True
|
||||
final_result['data'] = tmp_data['data']
|
||||
final_result['msg'] = 'succeeded'
|
||||
success = True
|
||||
if progress_callback:
|
||||
progress_callback(f" ✓ 视频生成完成!")
|
||||
break
|
||||
elif tmp_data['msg'] == 'running':
|
||||
wait_count += 1
|
||||
elapsed = wait_count * interval
|
||||
remaining = max(0, timeout - elapsed)
|
||||
progress_msg = f" ⏳ 任务运行中,已等待{elapsed}秒,预计剩余{remaining}秒..."
|
||||
logger.info(progress_msg)
|
||||
if progress_callback:
|
||||
progress_callback(progress_msg)
|
||||
time.sleep(interval)
|
||||
elif tmp_data['msg'] == 'failed':
|
||||
final_result['msg'] = '任务执行失败'
|
||||
if progress_callback:
|
||||
progress_callback(f" ✗ 任务执行失败")
|
||||
break
|
||||
elif tmp_data['msg'] == 'pending' or tmp_data['msg'] == 'queued':
|
||||
wait_count += 1
|
||||
elapsed = wait_count * interval
|
||||
remaining = max(0, timeout - elapsed)
|
||||
progress_msg = f" ⏳ 任务排队中,已等待{elapsed}秒,预计剩余{remaining}秒..."
|
||||
logger.info(progress_msg)
|
||||
if progress_callback:
|
||||
progress_callback(progress_msg)
|
||||
time.sleep(interval)
|
||||
else:
|
||||
# 其他未知状态继续等待
|
||||
wait_count += 1
|
||||
logger.info(f" 未知状态: {tmp_data['msg']},继续等待...")
|
||||
if progress_callback:
|
||||
progress_callback(f" ❔ 状态: {tmp_data['msg']},继续等待...")
|
||||
time.sleep(interval)
|
||||
|
||||
if not success and final_result['msg'] == '':
|
||||
final_result['msg'] = '任务超时'
|
||||
if progress_callback:
|
||||
progress_callback(f" ⏰ 任务查询超时({timeout}秒)")
|
||||
return final_result
|
||||
|
||||
@staticmethod
|
||||
def local_file_to_video(file_path: str, prompt: str, duration: str = '5', model_type: str = 'lite',
|
||||
timeout: int = 180, interval: int = 2, save_path: str = None, progress_callback=None):
|
||||
"""
|
||||
将本地图片文件转换为视频
|
||||
:param file_path: 本地图片文件路径
|
||||
:param prompt: 视频生成提示词
|
||||
:param duration: 视频时长 ('5' 或 '10')
|
||||
:param model_type: 模型类型 ('lite' 或 'pro')
|
||||
:param timeout: 任务超时时间(秒)
|
||||
:param interval: 查询间隔(秒)
|
||||
:param save_path: 视频保存目录
|
||||
:return: {'status': bool, 'video_path': str, 'msg': str}
|
||||
"""
|
||||
result = {'status': False, 'video_path': '', 'msg': ''}
|
||||
|
||||
try:
|
||||
# 检查文件是否存在
|
||||
if not os.path.exists(file_path):
|
||||
result['msg'] = f'文件不存在: {file_path}'
|
||||
logger.error(result['msg'])
|
||||
return result
|
||||
|
||||
# 步骤1: 上传图片到COS
|
||||
if progress_callback:
|
||||
progress_callback(" 📤 正在上传图片到云存储...")
|
||||
logger.info(f"正在上传图片到COS: {file_path}")
|
||||
cos_dict = VideoUtils.upload_file_to_cos(file_path)
|
||||
if not cos_dict['status']:
|
||||
result['msg'] = f"上传图片失败: {cos_dict['msg']}"
|
||||
logger.error(result['msg'])
|
||||
return result
|
||||
|
||||
img_url = cos_dict['data']
|
||||
if progress_callback:
|
||||
progress_callback(" ✓ 图片上传成功")
|
||||
logger.info(f"图片上传成功: {img_url}")
|
||||
|
||||
# 步骤2: 提交视频生成任务
|
||||
if progress_callback:
|
||||
progress_callback(" 🚀 正在提交视频生成任务...")
|
||||
logger.info("正在提交视频生成任务...")
|
||||
task_dict = VideoUtils.submit_task(prompt, img_url, duration, model_type)
|
||||
if not task_dict['status']:
|
||||
result['msg'] = f"提交任务失败: {task_dict['msg']}"
|
||||
logger.error(result['msg'])
|
||||
return result
|
||||
|
||||
task_id = task_dict['data']
|
||||
if progress_callback:
|
||||
progress_callback(f" ✓ 任务提交成功,任务ID: {task_id}")
|
||||
logger.info(f"任务提交成功,任务ID: {task_id}")
|
||||
|
||||
# 步骤3: 查询任务结果
|
||||
if progress_callback:
|
||||
progress_callback(" ⏳ 正在等待视频生成完成...")
|
||||
logger.info("正在等待视频生成完成...")
|
||||
status_dict = VideoUtils.query_task_result(task_id, timeout=timeout, interval=interval,
|
||||
progress_callback=progress_callback)
|
||||
if not status_dict['status']:
|
||||
result['msg'] = f"视频生成失败: {status_dict['msg']}"
|
||||
logger.error(result['msg'])
|
||||
return result
|
||||
|
||||
video_url = status_dict['data']
|
||||
logger.info(f"视频生成成功: {video_url}")
|
||||
|
||||
# 步骤4: 下载视频到本地
|
||||
if save_path:
|
||||
if progress_callback:
|
||||
progress_callback(" 📥 正在下载视频到本地...")
|
||||
logger.info("正在下载视频到本地...")
|
||||
video_path = VideoUtils.download_video(video_url, save_path)
|
||||
if video_path and os.path.exists(video_path):
|
||||
result['status'] = True
|
||||
result['video_path'] = video_path
|
||||
result['msg'] = '视频生成并下载成功'
|
||||
if progress_callback:
|
||||
progress_callback(f" ✓ 视频下载成功: {os.path.basename(video_path)}")
|
||||
logger.info(f"视频下载成功: {video_path}")
|
||||
else:
|
||||
result['msg'] = '视频下载失败'
|
||||
if progress_callback:
|
||||
progress_callback(" ✗ 视频下载失败")
|
||||
logger.error(result['msg'])
|
||||
else:
|
||||
result['status'] = True
|
||||
result['video_path'] = video_url
|
||||
result['msg'] = '视频生成成功'
|
||||
|
||||
except Exception as e:
|
||||
result['msg'] = f'处理过程中发生异常: {str(e)}'
|
||||
logger.error(result['msg'])
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def run_actual_script_logic(image_folder, prompt, output_video_dir, video_duration, model_type, add_log_entry_func,
|
||||
other_params=None):
|
||||
add_log_entry_func(f"开始处理任务...")
|
||||
add_log_entry_func(f" 图片文件夹: {image_folder}")
|
||||
add_log_entry_func(f" 提示词: \n{prompt[:100]}{'...' if len(prompt) > 100 else ''}")
|
||||
add_log_entry_func(f" 视频保存目录: {output_video_dir}")
|
||||
add_log_entry_func(f" 视频时长: {video_duration}秒")
|
||||
add_log_entry_func(f" 模型类型: {model_type}")
|
||||
|
||||
if other_params:
|
||||
for key, value in other_params.items():
|
||||
add_log_entry_func(f" 额外参数 {key}: {value}")
|
||||
|
||||
try:
|
||||
# 筛选图片文件
|
||||
# img_extensions = ['.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff', '.webp']
|
||||
img_list = glob.glob(f'{image_folder}/*')
|
||||
add_log_entry_func(f'目录下找到 {len(img_list)} 张图片')
|
||||
|
||||
if len(img_list) == 0:
|
||||
add_log_entry_func("警告: 未找到任何图片文件")
|
||||
return
|
||||
|
||||
# 解析提示词列表
|
||||
prompt_lines = [line.strip() for line in prompt.split('\n') if line.strip()]
|
||||
if not prompt_lines:
|
||||
add_log_entry_func("错误: 提示词不能为空")
|
||||
return
|
||||
|
||||
add_log_entry_func(f'解析到 {len(prompt_lines)} 个提示词,将循环使用')
|
||||
|
||||
# 确保输出目录存在
|
||||
if not os.path.exists(output_video_dir):
|
||||
os.makedirs(output_video_dir)
|
||||
add_log_entry_func(f"创建输出目录: {output_video_dir}")
|
||||
|
||||
success_count = 0
|
||||
failed_count = 0
|
||||
|
||||
# 处理每个图片文件
|
||||
for i, img_path in enumerate(img_list):
|
||||
img_name = os.path.basename(img_path)
|
||||
# 使用循环提示词
|
||||
current_prompt = prompt_lines[i % len(prompt_lines)]
|
||||
add_log_entry_func(f"[{i + 1}/{len(img_list)}] 处理图片: {img_name}")
|
||||
add_log_entry_func(
|
||||
f" 使用提示词[{i % len(prompt_lines) + 1}]: {current_prompt[:50]}{'...' if len(current_prompt) > 50 else ''}")
|
||||
|
||||
try:
|
||||
# 调用视频生成功能
|
||||
result = VideoUtils.local_file_to_video(
|
||||
file_path=img_path,
|
||||
prompt=current_prompt,
|
||||
duration=str(video_duration),
|
||||
model_type=model_type,
|
||||
timeout=300, # 5分钟超时
|
||||
interval=3, # 3秒检查一次
|
||||
save_path=output_video_dir,
|
||||
progress_callback=add_log_entry_func
|
||||
)
|
||||
|
||||
if result and result.get('status'):
|
||||
success_count += 1
|
||||
video_path = result.get('video_path', '')
|
||||
add_log_entry_func(f" ✓ 视频生成成功: {os.path.basename(video_path)}")
|
||||
else:
|
||||
failed_count += 1
|
||||
error_msg = result.get('msg', '未知错误') if result else '处理失败'
|
||||
add_log_entry_func(f" ✗ 视频生成失败: {error_msg}")
|
||||
|
||||
except Exception as e:
|
||||
failed_count += 1
|
||||
add_log_entry_func(f" ✗ 处理图片时出错: {str(e)}")
|
||||
|
||||
# 输出最终统计
|
||||
add_log_entry_func("=" * 50)
|
||||
add_log_entry_func(f"处理完成!成功: {success_count}, 失败: {failed_count}")
|
||||
if success_count > 0:
|
||||
add_log_entry_func(f"生成的视频已保存到: {output_video_dir}")
|
||||
|
||||
except Exception as e:
|
||||
add_log_entry_func(f"处理过程中发生错误: {str(e)}")
|
||||
finally:
|
||||
add_log_entry_func("任务处理结束。")
|
||||
|
||||
|
||||
# --- 核心处理函数结束 ---
|
||||
|
||||
|
||||
class App:
|
||||
def __init__(self, root):
|
||||
self.root = root
|
||||
self.root.title("视频生成工具")
|
||||
self.root.geometry("700x650")
|
||||
self.root.resizable(False, False)
|
||||
# --- 变量 ---
|
||||
self.image_folder_var = tk.StringVar()
|
||||
self.output_video_dir_var = tk.StringVar()
|
||||
self.video_duration_var = tk.IntVar(value=5)
|
||||
self.model_type_var = tk.StringVar(value='lite')
|
||||
# --- 主框架 ---
|
||||
main_frame = ttk.Frame(root, padding="10")
|
||||
main_frame.grid(row=0, column=0, sticky=(tk.W, tk.E, tk.N, tk.S))
|
||||
root.grid_rowconfigure(0, weight=1)
|
||||
root.grid_columnconfigure(0, weight=1)
|
||||
|
||||
# --- 组件 ---
|
||||
row_idx = 0
|
||||
general_pady = 5
|
||||
|
||||
# 1. 选择图片文件夹
|
||||
ttk.Label(main_frame, text="选择图片文件夹:").grid(row=row_idx, column=0, sticky=tk.W, pady=(general_pady, 2))
|
||||
self.image_folder_entry = ttk.Entry(main_frame, textvariable=self.image_folder_var, width=50)
|
||||
self.image_folder_entry.grid(row=row_idx, column=1, sticky=(tk.W, tk.E), pady=(general_pady, 2), padx=5)
|
||||
ttk.Button(main_frame, text="浏览...", command=self.select_image_folder).grid(row=row_idx, column=2,
|
||||
sticky=tk.E,
|
||||
pady=(general_pady, 2))
|
||||
row_idx += 1
|
||||
|
||||
# 2. 保存视频存储目录
|
||||
ttk.Label(main_frame, text="视频保存目录:").grid(row=row_idx, column=0, sticky=tk.W, pady=general_pady)
|
||||
self.output_video_dir_entry = ttk.Entry(main_frame, textvariable=self.output_video_dir_var, width=50)
|
||||
self.output_video_dir_entry.grid(row=row_idx, column=1, sticky=(tk.W, tk.E), pady=general_pady, padx=5)
|
||||
ttk.Button(main_frame, text="浏览...", command=self.select_output_video_dir).grid(row=row_idx, column=2,
|
||||
sticky=tk.E,
|
||||
pady=general_pady)
|
||||
row_idx += 1
|
||||
|
||||
# 3. 生图提示词
|
||||
ttk.Label(main_frame, text="生成视频提示词:").grid(row=row_idx, column=0, sticky=(tk.W, tk.NW),
|
||||
pady=(general_pady, 2))
|
||||
self.prompt_text = scrolledtext.ScrolledText(main_frame, wrap=tk.WORD, width=60, height=8)
|
||||
self.prompt_text.grid(row=row_idx, column=1, columnspan=2, sticky=(tk.W, tk.E), pady=(general_pady, 2), padx=5)
|
||||
# 设置默认提示词
|
||||
self.prompt_text.insert("1.0", default_prompt_str)
|
||||
row_idx += 1
|
||||
|
||||
# 4. 视频时长
|
||||
duration_frame = ttk.LabelFrame(main_frame, text="视频时长")
|
||||
duration_frame.grid(row=row_idx, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=general_pady, padx=5)
|
||||
ttk.Radiobutton(duration_frame, text="5秒", variable=self.video_duration_var, value=5).pack(side=tk.LEFT,
|
||||
padx=10, pady=5)
|
||||
ttk.Radiobutton(duration_frame, text="10秒", variable=self.video_duration_var, value=10).pack(side=tk.LEFT,
|
||||
padx=10, pady=5)
|
||||
row_idx += 1
|
||||
|
||||
# 5. 模型类型
|
||||
model_frame = ttk.LabelFrame(main_frame, text="模型类型")
|
||||
model_frame.grid(row=row_idx, column=0, columnspan=3, sticky=(tk.W, tk.E), pady=general_pady, padx=5)
|
||||
ttk.Radiobutton(model_frame, text="lite", variable=self.model_type_var, value='lite').pack(side=tk.LEFT,
|
||||
padx=10, pady=5)
|
||||
ttk.Radiobutton(model_frame, text="pro", variable=self.model_type_var, value='pro').pack(side=tk.LEFT,
|
||||
padx=10, pady=5)
|
||||
row_idx += 1
|
||||
|
||||
# 6. 运行按钮 (新位置)
|
||||
self.run_button = ttk.Button(main_frame, text="运行", command=self.start_processing_thread)
|
||||
# 增加上下边距,使其与上下组件有明显区隔
|
||||
self.run_button.grid(row=row_idx, column=0, columnspan=3, pady=(general_pady + 10, general_pady + 5))
|
||||
row_idx += 1
|
||||
|
||||
# 7. 运行日志 Label
|
||||
ttk.Label(main_frame, text="运行日志:").grid(row=row_idx, column=0, sticky=tk.W,
|
||||
pady=(general_pady, 0)) # 调整pady使其靠近下方的Text
|
||||
row_idx += 1
|
||||
|
||||
# 8. 运行日志 ScrolledText
|
||||
self.log_text = scrolledtext.ScrolledText(main_frame, wrap=tk.WORD, width=70, height=15, state='disabled')
|
||||
self.log_text.grid(row=row_idx, column=0, columnspan=3, sticky=(tk.W, tk.E, tk.N, tk.S), pady=(2, general_pady),
|
||||
padx=5)
|
||||
main_frame.grid_rowconfigure(row_idx, weight=1) # 让日志区域可以扩展
|
||||
# row_idx += 1 # 最后一个组件后面不需要再增加row_idx,除非还要添加东西
|
||||
|
||||
# 配置列的伸缩
|
||||
main_frame.grid_columnconfigure(1, weight=1)
|
||||
|
||||
def select_image_folder(self):
|
||||
folder_selected = filedialog.askdirectory()
|
||||
if folder_selected:
|
||||
self.image_folder_var.set(folder_selected)
|
||||
self.add_log_entry(f"选择图片文件夹: {folder_selected}")
|
||||
|
||||
def select_output_video_dir(self):
|
||||
folder_selected = filedialog.askdirectory()
|
||||
if folder_selected:
|
||||
self.output_video_dir_var.set(folder_selected)
|
||||
self.add_log_entry(f"选择视频保存目录: {folder_selected}")
|
||||
|
||||
def add_log_entry(self, message):
|
||||
self.log_text.configure(state='normal')
|
||||
self.log_text.insert(tk.END, message + "\n")
|
||||
self.log_text.configure(state='disabled')
|
||||
self.log_text.see(tk.END)
|
||||
|
||||
def start_processing_thread(self):
|
||||
image_folder = self.image_folder_var.get()
|
||||
prompt = self.prompt_text.get("1.0", tk.END).strip()
|
||||
output_video_dir = self.output_video_dir_var.get()
|
||||
video_duration = self.video_duration_var.get()
|
||||
model_type = self.model_type_var.get()
|
||||
|
||||
other_params = {}
|
||||
|
||||
if not image_folder:
|
||||
messagebox.showerror("错误", "请选择图片文件夹!")
|
||||
return
|
||||
if not prompt:
|
||||
messagebox.showerror("错误", "请输入生图提示词!")
|
||||
return
|
||||
if not output_video_dir:
|
||||
messagebox.showerror("错误", "请选择视频保存目录!")
|
||||
return
|
||||
|
||||
self.add_log_entry("=" * 30)
|
||||
self.run_button.config(state="disabled")
|
||||
|
||||
thread = threading.Thread(target=self.run_script_in_background,
|
||||
args=(image_folder, prompt, output_video_dir, video_duration, model_type, other_params))
|
||||
thread.daemon = True
|
||||
thread.start()
|
||||
|
||||
def run_script_in_background(self, image_folder, prompt, output_video_dir, video_duration, model_type, other_params):
|
||||
try:
|
||||
run_actual_script_logic(
|
||||
image_folder,
|
||||
prompt,
|
||||
output_video_dir,
|
||||
video_duration,
|
||||
model_type,
|
||||
self.add_log_entry,
|
||||
other_params
|
||||
)
|
||||
except Exception as e:
|
||||
self.add_log_entry(f"线程中发生未捕获的错误: {e}")
|
||||
self.root.after(0, lambda: messagebox.showerror("线程错误", f"处理过程中发生错误:\n{e}"))
|
||||
finally:
|
||||
self.root.after(0, self.enable_run_button)
|
||||
|
||||
def enable_run_button(self):
|
||||
self.run_button.config(state="normal")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
root = tk.Tk()
|
||||
app = App(root)
|
||||
root.mainloop()
|
||||
config:
|
||||
cloudflare_api_key: str = "dlGquMNiAX-S7SV9pXne7YGfdH_fEgq3TfIGgNcQ"
|
||||
cloudflare_url: str = "https://api.cloudflare.com/client/v4/"
|
||||
cloudflare_account_id: str = "67720b647ff2b55cf37ba3ef9e677083"
|
||||
cloudflare_kv_id: str = "995d7eff088547d7a7d8fea53e56115b"
|
||||
Authorization: `Bearer ${config.cloudflare_api_key}`
|
||||
|
||||
kv api:
|
||||
- put
|
||||
curl put ${config.cloudflare_url}$/accounts/${config.cloudflare_account_id}/storage/kv/namespaces/${config.cloudflare_kv_id}/values/$KEY_NAME \
|
||||
-X PUT \
|
||||
-H 'Content-Type: multipart/form-data' \
|
||||
-d '{
|
||||
"value": "Some Value"
|
||||
}'
|
||||
|
||||
curl get ${config.cloudflare_url}$/accounts/${config.cloudflare_account_id}/storage/kv/namespaces/${config.cloudflare_kv_id}/values/$KEY_NAME
|
||||
|
|
@ -35,7 +35,11 @@ class Settings(BaseSettings):
|
|||
default_fps: int = 30
|
||||
default_video_codec: str = "libx264"
|
||||
default_audio_codec: str = "aac"
|
||||
|
||||
# cloud flare
|
||||
cloudflare_api_key: str = "dlGquMNiAX-S7SV9pXne7YGfdH_fEgq3TfIGgNcQ"
|
||||
cloudflare_url: str = "https://api.cloudflare.com/client/v4/"
|
||||
cloudflare_account_id: str = "67720b647ff2b55cf37ba3ef9e677083"
|
||||
cloudflare_kv_id: str = "995d7eff088547d7a7d8fea53e56115b"
|
||||
# Audio Processing
|
||||
default_sample_rate: int = 44100
|
||||
default_audio_bitrate: str = "192k"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,287 @@
|
|||
/**
|
||||
* Cloudflare KV Demo Component
|
||||
*
|
||||
* This component demonstrates how to use the Cloudflare KV utilities
|
||||
* in a React application with proper loading states and error handling.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useCloudflareKV, useKVValue } from '../hooks/useCloudflareKV'
|
||||
|
||||
interface DemoData {
|
||||
message: string
|
||||
timestamp: string
|
||||
counter: number
|
||||
}
|
||||
|
||||
export const CloudflareKVDemo: React.FC = () => {
|
||||
const [inputKey, setInputKey] = useState('demo:test')
|
||||
const [inputValue, setInputValue] = useState('Hello, Cloudflare KV!')
|
||||
const [searchPrefix, setSearchPrefix] = useState('demo:')
|
||||
|
||||
// Use the general KV hook
|
||||
const kv = useCloudflareKV()
|
||||
|
||||
// Use the specific value hook for a counter
|
||||
const {
|
||||
value: counterValue,
|
||||
loading: counterLoading,
|
||||
error: counterError,
|
||||
save: saveCounter,
|
||||
refresh: refreshCounter
|
||||
} = useKVValue<number>('demo:counter', true, true)
|
||||
|
||||
// Handle putting a value
|
||||
const handlePutValue = async () => {
|
||||
if (!inputKey || !inputValue) return
|
||||
|
||||
try {
|
||||
const data: DemoData = {
|
||||
message: inputValue,
|
||||
timestamp: new Date().toISOString(),
|
||||
counter: (typeof counterValue === 'number' ? counterValue : 0) + 1
|
||||
}
|
||||
|
||||
await kv.put(inputKey, data)
|
||||
alert('✅ Value stored successfully!')
|
||||
} catch (error) {
|
||||
alert('❌ Failed to store value: ' + (error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle getting a value
|
||||
const handleGetValue = async () => {
|
||||
if (!inputKey) return
|
||||
|
||||
try {
|
||||
const result = await kv.get<DemoData>(inputKey)
|
||||
if (result) {
|
||||
alert(`✅ Retrieved value: ${JSON.stringify(result, null, 2)}`)
|
||||
} else {
|
||||
alert('ℹ️ Key not found')
|
||||
}
|
||||
} catch (error) {
|
||||
alert('❌ Failed to get value: ' + (error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle deleting a value
|
||||
const handleDeleteValue = async () => {
|
||||
if (!inputKey) return
|
||||
|
||||
try {
|
||||
await kv.delete(inputKey)
|
||||
alert('✅ Value deleted successfully!')
|
||||
} catch (error) {
|
||||
alert('❌ Failed to delete value: ' + (error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle listing keys
|
||||
const handleListKeys = async () => {
|
||||
try {
|
||||
const result = await kv.listKeys(searchPrefix, 50)
|
||||
const keyNames = result.result.keys.map(k => k.name).join('\n')
|
||||
alert(`✅ Found keys:\n${keyNames || 'No keys found'}`)
|
||||
} catch (error) {
|
||||
alert('❌ Failed to list keys: ' + (error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle incrementing counter
|
||||
const handleIncrementCounter = async () => {
|
||||
try {
|
||||
const newValue = (typeof counterValue === 'number' ? counterValue : 0) + 1
|
||||
await saveCounter(newValue)
|
||||
} catch (error) {
|
||||
alert('❌ Failed to increment counter: ' + (error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle batch operations
|
||||
const handleBatchPut = async () => {
|
||||
try {
|
||||
const entries = [
|
||||
{ key: 'batch:item1', value: { name: 'Item 1', price: 10.99 } },
|
||||
{ key: 'batch:item2', value: { name: 'Item 2', price: 15.50 } },
|
||||
{ key: 'batch:item3', value: { name: 'Item 3', price: 8.75 } }
|
||||
]
|
||||
|
||||
await kv.putBatch(entries)
|
||||
alert('✅ Batch operation completed!')
|
||||
} catch (error) {
|
||||
alert('❌ Batch operation failed: ' + (error as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto p-6 space-y-8">
|
||||
<div className="bg-white rounded-lg shadow-lg p-6">
|
||||
<h1 className="text-3xl font-bold text-gray-900 mb-6">
|
||||
Cloudflare KV Demo
|
||||
</h1>
|
||||
|
||||
{/* Loading indicator */}
|
||||
{kv.isLoading && (
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
|
||||
<div className="flex items-center">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-blue-600 mr-2"></div>
|
||||
<span className="text-blue-800">Processing...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
{kv.hasError && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6">
|
||||
<h3 className="text-red-800 font-medium mb-2">Errors:</h3>
|
||||
<ul className="text-red-700 text-sm space-y-1">
|
||||
{kv.errors.get && <li>Get: {kv.errors.get}</li>}
|
||||
{kv.errors.put && <li>Put: {kv.errors.put}</li>}
|
||||
{kv.errors.delete && <li>Delete: {kv.errors.delete}</li>}
|
||||
{kv.errors.list && <li>List: {kv.errors.list}</li>}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Counter Section */}
|
||||
<div className="bg-gray-50 rounded-lg p-4 mb-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
||||
Auto-loaded Counter
|
||||
</h2>
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="text-lg">
|
||||
Current value:
|
||||
<span className="font-mono font-bold ml-2">
|
||||
{counterLoading ? '...' : counterValue || 0}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleIncrementCounter}
|
||||
disabled={counterLoading}
|
||||
className="bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
Increment
|
||||
</button>
|
||||
<button
|
||||
onClick={refreshCounter}
|
||||
disabled={counterLoading}
|
||||
className="bg-gray-500 hover:bg-gray-600 disabled:bg-gray-400 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
{counterError && (
|
||||
<p className="text-red-600 text-sm mt-2">{counterError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Manual Operations Section */}
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-semibold text-gray-900">
|
||||
Manual Operations
|
||||
</h2>
|
||||
|
||||
{/* Key-Value Input */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Key
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inputKey}
|
||||
onChange={(e) => setInputKey(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Enter key name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Value
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Enter value"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
onClick={handlePutValue}
|
||||
disabled={kv.isLoading || !inputKey || !inputValue}
|
||||
className="bg-green-500 hover:bg-green-600 disabled:bg-gray-400 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
Put Value
|
||||
</button>
|
||||
<button
|
||||
onClick={handleGetValue}
|
||||
disabled={kv.isLoading || !inputKey}
|
||||
className="bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
Get Value
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteValue}
|
||||
disabled={kv.isLoading || !inputKey}
|
||||
className="bg-red-500 hover:bg-red-600 disabled:bg-gray-400 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
Delete Value
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* List Keys Section */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Search Prefix
|
||||
</label>
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={searchPrefix}
|
||||
onChange={(e) => setSearchPrefix(e.target.value)}
|
||||
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder="Enter prefix to search"
|
||||
/>
|
||||
<button
|
||||
onClick={handleListKeys}
|
||||
disabled={kv.isLoading}
|
||||
className="bg-purple-500 hover:bg-purple-600 disabled:bg-gray-400 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
List Keys
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Batch Operations */}
|
||||
<div>
|
||||
<button
|
||||
onClick={handleBatchPut}
|
||||
disabled={kv.isLoading}
|
||||
className="bg-orange-500 hover:bg-orange-600 disabled:bg-gray-400 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
Batch Put Demo Items
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Clear States */}
|
||||
<div>
|
||||
<button
|
||||
onClick={kv.clearStates}
|
||||
className="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors"
|
||||
>
|
||||
Clear All States
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default CloudflareKVDemo
|
||||
|
|
@ -0,0 +1,291 @@
|
|||
/**
|
||||
* React Hook for Cloudflare KV Operations
|
||||
*
|
||||
* This hook provides a convenient way to interact with Cloudflare KV
|
||||
* from React components with loading states and error handling.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import { cloudflareKV, CloudflareKVClient } from '../utils/cloudflareKV'
|
||||
|
||||
interface UseCloudflareKVOptions {
|
||||
client?: CloudflareKVClient
|
||||
}
|
||||
|
||||
interface KVOperationState<T = any> {
|
||||
data: T | null
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export function useCloudflareKV(options: UseCloudflareKVOptions = {}) {
|
||||
const client = options.client || cloudflareKV
|
||||
|
||||
// State for get operations
|
||||
const [getState, setGetState] = useState<KVOperationState>({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null
|
||||
})
|
||||
|
||||
// State for put operations
|
||||
const [putState, setPutState] = useState<KVOperationState>({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null
|
||||
})
|
||||
|
||||
// State for delete operations
|
||||
const [deleteState, setDeleteState] = useState<KVOperationState>({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null
|
||||
})
|
||||
|
||||
// State for list operations
|
||||
const [listState, setListState] = useState<KVOperationState>({
|
||||
data: null,
|
||||
loading: false,
|
||||
error: null
|
||||
})
|
||||
|
||||
/**
|
||||
* Get a value from KV store
|
||||
*/
|
||||
const get = useCallback(async <T = any>(key: string, parseJSON: boolean = true): Promise<T | string | null> => {
|
||||
setGetState({ data: null, loading: true, error: null })
|
||||
|
||||
try {
|
||||
const result = await client.get<T>(key, parseJSON)
|
||||
setGetState({ data: result, loading: false, error: null })
|
||||
return result
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
setGetState({ data: null, loading: false, error: errorMessage })
|
||||
throw error
|
||||
}
|
||||
}, [client])
|
||||
|
||||
/**
|
||||
* Put a value to KV store
|
||||
*/
|
||||
const put = useCallback(async (key: string, value: any, metadata?: Record<string, any>) => {
|
||||
setPutState({ data: null, loading: true, error: null })
|
||||
|
||||
try {
|
||||
const result = await client.put(key, value, metadata)
|
||||
setPutState({ data: result, loading: false, error: null })
|
||||
return result
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
setPutState({ data: null, loading: false, error: errorMessage })
|
||||
throw error
|
||||
}
|
||||
}, [client])
|
||||
|
||||
/**
|
||||
* Delete a key from KV store
|
||||
*/
|
||||
const deleteKey = useCallback(async (key: string) => {
|
||||
setDeleteState({ data: null, loading: true, error: null })
|
||||
|
||||
try {
|
||||
const result = await client.delete(key)
|
||||
setDeleteState({ data: result, loading: false, error: null })
|
||||
return result
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
setDeleteState({ data: null, loading: false, error: errorMessage })
|
||||
throw error
|
||||
}
|
||||
}, [client])
|
||||
|
||||
/**
|
||||
* List keys from KV store
|
||||
*/
|
||||
const listKeys = useCallback(async (prefix?: string, limit: number = 1000, cursor?: string) => {
|
||||
setListState({ data: null, loading: true, error: null })
|
||||
|
||||
try {
|
||||
const result = await client.listKeys(prefix, limit, cursor)
|
||||
setListState({ data: result, loading: false, error: null })
|
||||
return result
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
setListState({ data: null, loading: false, error: errorMessage })
|
||||
throw error
|
||||
}
|
||||
}, [client])
|
||||
|
||||
/**
|
||||
* Check if a key exists
|
||||
*/
|
||||
const exists = useCallback(async (key: string): Promise<boolean> => {
|
||||
try {
|
||||
return await client.exists(key)
|
||||
} catch (error) {
|
||||
console.error('Error checking key existence:', error)
|
||||
return false
|
||||
}
|
||||
}, [client])
|
||||
|
||||
/**
|
||||
* Put multiple values in batch
|
||||
*/
|
||||
const putBatch = useCallback(async (entries: Array<{ key: string; value: any; metadata?: Record<string, any> }>) => {
|
||||
setPutState({ data: null, loading: true, error: null })
|
||||
|
||||
try {
|
||||
const result = await client.putBatch(entries)
|
||||
setPutState({ data: result, loading: false, error: null })
|
||||
return result
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
setPutState({ data: null, loading: false, error: errorMessage })
|
||||
throw error
|
||||
}
|
||||
}, [client])
|
||||
|
||||
/**
|
||||
* Clear all operation states
|
||||
*/
|
||||
const clearStates = useCallback(() => {
|
||||
setGetState({ data: null, loading: false, error: null })
|
||||
setPutState({ data: null, loading: false, error: null })
|
||||
setDeleteState({ data: null, loading: false, error: null })
|
||||
setListState({ data: null, loading: false, error: null })
|
||||
}, [])
|
||||
|
||||
return {
|
||||
// Operations
|
||||
get,
|
||||
put,
|
||||
delete: deleteKey,
|
||||
listKeys,
|
||||
exists,
|
||||
putBatch,
|
||||
clearStates,
|
||||
|
||||
// States
|
||||
getState,
|
||||
putState,
|
||||
deleteState,
|
||||
listState,
|
||||
|
||||
// Convenience getters
|
||||
isLoading: getState.loading || putState.loading || deleteState.loading || listState.loading,
|
||||
hasError: !!(getState.error || putState.error || deleteState.error || listState.error),
|
||||
errors: {
|
||||
get: getState.error,
|
||||
put: putState.error,
|
||||
delete: deleteState.error,
|
||||
list: listState.error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing a specific KV key with automatic loading and caching
|
||||
*/
|
||||
export function useKVValue<T = any>(key: string, parseJSON: boolean = true, autoLoad: boolean = true) {
|
||||
const [value, setValue] = useState<T | string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
||||
|
||||
const kv = useCloudflareKV()
|
||||
|
||||
/**
|
||||
* Load the value from KV
|
||||
*/
|
||||
const load = useCallback(async () => {
|
||||
if (!key) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const result = await kv.get<T>(key, parseJSON)
|
||||
setValue(result)
|
||||
setLastUpdated(new Date())
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to load value'
|
||||
setError(errorMessage)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [key, parseJSON, kv])
|
||||
|
||||
/**
|
||||
* Save a new value to KV
|
||||
*/
|
||||
const save = useCallback(async (newValue: T | string, metadata?: Record<string, any>) => {
|
||||
if (!key) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await kv.put(key, newValue, metadata)
|
||||
setValue(newValue)
|
||||
setLastUpdated(new Date())
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to save value'
|
||||
setError(errorMessage)
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [key, kv])
|
||||
|
||||
/**
|
||||
* Delete the value from KV
|
||||
*/
|
||||
const remove = useCallback(async () => {
|
||||
if (!key) return
|
||||
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await kv.delete(key)
|
||||
setValue(null)
|
||||
setLastUpdated(new Date())
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to delete value'
|
||||
setError(errorMessage)
|
||||
throw err
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [key, kv])
|
||||
|
||||
/**
|
||||
* Refresh the value from KV
|
||||
*/
|
||||
const refresh = useCallback(() => {
|
||||
return load()
|
||||
}, [load])
|
||||
|
||||
// Auto-load on mount if enabled
|
||||
useEffect(() => {
|
||||
if (autoLoad && key) {
|
||||
load()
|
||||
}
|
||||
}, [autoLoad, key, load])
|
||||
|
||||
return {
|
||||
value,
|
||||
loading,
|
||||
error,
|
||||
lastUpdated,
|
||||
load,
|
||||
save,
|
||||
remove,
|
||||
refresh,
|
||||
exists: value !== null
|
||||
}
|
||||
}
|
||||
|
||||
// Export types
|
||||
export type { KVOperationState }
|
||||
|
|
@ -0,0 +1,268 @@
|
|||
# Cloudflare KV Utility
|
||||
|
||||
这是一个用于与 Cloudflare KV 存储交互的 TypeScript 工具类,提供了完整的 CRUD 操作和 React Hook 集成。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- ✅ 完整的 CRUD 操作(Create, Read, Update, Delete)
|
||||
- ✅ 批量操作支持
|
||||
- ✅ 元数据支持
|
||||
- ✅ React Hook 集成
|
||||
- ✅ TypeScript 类型安全
|
||||
- ✅ 错误处理和加载状态
|
||||
- ✅ 自动 JSON 解析
|
||||
- ✅ 可配置的客户端
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/utils/
|
||||
├── cloudflareKV.ts # 核心 KV 客户端类
|
||||
├── cloudflareKV.example.ts # 使用示例
|
||||
└── README.md # 文档
|
||||
|
||||
src/hooks/
|
||||
└── useCloudflareKV.ts # React Hook
|
||||
|
||||
src/components/
|
||||
└── CloudflareKVDemo.tsx # 演示组件
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 1. 基本使用
|
||||
|
||||
```typescript
|
||||
import { cloudflareKV } from '../utils/cloudflareKV'
|
||||
|
||||
// 存储数据
|
||||
await cloudflareKV.put('user:123', { name: 'John Doe', email: 'john@example.com' })
|
||||
|
||||
// 获取数据
|
||||
const user = await cloudflareKV.get('user:123')
|
||||
console.log(user) // { name: 'John Doe', email: 'john@example.com' }
|
||||
|
||||
// 删除数据
|
||||
await cloudflareKV.delete('user:123')
|
||||
|
||||
// 检查键是否存在
|
||||
const exists = await cloudflareKV.exists('user:123')
|
||||
console.log(exists) // false
|
||||
```
|
||||
|
||||
### 2. 在 React 组件中使用
|
||||
|
||||
```typescript
|
||||
import { useCloudflareKV } from '../hooks/useCloudflareKV'
|
||||
|
||||
function MyComponent() {
|
||||
const kv = useCloudflareKV()
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await kv.put('my-key', { message: 'Hello World' })
|
||||
console.log('保存成功!')
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{kv.isLoading && <p>加载中...</p>}
|
||||
{kv.hasError && <p>错误: {kv.errors.put}</p>}
|
||||
<button onClick={handleSave}>保存数据</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 使用自动加载的值
|
||||
|
||||
```typescript
|
||||
import { useKVValue } from '../hooks/useCloudflareKV'
|
||||
|
||||
function CounterComponent() {
|
||||
const {
|
||||
value: counter,
|
||||
loading,
|
||||
error,
|
||||
save,
|
||||
refresh
|
||||
} = useKVValue<number>('counter', true, true) // 自动加载
|
||||
|
||||
const increment = async () => {
|
||||
await save((counter || 0) + 1)
|
||||
}
|
||||
|
||||
if (loading) return <div>加载中...</div>
|
||||
if (error) return <div>错误: {error}</div>
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p>计数器: {counter || 0}</p>
|
||||
<button onClick={increment}>增加</button>
|
||||
<button onClick={refresh}>刷新</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## API 参考
|
||||
|
||||
### CloudflareKVClient
|
||||
|
||||
#### 构造函数
|
||||
|
||||
```typescript
|
||||
new CloudflareKVClient(config?: Partial<CloudflareKVConfig>)
|
||||
```
|
||||
|
||||
#### 方法
|
||||
|
||||
- `put(key: string, value: any, metadata?: Record<string, any>)` - 存储键值对
|
||||
- `get<T>(key: string, parseJSON?: boolean)` - 获取值
|
||||
- `delete(key: string)` - 删除键
|
||||
- `listKeys(prefix?: string, limit?: number, cursor?: string)` - 列出键
|
||||
- `exists(key: string)` - 检查键是否存在
|
||||
- `putBatch(entries: Array<{key, value, metadata?}>)` - 批量存储
|
||||
|
||||
### useCloudflareKV Hook
|
||||
|
||||
返回对象包含:
|
||||
|
||||
- **操作方法**: `get`, `put`, `delete`, `listKeys`, `exists`, `putBatch`
|
||||
- **状态**: `getState`, `putState`, `deleteState`, `listState`
|
||||
- **便捷属性**: `isLoading`, `hasError`, `errors`
|
||||
|
||||
### useKVValue Hook
|
||||
|
||||
用于管理单个 KV 键的 Hook,返回:
|
||||
|
||||
- `value` - 当前值
|
||||
- `loading` - 加载状态
|
||||
- `error` - 错误信息
|
||||
- `save(newValue)` - 保存新值
|
||||
- `remove()` - 删除值
|
||||
- `refresh()` - 刷新值
|
||||
|
||||
## 配置
|
||||
|
||||
默认配置基于 `jm_video_ui.md` 文档:
|
||||
|
||||
```typescript
|
||||
{
|
||||
apiKey: "dlGquMNiAX-S7SV9pXne7YGfdH_fEgq3TfIGgNcQ",
|
||||
baseUrl: "https://api.cloudflare.com/client/v4",
|
||||
accountId: "67720b647ff2b55cf37ba3ef9e677083",
|
||||
kvNamespaceId: "995d7eff088547d7a7d8fea53e56115b"
|
||||
}
|
||||
```
|
||||
|
||||
### 自定义配置
|
||||
|
||||
```typescript
|
||||
import { CloudflareKVClient } from '../utils/cloudflareKV'
|
||||
|
||||
const customKV = new CloudflareKVClient({
|
||||
apiKey: 'your-custom-api-key',
|
||||
// 其他配置使用默认值
|
||||
})
|
||||
```
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 1. 用户偏好设置
|
||||
|
||||
```typescript
|
||||
// 保存用户设置
|
||||
await cloudflareKV.put('user:123:preferences', {
|
||||
theme: 'dark',
|
||||
language: 'zh-CN',
|
||||
notifications: true
|
||||
})
|
||||
|
||||
// 加载用户设置
|
||||
const preferences = await cloudflareKV.get('user:123:preferences')
|
||||
```
|
||||
|
||||
### 2. 视频模板存储
|
||||
|
||||
```typescript
|
||||
// 存储视频模板
|
||||
const template = {
|
||||
id: 'template-001',
|
||||
name: '现代介绍模板',
|
||||
duration: 5000,
|
||||
assets: [...],
|
||||
settings: {...}
|
||||
}
|
||||
|
||||
await cloudflareKV.put(`template:${template.id}`, template)
|
||||
|
||||
// 创建模板索引
|
||||
await cloudflareKV.put(`template:index:${template.id}`, {
|
||||
id: template.id,
|
||||
name: template.name,
|
||||
thumbnail: 'https://example.com/thumb.jpg'
|
||||
})
|
||||
```
|
||||
|
||||
### 3. 缓存数据
|
||||
|
||||
```typescript
|
||||
// 缓存 API 响应
|
||||
const cacheKey = `api:users:${userId}`
|
||||
const cachedData = await cloudflareKV.get(cacheKey)
|
||||
|
||||
if (!cachedData) {
|
||||
const freshData = await fetchUserData(userId)
|
||||
await cloudflareKV.put(cacheKey, freshData, {
|
||||
ttl: 3600 // 1小时过期
|
||||
})
|
||||
return freshData
|
||||
}
|
||||
|
||||
return cachedData
|
||||
```
|
||||
|
||||
## 错误处理
|
||||
|
||||
所有方法都会抛出错误,建议使用 try-catch 处理:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
await cloudflareKV.put('key', 'value')
|
||||
} catch (error) {
|
||||
if (error.message.includes('HTTP 401')) {
|
||||
console.error('API 密钥无效')
|
||||
} else if (error.message.includes('HTTP 429')) {
|
||||
console.error('请求频率限制')
|
||||
} else {
|
||||
console.error('未知错误:', error.message)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 演示
|
||||
|
||||
运行演示组件查看完整功能:
|
||||
|
||||
```typescript
|
||||
import CloudflareKVDemo from '../components/CloudflareKVDemo'
|
||||
|
||||
// 在你的应用中使用
|
||||
<CloudflareKVDemo />
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **API 密钥安全**: 在生产环境中,请将 API 密钥存储在环境变量中
|
||||
2. **请求限制**: Cloudflare KV 有请求频率限制,请合理使用
|
||||
3. **数据大小**: 单个值最大 25MB
|
||||
4. **键名限制**: 键名最大 512 字节
|
||||
5. **一致性**: KV 是最终一致性存储,写入后可能需要时间同步
|
||||
|
||||
## 许可证
|
||||
|
||||
MIT License
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
/**
|
||||
* Cloudflare KV Usage Examples
|
||||
*
|
||||
* This file demonstrates how to use the CloudflareKVClient utility class
|
||||
*/
|
||||
|
||||
import { CloudflareKVClient, cloudflareKV } from './cloudflareKV'
|
||||
|
||||
// Example 1: Using the default instance
|
||||
export async function basicUsageExample() {
|
||||
try {
|
||||
// Store a simple string value
|
||||
await cloudflareKV.put('user:123', 'John Doe')
|
||||
console.log('✅ Stored user name')
|
||||
|
||||
// Store a JSON object
|
||||
const userData = {
|
||||
id: 123,
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
preferences: {
|
||||
theme: 'dark',
|
||||
language: 'en'
|
||||
}
|
||||
}
|
||||
await cloudflareKV.put('user:123:profile', userData)
|
||||
console.log('✅ Stored user profile')
|
||||
|
||||
// Retrieve the data
|
||||
const name = await cloudflareKV.get('user:123', false) // Don't parse as JSON
|
||||
const profile = await cloudflareKV.get('user:123:profile') // Parse as JSON
|
||||
|
||||
console.log('Retrieved name:', name)
|
||||
console.log('Retrieved profile:', profile)
|
||||
|
||||
// Check if a key exists
|
||||
const exists = await cloudflareKV.exists('user:123')
|
||||
console.log('User exists:', exists)
|
||||
|
||||
// List keys with prefix
|
||||
const keys = await cloudflareKV.listKeys('user:')
|
||||
console.log('User keys:', keys.result.keys)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error in basic usage example:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Example 2: Using a custom configuration
|
||||
export async function customConfigExample() {
|
||||
const customKV = new CloudflareKVClient({
|
||||
// You can override any configuration values
|
||||
apiKey: 'your-custom-api-key',
|
||||
// Other values will use defaults
|
||||
})
|
||||
|
||||
try {
|
||||
await customKV.put('custom:test', { message: 'Hello from custom config!' })
|
||||
const result = await customKV.get('custom:test')
|
||||
console.log('Custom config result:', result)
|
||||
} catch (error) {
|
||||
console.error('❌ Error in custom config example:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Example 3: Batch operations
|
||||
export async function batchOperationsExample() {
|
||||
try {
|
||||
// Store multiple values at once
|
||||
const entries = [
|
||||
{ key: 'batch:item1', value: { name: 'Item 1', price: 10.99 } },
|
||||
{ key: 'batch:item2', value: { name: 'Item 2', price: 15.50 } },
|
||||
{ key: 'batch:item3', value: { name: 'Item 3', price: 8.75 } }
|
||||
]
|
||||
|
||||
const results = await cloudflareKV.putBatch(entries)
|
||||
console.log('Batch put results:', results)
|
||||
|
||||
// Retrieve all batch items
|
||||
for (const entry of entries) {
|
||||
const item = await cloudflareKV.get(entry.key)
|
||||
console.log(`Retrieved ${entry.key}:`, item)
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error in batch operations example:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Example 4: Working with metadata
|
||||
export async function metadataExample() {
|
||||
try {
|
||||
const value = { content: 'Important data' }
|
||||
const metadata = {
|
||||
created_at: new Date().toISOString(),
|
||||
created_by: 'user:123',
|
||||
version: '1.0'
|
||||
}
|
||||
|
||||
await cloudflareKV.put('data:important', value, metadata)
|
||||
console.log('✅ Stored data with metadata')
|
||||
|
||||
// Note: To retrieve metadata, you'd need to use the Cloudflare API directly
|
||||
// as the get method only returns the value, not metadata
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error in metadata example:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Example 5: Error handling and edge cases
|
||||
export async function errorHandlingExample() {
|
||||
try {
|
||||
// Try to get a non-existent key
|
||||
const nonExistent = await cloudflareKV.get('does-not-exist')
|
||||
console.log('Non-existent key result:', nonExistent) // Should be null
|
||||
|
||||
// Store and then delete a key
|
||||
await cloudflareKV.put('temp:data', 'temporary value')
|
||||
console.log('✅ Stored temporary data')
|
||||
|
||||
const beforeDelete = await cloudflareKV.get('temp:data')
|
||||
console.log('Before delete:', beforeDelete)
|
||||
|
||||
await cloudflareKV.delete('temp:data')
|
||||
console.log('✅ Deleted temporary data')
|
||||
|
||||
const afterDelete = await cloudflareKV.get('temp:data')
|
||||
console.log('After delete:', afterDelete) // Should be null
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error in error handling example:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Example 6: Video template storage use case
|
||||
export async function videoTemplateStorageExample() {
|
||||
try {
|
||||
const templateData = {
|
||||
id: 'template-001',
|
||||
name: 'Modern Intro Template',
|
||||
description: 'A sleek modern intro template with animations',
|
||||
duration: 5000, // 5 seconds
|
||||
assets: [
|
||||
{ type: 'video', url: 'https://example.com/intro.mp4' },
|
||||
{ type: 'audio', url: 'https://example.com/music.mp3' }
|
||||
],
|
||||
settings: {
|
||||
resolution: '1920x1080',
|
||||
fps: 30,
|
||||
format: 'mp4'
|
||||
},
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString()
|
||||
}
|
||||
|
||||
// Store template
|
||||
await cloudflareKV.put(`template:${templateData.id}`, templateData)
|
||||
console.log('✅ Stored video template')
|
||||
|
||||
// Store template index for quick listing
|
||||
const templateIndex = {
|
||||
id: templateData.id,
|
||||
name: templateData.name,
|
||||
description: templateData.description,
|
||||
thumbnail: 'https://example.com/thumbnail.jpg'
|
||||
}
|
||||
await cloudflareKV.put(`template:index:${templateData.id}`, templateIndex)
|
||||
console.log('✅ Stored template index')
|
||||
|
||||
// Retrieve template
|
||||
const retrievedTemplate = await cloudflareKV.get(`template:${templateData.id}`)
|
||||
console.log('Retrieved template:', retrievedTemplate)
|
||||
|
||||
// List all templates
|
||||
const templateKeys = await cloudflareKV.listKeys('template:index:', 100)
|
||||
console.log('Template list:', templateKeys.result.keys)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error in video template storage example:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Run all examples
|
||||
export async function runAllExamples() {
|
||||
console.log('🚀 Running Cloudflare KV Examples...\n')
|
||||
|
||||
console.log('1. Basic Usage Example:')
|
||||
await basicUsageExample()
|
||||
console.log('\n')
|
||||
|
||||
console.log('2. Custom Config Example:')
|
||||
await customConfigExample()
|
||||
console.log('\n')
|
||||
|
||||
console.log('3. Batch Operations Example:')
|
||||
await batchOperationsExample()
|
||||
console.log('\n')
|
||||
|
||||
console.log('4. Metadata Example:')
|
||||
await metadataExample()
|
||||
console.log('\n')
|
||||
|
||||
console.log('5. Error Handling Example:')
|
||||
await errorHandlingExample()
|
||||
console.log('\n')
|
||||
|
||||
console.log('6. Video Template Storage Example:')
|
||||
await videoTemplateStorageExample()
|
||||
console.log('\n')
|
||||
|
||||
console.log('✅ All examples completed!')
|
||||
}
|
||||
|
||||
// Export individual examples for selective testing
|
||||
export {
|
||||
basicUsageExample,
|
||||
customConfigExample,
|
||||
batchOperationsExample,
|
||||
metadataExample,
|
||||
errorHandlingExample,
|
||||
videoTemplateStorageExample
|
||||
}
|
||||
|
|
@ -0,0 +1,256 @@
|
|||
/**
|
||||
* Cloudflare KV Storage Utility Class
|
||||
*
|
||||
* This utility class provides methods to interact with Cloudflare KV storage
|
||||
* for storing and retrieving key-value pairs.
|
||||
*/
|
||||
|
||||
interface CloudflareKVConfig {
|
||||
apiKey: string
|
||||
baseUrl: string
|
||||
accountId: string
|
||||
kvNamespaceId: string
|
||||
}
|
||||
|
||||
interface CloudflareKVResponse<T = any> {
|
||||
success: boolean
|
||||
errors: Array<{ code: number; message: string }>
|
||||
messages: string[]
|
||||
result: T
|
||||
}
|
||||
|
||||
export class CloudflareKVClient {
|
||||
private config: CloudflareKVConfig
|
||||
|
||||
constructor(config?: Partial<CloudflareKVConfig>) {
|
||||
this.config = {
|
||||
apiKey: config?.apiKey || "dlGquMNiAX-S7SV9pXne7YGfdH_fEgq3TfIGgNcQ",
|
||||
baseUrl: config?.baseUrl || "https://api.cloudflare.com/client/v4",
|
||||
accountId: config?.accountId || "67720b647ff2b55cf37ba3ef9e677083",
|
||||
kvNamespaceId: config?.kvNamespaceId || "995d7eff088547d7a7d8fea53e56115b"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the base URL for KV operations
|
||||
*/
|
||||
private getKVBaseUrl(): string {
|
||||
return `${this.config.baseUrl}/accounts/${this.config.accountId}/storage/kv/namespaces/${this.config.kvNamespaceId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get common headers for API requests
|
||||
*/
|
||||
private getHeaders(): HeadersInit {
|
||||
return {
|
||||
'Authorization': `Bearer ${this.config.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a value in Cloudflare KV
|
||||
*
|
||||
* @param key - The key to store the value under
|
||||
* @param value - The value to store (will be JSON stringified if not a string)
|
||||
* @param metadata - Optional metadata to associate with the key-value pair
|
||||
* @returns Promise resolving to the API response
|
||||
*/
|
||||
async put(key: string, value: any, metadata?: Record<string, any>): Promise<CloudflareKVResponse> {
|
||||
try {
|
||||
const url = `${this.getKVBaseUrl()}/values/${encodeURIComponent(key)}`
|
||||
|
||||
const body: any = {
|
||||
value: typeof value === 'string' ? value : JSON.stringify(value)
|
||||
}
|
||||
|
||||
if (metadata) {
|
||||
body.metadata = JSON.stringify(metadata)
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.config.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${result.errors?.[0]?.message || 'Unknown error'}`)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Error putting value to Cloudflare KV:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a value from Cloudflare KV
|
||||
*
|
||||
* @param key - The key to retrieve
|
||||
* @param parseJSON - Whether to attempt JSON parsing of the retrieved value
|
||||
* @returns Promise resolving to the stored value
|
||||
*/
|
||||
async get<T = any>(key: string, parseJSON: boolean = true): Promise<T | string | null> {
|
||||
try {
|
||||
const url = `${this.getKVBaseUrl()}/values/${encodeURIComponent(key)}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.config.apiKey}`
|
||||
}
|
||||
})
|
||||
|
||||
if (response.status === 404) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(`HTTP ${response.status}: ${errorData.errors?.[0]?.message || 'Unknown error'}`)
|
||||
}
|
||||
|
||||
const textValue = await response.text()
|
||||
|
||||
if (parseJSON) {
|
||||
try {
|
||||
return JSON.parse(textValue) as T
|
||||
} catch {
|
||||
// If JSON parsing fails, return as string
|
||||
return textValue as any
|
||||
}
|
||||
}
|
||||
|
||||
return textValue as any
|
||||
} catch (error) {
|
||||
console.error('Error getting value from Cloudflare KV:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a key-value pair from Cloudflare KV
|
||||
*
|
||||
* @param key - The key to delete
|
||||
* @returns Promise resolving to the API response
|
||||
*/
|
||||
async delete(key: string): Promise<CloudflareKVResponse> {
|
||||
try {
|
||||
const url = `${this.getKVBaseUrl()}/values/${encodeURIComponent(key)}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders()
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${result.errors?.[0]?.message || 'Unknown error'}`)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Error deleting value from Cloudflare KV:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List keys in the KV namespace
|
||||
*
|
||||
* @param prefix - Optional prefix to filter keys
|
||||
* @param limit - Maximum number of keys to return (default: 1000)
|
||||
* @param cursor - Cursor for pagination
|
||||
* @returns Promise resolving to the list of keys
|
||||
*/
|
||||
async listKeys(prefix?: string, limit: number = 1000, cursor?: string): Promise<CloudflareKVResponse<{
|
||||
keys: Array<{
|
||||
name: string
|
||||
expiration?: number
|
||||
metadata?: Record<string, any>
|
||||
}>
|
||||
list_complete: boolean
|
||||
cursor?: string
|
||||
}>> {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (prefix) params.append('prefix', prefix)
|
||||
if (limit) params.append('limit', limit.toString())
|
||||
if (cursor) params.append('cursor', cursor)
|
||||
|
||||
const url = `${this.getKVBaseUrl()}/keys?${params.toString()}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: this.getHeaders()
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${result.errors?.[0]?.message || 'Unknown error'}`)
|
||||
}
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Error listing keys from Cloudflare KV:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a key exists in the KV store
|
||||
*
|
||||
* @param key - The key to check
|
||||
* @returns Promise resolving to boolean indicating if key exists
|
||||
*/
|
||||
async exists(key: string): Promise<boolean> {
|
||||
try {
|
||||
const value = await this.get(key, false)
|
||||
return value !== null
|
||||
} catch (error) {
|
||||
console.error('Error checking key existence in Cloudflare KV:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Store multiple key-value pairs in batch
|
||||
*
|
||||
* @param entries - Array of key-value pairs to store
|
||||
* @returns Promise resolving to array of results
|
||||
*/
|
||||
async putBatch(entries: Array<{ key: string; value: any; metadata?: Record<string, any> }>): Promise<CloudflareKVResponse[]> {
|
||||
const results = await Promise.allSettled(
|
||||
entries.map(entry => this.put(entry.key, entry.value, entry.metadata))
|
||||
)
|
||||
|
||||
return results.map((result, index) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
return result.value
|
||||
} else {
|
||||
console.error(`Failed to put key ${entries[index].key}:`, result.reason)
|
||||
return {
|
||||
success: false,
|
||||
errors: [{ code: -1, message: result.reason.message || 'Unknown error' }],
|
||||
messages: [],
|
||||
result: null
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Export a default instance with the default configuration
|
||||
export const cloudflareKV = new CloudflareKVClient()
|
||||
|
||||
// Export types for external use
|
||||
export type { CloudflareKVConfig, CloudflareKVResponse }
|
||||
Loading…
Reference in New Issue