feat: support setting default workspace/project from frontend

- Add set_default_host_workspace() and /api/host/workspaces/set-default
- Add default_workspace_id persistence in UserManager and /api/projects/set-default
- Add 'Set as default' button in HostWorkspaceManageDialog
- Sync default badge in LeftPanel workspace list
- Update all workspace/project APIs to return real default_workspace_id
This commit is contained in:
JOJO 2026-06-19 21:48:24 +08:00
parent 2c6d208be7
commit a212a0076c
7 changed files with 226 additions and 9 deletions

View File

@ -338,8 +338,43 @@ def delete_host_workspace(
}
def set_default_host_workspace(
workspace_id: str,
config_path: Optional[Union[str, Path]] = None,
) -> Dict[str, Any]:
"""设置指定工作区为默认工作区。"""
ws_id = str(workspace_id or "").strip()
if not ws_id:
raise ValueError("缺少 workspace_id")
cfg_path = _resolve_config_path(config_path)
with _HOST_WORKSPACE_LOCK:
payload = _ensure_config_file(cfg_path, strict=True)
raw_workspaces = payload.get("workspaces")
if not isinstance(raw_workspaces, list):
raw_workspaces = []
seen_ids = set()
for idx, item in enumerate(raw_workspaces):
normalized = _normalize_entry(item, idx)
if normalized:
seen_ids.add(normalized.get("workspace_id"))
if ws_id not in seen_ids:
raise ValueError("工作区不存在")
payload["default_workspace_id"] = ws_id
_atomic_write_json(cfg_path, payload)
return {
"default_workspace_id": ws_id,
"catalog": load_host_workspace_catalog(config_path=cfg_path),
}
__all__ = [
"load_host_workspace_catalog",
"resolve_host_workspace",
"create_host_workspace",
"set_default_host_workspace",
]

View File

@ -36,6 +36,7 @@ class UserRecord:
invite_code: Optional[str] = None
role: str = "user"
tutorial_completed: bool = False
default_workspace_id: str = "default"
@dataclass
@ -390,6 +391,14 @@ class UserManager:
workspace.workspace_id = workspace_id
return workspace
def _get_user_default_workspace_id(self, username: str) -> str:
"""返回用户设置的默认项目 ID如未设置或无效则返回 default。"""
record = self._users.get(self._normalize_username(username))
default_id = str(getattr(record, "default_workspace_id", None) or "default").strip()
if not default_id:
return "default"
return default_id
def list_user_workspaces(self, username: str) -> Dict[str, Dict]:
username = self._normalize_username(username)
self._migrate_legacy_docker_workspace_layout(username)
@ -404,15 +413,21 @@ class UserManager:
if web_root.exists():
self._collect_workspaces(result, web_root)
default_workspace_id = self._get_user_default_workspace_id(username)
# 确保 default 始终存在
if "default" not in result:
result["default"] = {
"workspace_id": "default",
"label": "默认项目",
"path": "",
"is_default": True,
"is_default": default_workspace_id == "default",
"has_conversations": False,
}
# 同步 is_default 标记
for ws_id in result:
result[ws_id]["is_default"] = ws_id == default_workspace_id
return result
def _collect_workspaces(self, result: Dict[str, Dict], projects_root: Path) -> None:
@ -519,6 +534,20 @@ class UserManager:
raise ValueError("项目不存在")
shutil.rmtree(target)
def set_default_user_workspace(self, username: str, workspace_id: str) -> str:
"""设置指定项目为用户默认项目,返回生效的默认项目 ID。"""
username = self._normalize_username(username)
ws_id = self.normalize_workspace_id(workspace_id)
known = self.list_user_workspaces(username)
if ws_id not in known:
raise ValueError("项目不存在")
record = self._users.get(username)
if not record:
raise ValueError("用户不存在")
record.default_workspace_id = ws_id
self._save_users()
return ws_id
@staticmethod
def _load_workspace_label(workspace_root: Path, fallback: str) -> str:
meta_path = workspace_root / "project.json"
@ -652,9 +681,12 @@ class UserManager:
invite_code=payload.get("invite_code"),
role=payload.get("role", "user"),
tutorial_completed=bool(payload.get("tutorial_completed", False)),
default_workspace_id=str(payload.get("default_workspace_id") or "default").strip() or "default",
)
if "tutorial_completed" not in payload:
migrated = True
if "default_workspace_id" not in payload:
migrated = True
self._users[username] = record
self._index_user(record)
if migrated:
@ -672,6 +704,7 @@ class UserManager:
"invite_code": record.invite_code,
"role": record.role,
"tutorial_completed": bool(record.tutorial_completed),
"default_workspace_id": str(record.default_workspace_id or "default").strip() or "default",
}
for username, record in self._users.items()
}

View File

