From 5f7ddc197a98e500c6a2821bf6531bd615bcee6e Mon Sep 17 00:00:00 2001
From: p
Date: Thu, 13 Aug 2026 15:56:21 +0800
Subject: [PATCH] workspace: add upload/open/delete tools + drag-drop
---
wwwroot/api/workspace_delete.dspy | 49 ++++++++++++++++++++++
wwwroot/api/workspace_files.dspy | 65 +++++++++++++++++++++++++----
wwwroot/api/workspace_open.dspy | 67 ++++++++++++++++++++++++++++++
wwwroot/api/workspace_popup.dspy | 53 ++++++++++++++++++++++--
wwwroot/api/workspace_upload.dspy | 69 +++++++++++++++++++++++++++++++
5 files changed, 291 insertions(+), 12 deletions(-)
create mode 100644 wwwroot/api/workspace_delete.dspy
create mode 100644 wwwroot/api/workspace_open.dspy
create mode 100644 wwwroot/api/workspace_upload.dspy
diff --git a/wwwroot/api/workspace_delete.dspy b/wwwroot/api/workspace_delete.dspy
new file mode 100644
index 0000000..399ee92
--- /dev/null
+++ b/wwwroot/api/workspace_delete.dspy
@@ -0,0 +1,49 @@
+# workspace_delete.dspy - 删除工作空间文件
+
+import os
+
+file_id = (params_kw or {}).get('id', '').strip()
+
+uid = await get_user()
+if not uid:
+ uid = 'user-01'
+
+dbname = get_module_dbname('pipeline-sdlc')
+workspace_base = '/d/pipeline/workspaces'
+
+async with DBPools().sqlorContext(dbname) as sor:
+ recs = await sor.sqlExe(
+ "SELECT current_project_id FROM pipeline_agent_settings WHERE user_id=${u}$", {"u": uid})
+ pid = getattr(recs[0], 'current_project_id', '') if recs else ''
+ ws_dir = ''
+ if pid:
+ proj = await sor.sqlExe("SELECT name, org_id, workspace_dir FROM sd_projects WHERE id=${p}$", {"p": pid})
+ if proj:
+ ws = getattr(proj[0], 'workspace_dir', '') or ''
+ if ws.startswith('/'):
+ ws_dir = ws
+ else:
+ pname = getattr(proj[0], 'name', '')
+ org_id = getattr(proj[0], 'org_id', '0') or '0'
+ ws_dir = workspace_base + '/' + org_id + '/' + pname
+
+if not file_id or file_id == '__root__':
+ return {"status": "error", "message": "不能删除根目录"}
+
+full_path = ws_dir + '/' + file_id if ws_dir else file_id
+
+# 防止路径穿越:确保最终路径在 ws_dir 内
+real_ws = os.path.realpath(ws_dir)
+real_full = os.path.realpath(full_path)
+if not real_full.startswith(real_ws + os.sep):
+ return {"status": "error", "message": "非法路径"}
+
+if not os.path.isfile(full_path):
+ return {"status": "error", "message": "文件不存在: " + file_id}
+
+try:
+ os.remove(full_path)
+except Exception as e:
+ return {"status": "error", "message": "删除失败: " + str(e)}
+
+return {"status": "ok", "message": "已删除: " + os.path.basename(full_path)}
diff --git a/wwwroot/api/workspace_files.dspy b/wwwroot/api/workspace_files.dspy
index 2bb4fa9..a9c33cf 100644
--- a/wwwroot/api/workspace_files.dspy
+++ b/wwwroot/api/workspace_files.dspy
@@ -1,6 +1,7 @@
-# workspace_files.dspy - 返回目录文件列表(无 id 或 id=__root__ 时返回根目录文件)
+# workspace_files.dspy - 目录文件列表(拖拽上传区 + 可选中文件行)
import os
+import json as _json
folder_id = (params_kw or {}).get('id', '').strip()
@@ -27,7 +28,6 @@ async with DBPools().sqlorContext(dbname) as sor:
org_id = getattr(proj[0], 'org_id', '0') or '0'
ws_dir = workspace_base + '/' + org_id + '/' + pname
-# 无 id 或 __root__ 都指向工作空间根目录
if not folder_id or folder_id == '__root__':
full_dir = ws_dir
else:
@@ -36,6 +36,35 @@ else:
if not os.path.isdir(full_dir):
return {"widgettype": "Text", "options": {"text": "目录不可用: " + folder_id, "cfontsize": 0.9}}
+upload_url = entire_url("/pipeline-sdlc/api/workspace_upload.dspy")
+
+# 拖拽上传脚本:读取文件 → base64 → POST → 刷新浏览器
+upload_script = (
+ "var fs=event.params.files;"
+ "if(!fs||!fs.length)return;"
+ "for(var i=0;i 200000:
+ content = content[:200000] + "\n\n...(内容过长,已截断)"
+
+return {
+ "widgettype": "PopupWindow",
+ "options": {"title": name, "width": "80%", "height": "80%", "auto_open": True},
+ "subwidgets": [{
+ "widgettype": "VScrollPanel",
+ "options": {"css": "filler", "padding": "12px"},
+ "subwidgets": [
+ {"widgettype": "Text", "options": {"text": content, "cfontsize": 0.85, "wrap": True}}
+ ]
+ }]
+}
diff --git a/wwwroot/api/workspace_popup.dspy b/wwwroot/api/workspace_popup.dspy
index 97c1e53..252884e 100644
--- a/wwwroot/api/workspace_popup.dspy
+++ b/wwwroot/api/workspace_popup.dspy
@@ -1,4 +1,6 @@
-# workspace_popup.dspy - 返回 PopupWindow,内含 ResourceBrowser 控件
+# workspace_popup.dspy - 返回 PopupWindow,内含 ResourceBrowser(含打开/删除 tools)
+
+import json as _json
uid = await get_user()
if not uid:
@@ -28,6 +30,29 @@ async with DBPools().sqlorContext(dbname) as sor:
}]
}
else:
+ open_url = entire_url("/pipeline-sdlc/api/workspace_open.dspy")
+ delete_url = entire_url("/pipeline-sdlc/api/workspace_delete.dspy")
+
+ # 打开:预览选中文件
+ open_script = (
+ "if(!bricks.app._ws_sel)return;"
+ "var r=await fetch(" + _json.dumps(open_url) + "+'?id='+encodeURIComponent(bricks.app._ws_sel.id));"
+ "var d=await r.json();"
+ "bricks.widgetBuild(d,bricks.app);"
+ )
+
+ # 删除:删除选中文件并刷新
+ delete_script = (
+ "if(!bricks.app._ws_sel)return;"
+ "var resp=await fetch(" + _json.dumps(delete_url) + ",{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:bricks.app._ws_sel.id})});"
+ "var rj=await resp.json();"
+ "if(rj.status=='ok'){"
+ "bricks.app._ws_sel=null;bricks.app._ws_sel_el=null;"
+ "var rb=bricks.getWidgetById('ws_rb',bricks.app);"
+ "if(rb){var cid=rb.current_id;rb.current_id=undefined;await rb.render_browser(cid);}"
+ "}else{var m=new bricks.Message({title:'删除失败',message:rj.message||'未知错误'});m.open();}"
+ )
+
resp = {
"widgettype": "PopupWindow",
"options": {
@@ -38,6 +63,7 @@ async with DBPools().sqlorContext(dbname) as sor:
},
"subwidgets": [{
"widgettype": "ResourceBrowser",
+ "id": "ws_rb",
"options": {
"tree_options": {
"widgettype": "Tree",
@@ -52,11 +78,32 @@ async with DBPools().sqlorContext(dbname) as sor:
"widgettype": "urlwidget",
"options": {
"url": entire_url("/pipeline-sdlc/api/workspace_files.dspy")
- }
+ },
+ "tools": [
+ {
+ "widgettype": "Button",
+ "options": {"label": "打开", "css": "small"},
+ "binds": [{"wid": "self", "event": "click", "actiontype": "script", "target": "self", "script": open_script}]
+ },
+ {
+ "widgettype": "Button",
+ "options": {"label": "删除", "css": "small"},
+ "binds": [{
+ "wid": "self", "event": "click", "actiontype": "script", "target": "self",
+ "conform": {
+ "title": "删除确认",
+ "message": "确认删除选中的文件?",
+ "conform": {"label": "删除"},
+ "discard": {"label": "取消"}
+ },
+ "script": delete_script
+ }]
+ }
+ ]
},
"tree_width": "280px"
}
}]
}
- return json.dumps(resp, ensure_ascii=False)
+ return _json.dumps(resp, ensure_ascii=False)
diff --git a/wwwroot/api/workspace_upload.dspy b/wwwroot/api/workspace_upload.dspy
new file mode 100644
index 0000000..74d0e59
--- /dev/null
+++ b/wwwroot/api/workspace_upload.dspy
@@ -0,0 +1,69 @@
+# workspace_upload.dspy - 上传文件到工作空间目录(base64 JSON body)
+
+import os
+import base64
+
+folder_id = (params_kw or {}).get('id', '').strip()
+filename = (params_kw or {}).get('filename', '').strip()
+filedata = (params_kw or {}).get('filedata', '').strip()
+
+uid = await get_user()
+if not uid:
+ uid = 'user-01'
+
+dbname = get_module_dbname('pipeline-sdlc')
+workspace_base = '/d/pipeline/workspaces'
+
+async with DBPools().sqlorContext(dbname) as sor:
+ recs = await sor.sqlExe(
+ "SELECT current_project_id FROM pipeline_agent_settings WHERE user_id=${u}$", {"u": uid})
+ pid = getattr(recs[0], 'current_project_id', '') if recs else ''
+ ws_dir = ''
+ if pid:
+ proj = await sor.sqlExe("SELECT name, org_id, workspace_dir FROM sd_projects WHERE id=${p}$", {"p": pid})
+ if proj:
+ ws = getattr(proj[0], 'workspace_dir', '') or ''
+ if ws.startswith('/'):
+ ws_dir = ws
+ else:
+ pname = getattr(proj[0], 'name', '')
+ org_id = getattr(proj[0], 'org_id', '0') or '0'
+ ws_dir = workspace_base + '/' + org_id + '/' + pname
+
+if not ws_dir or not os.path.isdir(ws_dir):
+ return {"status": "error", "message": "工作空间目录不可用"}
+
+if not folder_id or folder_id == '__root__':
+ target_dir = ws_dir
+else:
+ target_dir = ws_dir + '/' + folder_id
+
+if not os.path.isdir(target_dir):
+ return {"status": "error", "message": "目标目录不存在: " + folder_id}
+
+# 防止路径穿越
+filename = os.path.basename(filename)
+if not filename:
+ return {"status": "error", "message": "文件名无效"}
+
+if not filedata:
+ return {"status": "error", "message": "文件内容为空"}
+
+# 解码 base64
+try:
+ if filedata.startswith('data:'):
+ _, b64 = filedata.split(',', 1)
+ else:
+ b64 = filedata
+ data = base64.b64decode(b64)
+except Exception as e:
+ return {"status": "error", "message": "文件解码失败: " + str(e)}
+
+target_path = os.path.join(target_dir, filename)
+try:
+ with open(target_path, 'wb') as f:
+ f.write(data)
+except Exception as e:
+ return {"status": "error", "message": "写入失败: " + str(e)}
+
+return {"status": "ok", "message": "上传成功: " + filename}