优化: 工程实体化(description/stack/status)+ 概览工程列表
- project_modules 加 description/status 列(V40 迁移,stack V34 已有)+ models/repo/INSERT/UPDATE/SELECT 映射 - FileExplorer 工程 CRUD 弹窗加 description(textarea)/stack(input)/status(select)输入,工程有完整身份 - ProjectDetail 概览工程列表升级:name+status badge+path + description 段(2行省略)+ stack tag - 老工程兼容(None=active),update 部分更新语义,status 归一(active/archived)
This commit is contained in:
+346
-15
@@ -117,13 +117,67 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 工程列表(多工程只读展示;CRUD 在文件 Tab) -->
|
||||
<div class="info-item" v-if="modules.length">
|
||||
<!-- 工程管理(概览内 CRUD:新增/编辑/删除/扫描;复用 moduleApi + ConfirmDialog + arco Message。
|
||||
切到文件 Tab 时 FileExplorer 因 v-if 重挂载自动重拉,无需手动同步) -->
|
||||
<div class="info-item info-block">
|
||||
<span class="label">{{ $t('projectDetail.modulesLabel', { n: modules.length }) }}</span>
|
||||
<div class="module-list-overview">
|
||||
<div v-for="m in modules" :key="m.id" class="module-row-overview">
|
||||
<span class="module-name-overview">{{ m.name }}</span>
|
||||
<span class="module-path-overview" :title="m.path">{{ m.path }}</span>
|
||||
<div class="module-manager-card">
|
||||
<!-- 标题行操作入口 -->
|
||||
<div class="module-manager-header">
|
||||
<button class="btn btn-primary btn-sm" type="button" @click="openAddModule">
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
|
||||
{{ $t('fileExplorer.addModule') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="currentProject?.path"
|
||||
class="btn btn-ghost btn-sm"
|
||||
type="button"
|
||||
:disabled="scanning"
|
||||
@click="onScanSubmodules"
|
||||
>
|
||||
<span v-if="scanning" class="spinner"></span>
|
||||
{{ $t('fileExplorer.scanSubmodules') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 空态引导(无工程不隐藏,显卡片 + CTA) -->
|
||||
<div v-if="modules.length === 0" class="module-empty">
|
||||
<div class="module-empty-icon">📦</div>
|
||||
<p class="module-empty-hint">{{ $t('projectDetail.modulesEmptyHint') }}</p>
|
||||
<div class="module-empty-actions">
|
||||
<button class="btn btn-primary btn-sm" type="button" @click="openAddModule">{{ $t('fileExplorer.addModule') }}</button>
|
||||
<button
|
||||
v-if="currentProject?.path"
|
||||
class="btn btn-ghost btn-sm"
|
||||
type="button"
|
||||
:disabled="scanning"
|
||||
@click="onScanSubmodules"
|
||||
>{{ $t('fileExplorer.scanSubmodules') }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工程列表(hover 显行内操作) -->
|
||||
<div v-else class="module-list-overview">
|
||||
<div v-for="m in modules" :key="m.id" class="module-row-overview">
|
||||
<div class="module-row-head">
|
||||
<span class="module-name-overview">{{ m.name }}</span>
|
||||
<span v-if="moduleStatusOf(m) === 'archived'" class="module-status-badge module-status-archived">{{ $t('projectDetail.moduleStatusArchived') }}</span>
|
||||
<span v-else class="module-status-badge module-status-active">{{ $t('projectDetail.moduleStatusActive') }}</span>
|
||||
<span class="module-path-overview" :title="m.path">{{ m.path }}</span>
|
||||
<span class="module-row-actions">
|
||||
<button class="ai-btn-icon" :title="$t('fileExplorer.editModule')" @click="openEditModule(m)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
||||
</button>
|
||||
<button class="ai-btn-icon module-row-action-danger" :title="$t('fileExplorer.deleteCurrentModule')" @click="confirmRemoveModule(m)">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="m.description" class="module-desc-overview">{{ m.description }}</p>
|
||||
<div v-if="parseStack(m.stack).length" class="module-stack-overview">
|
||||
<span class="tech-tag" v-for="tag in parseStack(m.stack)" :key="tag">{{ tag }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -289,7 +343,48 @@
|
||||
/>
|
||||
|
||||
<!-- 确认弹层(删除/重定位确认,替代原生 window.confirm/alert) -->
|
||||
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" />
|
||||
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" :danger-label="confirmState.dangerLabel" @result="answerConfirm" />
|
||||
|
||||
<!-- 新增/编辑工程弹窗(概览内 CRUD,对标 FileExplorer 工程 modal) -->
|
||||
<div v-if="showModuleModal" class="modal-overlay" @click.self="showModuleModal = false">
|
||||
<div class="modal-box">
|
||||
<h3 class="modal-title">{{ editingModule ? $t('fileExplorer.editModule') : $t('fileExplorer.addModule') }}</h3>
|
||||
<div class="modal-field">
|
||||
<label>{{ $t('fileExplorer.moduleName') }}</label>
|
||||
<input v-model="moduleFormName" :placeholder="$t('fileExplorer.moduleNamePlaceholder')" />
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>{{ $t('fileExplorer.modulePath') }}</label>
|
||||
<input v-model="moduleFormPath" :placeholder="$t('fileExplorer.modulePathPlaceholder')" />
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>{{ $t('fileExplorer.moduleGitUrl') }} <span class="opt-label">{{ $t('fileExplorer.optSuffix') }}</span></label>
|
||||
<input v-model="moduleFormGitUrl" :placeholder="$t('fileExplorer.moduleGitUrlPlaceholder')" />
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>{{ $t('fileExplorer.moduleDescription') }} <span class="opt-label">{{ $t('fileExplorer.optSuffix') }}</span></label>
|
||||
<textarea v-model="moduleFormDescription" :placeholder="$t('fileExplorer.moduleDescriptionPlaceholder')" rows="2"></textarea>
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>{{ $t('fileExplorer.moduleStack') }} <span class="opt-label">{{ $t('fileExplorer.optSuffix') }}</span></label>
|
||||
<input v-model="moduleFormStack" :placeholder="$t('fileExplorer.moduleStackPlaceholder')" />
|
||||
</div>
|
||||
<div class="modal-field">
|
||||
<label>{{ $t('fileExplorer.moduleStatus') }}</label>
|
||||
<select v-model="moduleFormStatus">
|
||||
<option value="active">{{ $t('fileExplorer.moduleStatusActive') }}</option>
|
||||
<option value="archived">{{ $t('fileExplorer.moduleStatusArchived') }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="btn btn-ghost" @click="showModuleModal = false">{{ $t('common.cancel') }}</button>
|
||||
<button class="btn btn-primary" :disabled="savingModule || !moduleFormName.trim() || !moduleFormPath.trim()" @click="onSaveModule">
|
||||
<span v-if="savingModule" class="spinner"></span>
|
||||
{{ savingModule ? $t('common.loading') : (editingModule ? $t('common.save') : $t('fileExplorer.addModule')) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -431,9 +526,9 @@ async function loadProjectTasks() {
|
||||
}
|
||||
// taskStatusLabel / taskStatusClass 由 ../constants/project 提供
|
||||
|
||||
// ── 工程列表(只读展示)──
|
||||
// 多工程(monorepo/多模块)在 FileExplorer 文件 Tab 内 CRUD,概览仅按 project_id 拉取展示。
|
||||
// 0 工程 v-if 隐藏,单工程也显(与多工程同列表)。
|
||||
// ── 工程列表(概览内 CRUD)──
|
||||
// 多工程(monorepo/多模块)在概览内增删改 + 扫描;复用 moduleApi + ConfirmDialog(删除确认)
|
||||
// + arco Message(反馈)。切到文件 Tab 时 FileExplorer 因 v-if 重挂载自动重拉,无需手动同步。
|
||||
const modules = ref<ProjectModuleRecord[]>([])
|
||||
async function loadModules() {
|
||||
try {
|
||||
@@ -443,6 +538,112 @@ async function loadModules() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 工程状态归一:老工程 status 为 null 视作 active(默认活跃)。
|
||||
* 仅返回 active / archived 两值,供模板按状态显 badge。
|
||||
*/
|
||||
function moduleStatusOf(m: ProjectModuleRecord): 'active' | 'archived' {
|
||||
return m.status === 'archived' ? 'archived' : 'active'
|
||||
}
|
||||
|
||||
// 工程 CRUD 表单状态(对标 FileExplorer 工程 modal)
|
||||
const showModuleModal = ref(false)
|
||||
const editingModule = ref<ProjectModuleRecord | null>(null)
|
||||
const savingModule = ref(false)
|
||||
const scanning = ref(false)
|
||||
const moduleFormName = ref('')
|
||||
const moduleFormPath = ref('')
|
||||
const moduleFormGitUrl = ref('')
|
||||
const moduleFormDescription = ref('')
|
||||
const moduleFormStack = ref('')
|
||||
const moduleFormStatus = ref<'active' | 'archived'>('active')
|
||||
|
||||
/** 打开新增工程弹窗(清空表单)。 */
|
||||
function openAddModule() {
|
||||
editingModule.value = null
|
||||
moduleFormName.value = ''
|
||||
moduleFormPath.value = ''
|
||||
moduleFormGitUrl.value = ''
|
||||
moduleFormDescription.value = ''
|
||||
moduleFormStack.value = ''
|
||||
moduleFormStatus.value = 'active'
|
||||
showModuleModal.value = true
|
||||
}
|
||||
|
||||
/** 打开编辑工程弹窗(回填当前工程字段;老工程 status=null 视作 active)。 */
|
||||
function openEditModule(m: ProjectModuleRecord) {
|
||||
editingModule.value = m
|
||||
moduleFormName.value = m.name
|
||||
moduleFormPath.value = m.path
|
||||
moduleFormGitUrl.value = m.git_url ?? ''
|
||||
moduleFormDescription.value = m.description ?? ''
|
||||
moduleFormStack.value = m.stack ?? ''
|
||||
moduleFormStatus.value = m.status === 'archived' ? 'archived' : 'active'
|
||||
showModuleModal.value = true
|
||||
}
|
||||
|
||||
/** 保存工程(新增/编辑统一入口;成功 toast + 关弹窗 + 重拉列表)。 */
|
||||
async function onSaveModule() {
|
||||
if (savingModule.value) return
|
||||
if (!moduleFormName.value.trim() || !moduleFormPath.value.trim()) return
|
||||
savingModule.value = true
|
||||
try {
|
||||
const payload = {
|
||||
name: moduleFormName.value.trim(),
|
||||
path: moduleFormPath.value.trim(),
|
||||
gitUrl: moduleFormGitUrl.value.trim() || null,
|
||||
description: moduleFormDescription.value.trim() || null,
|
||||
stack: moduleFormStack.value.trim() || null,
|
||||
status: moduleFormStatus.value,
|
||||
}
|
||||
if (editingModule.value) {
|
||||
await moduleApi.updateProjectModule({ id: editingModule.value.id, ...payload })
|
||||
Message.success(t('projectDetail.moduleUpdateSuccess'))
|
||||
} else {
|
||||
await moduleApi.addProjectModule({ projectId: projectId.value, ...payload })
|
||||
Message.success(t('projectDetail.moduleAddSuccess'))
|
||||
}
|
||||
showModuleModal.value = false
|
||||
await loadModules()
|
||||
} catch (e) {
|
||||
Message.error(t('common.unknownError'))
|
||||
console.error('[ProjectDetail] 保存工程失败:', e)
|
||||
} finally {
|
||||
savingModule.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 删除工程(二次确认后执行;复用 useConfirm 的 Promise 化 confirmDialog)。 */
|
||||
async function confirmRemoveModule(m: ProjectModuleRecord) {
|
||||
const ok = await confirmDialog(t('fileExplorer.removeConfirm', { name: m.name }), t('common.delete'))
|
||||
if (!ok) return
|
||||
try {
|
||||
await moduleApi.removeProjectModule(m.id)
|
||||
Message.info(t('projectDetail.moduleRemoved'))
|
||||
await loadModules()
|
||||
} catch (e) {
|
||||
Message.error(t('common.unknownError'))
|
||||
console.error('[ProjectDetail] 删除工程失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
/** 扫描项目绑定目录下的子仓库,自动创建工程记录(幂等)。 */
|
||||
async function onScanSubmodules() {
|
||||
if (scanning.value) return
|
||||
scanning.value = true
|
||||
try {
|
||||
const n = await moduleApi.scanProjectModules(projectId.value)
|
||||
await loadModules()
|
||||
if (n > 0) Message.success(t('projectDetail.scanFound', { n }))
|
||||
else Message.info(t('projectDetail.scanNone'))
|
||||
} catch (e) {
|
||||
Message.error(t('common.unknownError'))
|
||||
console.error('[ProjectDetail] 扫描子仓库失败:', e)
|
||||
} finally {
|
||||
scanning.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── 工作流 ──
|
||||
// demoDag + runDemoWorkflow 已下线(R-PD-2):"script" 节点不再注册到 NodeRegistry,
|
||||
// 前端构造含 script 节点的 DagDef 会在后端 build_dag 失败报错。
|
||||
@@ -677,7 +878,88 @@ onUnmounted(() => {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ===== 工程列表(概览只读展示;对齐 .info-item label固定宽 + value) ===== */
|
||||
/* ===== 工程管理卡片(概览内 CRUD) ===== */
|
||||
.module-manager-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
background: var(--df-bg-card);
|
||||
border: 0.5px solid var(--df-border);
|
||||
border-radius: var(--df-radius-lg);
|
||||
}
|
||||
.module-manager-header {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
/* 工程行:hover 显操作 + 背景反馈(原 .module-row-overview padding/border-bottom 保留) */
|
||||
.module-row-overview {
|
||||
position: relative;
|
||||
border-radius: var(--df-radius-sm);
|
||||
transition: background 0.15s var(--df-ease);
|
||||
}
|
||||
.module-row-overview:hover {
|
||||
background: var(--df-bg-card-hover);
|
||||
}
|
||||
.module-row-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s var(--df-ease);
|
||||
}
|
||||
.module-row-overview:hover .module-row-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
/* 行内图标钮缩到 22×22(紧凑协调,覆盖全局 .ai-btn-icon 26×26) */
|
||||
.module-row-actions .ai-btn-icon {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
.module-row-action-danger:hover {
|
||||
color: var(--df-danger);
|
||||
}
|
||||
/* 无 hover 设备(触屏):操作常显,可达性兜底 */
|
||||
@media (hover: none) {
|
||||
.module-row-actions { opacity: 1; }
|
||||
}
|
||||
/* 空态引导 */
|
||||
.module-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 20px 12px;
|
||||
text-align: center;
|
||||
}
|
||||
.module-empty-icon {
|
||||
font-size: 28px;
|
||||
opacity: 0.6;
|
||||
line-height: 1;
|
||||
}
|
||||
.module-empty-hint {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--df-text-dim);
|
||||
line-height: 1.6;
|
||||
max-width: 320px;
|
||||
}
|
||||
.module-empty-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
/* modal 内「(可选)」小字标签(对标 FileExplorer) */
|
||||
.opt-label {
|
||||
font-size: 11px;
|
||||
color: var(--df-text-dim);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ===== 工程列表 ===== */
|
||||
.module-list-overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -686,10 +968,22 @@ onUnmounted(() => {
|
||||
min-width: 0;
|
||||
}
|
||||
.module-row-overview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--df-border, rgba(0, 0, 0, 0.06));
|
||||
min-width: 0;
|
||||
}
|
||||
.module-row-overview:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.module-row-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.module-name-overview {
|
||||
font-size: 13px;
|
||||
@@ -705,6 +999,43 @@ onUnmounted(() => {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.module-desc-overview {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--df-text-secondary);
|
||||
line-height: 1.5;
|
||||
/* 限制两行,过长省略;描述通常一句话,两行覆盖 99% 场景 */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
.module-stack-overview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
.module-stack-overview .tech-tag {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
}
|
||||
.module-status-badge {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.module-status-active {
|
||||
color: var(--df-success, #16a34a);
|
||||
background: rgba(22, 163, 74, 0.1);
|
||||
}
|
||||
.module-status-archived {
|
||||
color: var(--df-text-dim, #888);
|
||||
background: rgba(136, 136, 136, 0.12);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
@@ -713,7 +1044,7 @@ onUnmounted(() => {
|
||||
border-radius: var(--df-radius-lg);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: rgba(108,99,255,0.1);
|
||||
background: var(--df-accent-bg);
|
||||
color: var(--df-accent);
|
||||
}
|
||||
|
||||
@@ -843,7 +1174,7 @@ onUnmounted(() => {
|
||||
font-weight: 500;
|
||||
}
|
||||
.stage-planning { background: rgba(100,181,246,0.15); color: #64b5f6; }
|
||||
.stage-in_progress { background: rgba(108,99,255,0.15); color: #6c63ff; }
|
||||
.stage-in_progress { background: var(--df-accent-soft); color: var(--df-accent); }
|
||||
.stage-testing { background: rgba(255,193,7,0.15); color: #ffc107; }
|
||||
.stage-releasing { background: rgba(156,39,176,0.15); color: #ce93d8; }
|
||||
.stage-completed { background: rgba(100,200,100,0.15); color: #64c864; }
|
||||
@@ -890,7 +1221,7 @@ onUnmounted(() => {
|
||||
.task-title { font-size: 14px; font-weight: 500; color: var(--df-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.task-status { font-size: 11px; padding: 2px 8px; border-radius: var(--df-radius-xs); }
|
||||
.status-done { background: rgba(100,255,218,0.15); color: var(--df-success); }
|
||||
.status-progress { background: rgba(108,99,255,0.15); color: var(--df-accent); }
|
||||
.status-progress { background: var(--df-accent-soft); color: var(--df-accent); }
|
||||
.status-review { background: rgba(255,217,61,0.15); color: var(--df-warning); }
|
||||
.status-todo { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||
|
||||
@@ -905,7 +1236,7 @@ onUnmounted(() => {
|
||||
font-size: 12px;
|
||||
font-family: 'SF Mono', 'Fira Code', monospace;
|
||||
color: var(--df-text-secondary);
|
||||
background: rgba(108,99,255,0.1);
|
||||
background: var(--df-accent-bg);
|
||||
padding: 1px 6px;
|
||||
border-radius: var(--df-radius-sm);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user