@ -27,6 +27,7 @@ from modules.host_workspace_manager import (
load_host_workspace_catalog,
rename_host_workspace,
resolve_host_workspace,
set_default_host_workspace,
)
from utils.host_workspace_debug import write_host_workspace_debug
from .utils_common import log_conn_diag
@ -49,14 +50,15 @@ def _close_terminal_for_key(term_key: str):
def _build_docker_projects_payload(username: str, current_id: str) -> list[dict]:
running_by_workspace = _active_task_counts(username)
workspaces = user_manager.list_user_workspaces(username)
projects = []
for ws_id, item in user_manager.list_user_workspaces(username).items():
for ws_id, item in workspaces.items():
projects.append({
"workspace_id": ws_id,
"label": item.get("label") or ("默认项目" if ws_id == "default" else ws_id),
"path": "",
"is_current": ws_id == current_id,
"is_default": ws_id == "default",
"is_default": bool(item.get("is_default", ws_id == "default")),
"running_task_count": int(running_by_workspace.get(ws_id, 0)),
})
return projects
@ -1051,10 +1053,11 @@ def list_docker_projects():
# 默认项目用于向后兼容旧 Docker Web 文件/对话。
user_manager.ensure_user_workspace(username, current_id)
projects = _build_docker_projects_payload(username, current_id)
default_workspace_id = user_manager._get_user_default_workspace_id(username)
return jsonify({
"success": True,
"data": {
"default_workspace_id": "default",
"default_workspace_id": default_workspace_id,
"current_workspace_id": current_id,
"workspaces": projects,
}
@ -1100,12 +1103,13 @@ def select_docker_project():
attach_user_broadcast(terminal, username)
except Exception:
pass
default_workspace_id = user_manager._get_user_default_workspace_id(username)
return jsonify({
"success": True,
"data": {
"current_workspace_id": session["workspace_id"],
"project_path": "",
"default_workspace_id": "default",
"default_workspace_id": default_workspace_id,
"reloaded": previous_workspace_id != session["workspace_id"],
}
})
@ -1127,13 +1131,14 @@ def create_docker_project():
return jsonify({"success": False, "error": str(exc)}), 400
projects = []
current_id = session.get("workspace_id") or "default"
default_workspace_id = user_manager._get_user_default_workspace_id(username)
for ws_id, item in user_manager.list_user_workspaces(username).items():
projects.append({
"workspace_id": ws_id,
"label": item.get("label") or ws_id,
"path": "",
"is_current": ws_id == current_id,
"is_default": ws_id == "default",
"is_default": ws_id == default_workspace_id,
})
return jsonify({
"success": True,
@ -1144,7 +1149,7 @@ def create_docker_project():
"label": label,
"path": "",
},
"default_workspace_id": "default",
"default_workspace_id": default_workspace_id,
"current_workspace_id": current_id,
"workspaces": projects,
}
@ -1169,11 +1174,12 @@ def rename_docker_project():
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
current_id = session.get("workspace_id") or "default"
default_workspace_id = user_manager._get_user_default_workspace_id(username)
return jsonify({
"success": True,
"data": {
"workspace": workspace,
"default_workspace_id": "default",
"default_workspace_id": default_workspace_id,
"current_workspace_id": current_id,
"workspaces": _build_docker_projects_payload(username, current_id),
}
@ -1218,11 +1224,20 @@ def delete_docker_project():
)
except RuntimeError as exc:
return jsonify({"success": False, "error": str(exc)}), 503
# 如果删除的是当前默认项目,重置为 default
if user_manager._get_user_default_workspace_id(username) == workspace_id:
try:
user_manager.set_default_user_workspace(username, "default")
except Exception:
pass
default_workspace_id = user_manager._get_user_default_workspace_id(username)
return jsonify({
"success": True,
"data": {
"deleted_workspace_id": workspace_id,
"default_workspace_id": "default",
"default_workspace_id": default_workspace_id,
"current_workspace_id": current_id,
"workspaces": _build_docker_projects_payload(username, current_id),
}
@ -1474,6 +1489,64 @@ def delete_host_workspace_api():
})
@status_bp.route('/api/host/workspaces/set-default', methods=['POST'])
@api_login_required
def set_default_host_workspace_api():
if not _is_host_mode_request():
return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403
payload = request.get_json(silent=True) or {}
workspace_id = (payload.get("workspace_id") or "").strip()
if not workspace_id:
return jsonify({"success": False, "error": "缺少 workspace_id"}), 400
try:
result = set_default_host_workspace(workspace_id)
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
catalog = result.get("catalog") or load_host_workspace_catalog()
current_id = (session.get("host_workspace_id") or "").strip()
return jsonify({
"success": True,
"data": {
"default_workspace_id": catalog.get("default_workspace_id"),
"current_workspace_id": current_id,
"workspaces": _build_host_workspaces_payload(catalog, current_id),
}
})
@status_bp.route('/api/projects/set-default', methods=['POST'])
@api_login_required
def set_default_docker_project_api():
if not _is_docker_project_request():
return jsonify({"success": False, "error": "仅 Docker Web 模式可用"}), 403
payload = request.get_json(silent=True) or {}
workspace_id = (payload.get("workspace_id") or "").strip()
if not workspace_id:
return jsonify({"success": False, "error": "缺少项目 ID"}), 400
username = session.get("username")
try:
user_manager.set_default_user_workspace(username, workspace_id)
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
current_id = session.get("workspace_id") or "default"
return jsonify({
"success": True,
"data": {
"default_workspace_id": user_manager._get_user_default_workspace_id(username),
"current_workspace_id": current_id,
"workspaces": _build_docker_projects_payload(username, current_id),
}
})
@status_bp.route('/api/container-status')
@api_login_required
@with_terminal

View File

@ -551,6 +551,7 @@
@create="submitHostWorkspaceCreateFromManage"
@rename="renameHostWorkspace"
@delete="deleteHostWorkspace"
@set-default="setDefaultHostWorkspace"
/>
</transition>
<TutorialOverlay v-if="tutorialStore.running" />

View File

@ -1474,6 +1474,52 @@ export const uiMethods = {
}
},
async setDefaultHostWorkspace(payload = {}) {
if (!(this.versioningHostMode || this.dockerProjectMode)) {
return;
}
const workspaceId = String(payload?.workspace_id || '').trim();
if (!workspaceId) return;
if (workspaceId === this.defaultHostWorkspaceId) return;
this.hostWorkspaceManageSubmitting = true;
this.hostWorkspaceCreateError = '';
const kindLabel = this.versioningHostMode ? '工作区' : '项目';
try {
const endpoint = this.versioningHostMode
? '/api/host/workspaces/set-default'
: '/api/projects/set-default';
const resp = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ workspace_id: workspaceId })
});
const result = await resp.json().catch(() => ({}));
if (!resp.ok || !result?.success) {
throw new Error(result?.error || `设置默认${kindLabel}失败`);
}
const data = result.data || {};
this.defaultHostWorkspaceId = String(data.default_workspace_id || workspaceId);
await this.fetchHostWorkspaces();
this.uiPushToast({
title: `已设为默认${kindLabel}`,
message: (this.hostWorkspaces || []).find((w: any) => w.workspace_id === workspaceId)?.label || workspaceId,
type: 'success'
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error || `设置默认${kindLabel}失败`);
this.hostWorkspaceCreateError = message;
this.uiPushToast({
title: `设置默认${kindLabel}失败`,
message,
type: 'error'
});
} finally {
this.hostWorkspaceManageSubmitting = false;
}
},
fetchTodoList() {
return this.fileFetchTodoList();
},

View File

@ -77,12 +77,24 @@
{{ item.path || '(未配置路径)' }}
</div>
<div class="workspace-manage-badges">
<span v-if="item.workspace_id === defaultWorkspaceId" class="workspace-manage-badge default">
默认
</span>
<span v-if="Number(item.running_task_count || 0) > 0" class="workspace-manage-badge running">
运行中
</span>
</div>
</div>
<div class="workspace-manage-actions">
<button
type="button"
class="host-workspace-dialog__btn ghost"
:class="{ active: item.workspace_id === defaultWorkspaceId }"
:disabled="busy || item.workspace_id === defaultWorkspaceId"
@click="submitSetDefault(item)"
>
{{ item.workspace_id === defaultWorkspaceId ? '已默认' : '设为默认' }}
</button>
<button
type="button"
class="host-workspace-dialog__btn ghost"
@ -140,6 +152,7 @@ const emits = defineEmits<{
(event: 'create', payload: { path: string; label: string }): void;
(event: 'rename', payload: { workspace_id: string; label: string }): void;
(event: 'delete', item: WorkspaceItem): void;
(event: 'setDefault', payload: { workspace_id: string }): void;
}>();
const workspaceKind = props.workspaceKind || 'workspace';
@ -190,6 +203,11 @@ const submitRename = (item: WorkspaceItem) => {
label: String(renameDrafts[item.workspace_id] ?? '').trim()
});
};
const submitSetDefault = (item: WorkspaceItem) => {
if (item.workspace_id === props.defaultWorkspaceId) return;
emits('setDefault', { workspace_id: item.workspace_id });
};
</script>
<style scoped>
@ -368,6 +386,11 @@ const submitRename = (item: WorkspaceItem) => {
color: var(--claude-accent);
}
.workspace-manage-badge.default {
color: var(--state-success);
border-color: color-mix(in srgb, var(--state-success) 45%, var(--theme-control-border-strong));
}
.workspace-manage-empty {
font-size: 13px;
color: var(--claude-text-secondary);

View File

@ -222,6 +222,12 @@
<div class="host-workspace-card-head">
<span class="host-workspace-label">{{ item.label }}</span>
<span class="host-workspace-badges">
<span
v-if="item.workspace_id === hostWorkspaceDefaultId"
class="host-workspace-badge default"
>
默认
</span>
<span
v-if="Number(item.running_task_count || 0) > 0"
class="host-workspace-badge running"