DevLoopProjects.vue 12.3 KB
<template>
  <div class="devloop-projects">
    <header class="hdr">
      <h1>DevLoop · 项目</h1>
      <div class="hdr-actions">
        <label class="archived-toggle">
          <input type="checkbox" v-model="includeArchived" @change="fetchProjects" />
          显示已归档
        </label>
        <button class="primary" @click="openCreate">+ 新建项目</button>
      </div>
    </header>

    <div v-if="error" class="error-banner">{{ error }}</div>

    <div v-if="loading && !projects.length" class="empty">加载中…</div>
    <div v-else-if="!projects.length" class="empty">
      暂无项目。点击右上角 <b>新建项目</b> 开始一个 DevLoop 流程。
    </div>

    <table v-else class="proj-table">
      <thead>
        <tr>
          <th>名称</th>
          <th>状态</th>
          <th>Mode</th>
          <th>Company</th>
          <th>创建时间</th>
          <th>PID</th>
          <th class="actions-col">操作</th>
        </tr>
      </thead>
      <tbody>
        <tr v-for="p in projects" :key="p.pid" :class="{ archived: p.archived }">
          <td class="name-cell">
            <router-link :to="`/devloop/${p.pid}`" class="name-link">
              {{ p.name || p.pid }}
            </router-link>
            <div v-if="p.idea_summary && p.idea_summary !== p.name" class="idea">{{ p.idea_summary }}</div>
          </td>
          <td><span :class="['status-pill', `s-${p.status}`]">{{ p.status }}</span></td>
          <td><code>{{ p.mode }}</code></td>
          <td><code>{{ p.company_id }}</code></td>
          <td>{{ formatTime(p.created_at) }}</td>
          <td><code class="pid">{{ p.pid }}</code></td>
          <td class="actions-cell">
            <button @click="openRename(p)">改名</button>
            <button v-if="!p.archived" class="warn" @click="archive(p)">归档</button>
            <button v-else @click="restore(p)">恢复</button>
            <button class="danger" @click="hardDelete(p)">永删</button>
          </td>
        </tr>
      </tbody>
    </table>

    <!-- 新建项目弹窗 -->
    <div v-if="showCreate" class="modal-mask" @click.self="closeCreate">
      <div class="modal">
        <h2>新建 DevLoop 项目</h2>
        <label>
          公司
          <select v-model="newProj.company_id">
            <option value="">— 选择公司 —</option>
            <option v-for="c in companies" :key="c.id" :value="c.id">
              {{ c.name }} ({{ c.id }})
            </option>
          </select>
        </label>
        <label>
          项目名 (可选)
          <input v-model="newProj.name" placeholder="如:购物车结算重构" />
        </label>
        <label>
          需求描述 *
          <textarea v-model="newProj.requirement" rows="5"
            placeholder="一句话或一段话描述这个项目要做什么。Agent 会按此生成 PRD/SDD。" />
        </label>
        <label class="inline">
          <input type="checkbox" v-model="newProj.mock" />
          使用 mock 模式(不调真实 Claude,用 fixture 走通流程)
        </label>
        <div v-if="createError" class="err">{{ createError }}</div>
        <div class="modal-actions">
          <button @click="closeCreate" :disabled="creating">取消</button>
          <button class="primary" :disabled="creating || !canCreate" @click="submitCreate">
            {{ creating ? '创建中…' : '创建' }}
          </button>
        </div>
      </div>
    </div>

    <!-- 改名弹窗 -->
    <div v-if="renameTarget" class="modal-mask" @click.self="renameTarget = null">
      <div class="modal sm">
        <h2>改名 — {{ renameTarget.pid }}</h2>
        <label>
          名称
          <input v-model="renameValue" />
        </label>
        <div v-if="renameError" class="err">{{ renameError }}</div>
        <div class="modal-actions">
          <button @click="renameTarget = null" :disabled="renaming">取消</button>
          <button class="primary" :disabled="renaming || !renameValue" @click="submitRename">
            {{ renaming ? '保存中…' : '保存' }}
          </button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue';

const projects = ref([]);
const companies = ref([]);
const error = ref('');
const loading = ref(false);
const includeArchived = ref(false);

const showCreate = ref(false);
const creating = ref(false);
const createError = ref('');
const newProj = ref({ company_id: '', name: '', requirement: '', mock: true });
const canCreate = computed(() => newProj.value.company_id && newProj.value.requirement.trim());

const renameTarget = ref(null);
const renameValue = ref('');
const renaming = ref(false);
const renameError = ref('');

async function fetchProjects() {
  loading.value = true;
  try {
    const url = `/api/devloop/projects${includeArchived.value ? '?include_archived=true' : ''}`;
    const r = await fetch(url);
    if (!r.ok) throw new Error(`HTTP ${r.status}`);
    const j = await r.json();
    projects.value = j.projects || [];
    error.value = '';
  } catch (e) {
    error.value = '加载项目失败: ' + e.message;
  } finally {
    loading.value = false;
  }
}

async function fetchCompanies() {
  try {
    const r = await fetch('/api/company/list');
    if (!r.ok) return;
    const j = await r.json();
    companies.value = Array.isArray(j) ? j : (j.companies || []);
  } catch { /* 忽略,用户可手填 pid */ }
}

function openCreate() {
  newProj.value = { company_id: '', name: '', requirement: '', mock: true };
  createError.value = '';
  showCreate.value = true;
}
function closeCreate() {
  if (creating.value) return;
  showCreate.value = false;
}

async function submitCreate() {
  creating.value = true;
  createError.value = '';
  try {
    const r = await fetch('/api/devloop/projects', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        company_id: newProj.value.company_id,
        name: newProj.value.name || undefined,
        requirement: newProj.value.requirement.trim(),
        mock: newProj.value.mock,
      }),
    });
    const j = await r.json();
    if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
    showCreate.value = false;
    await fetchProjects();
  } catch (e) {
    createError.value = e.message;
  } finally {
    creating.value = false;
  }
}

function openRename(p) {
  renameTarget.value = p;
  renameValue.value = p.name || '';
  renameError.value = '';
}

async function submitRename() {
  if (!renameTarget.value) return;
  renaming.value = true;
  renameError.value = '';
  try {
    const r = await fetch(`/api/devloop/projects/${renameTarget.value.pid}`, {
      method: 'PATCH',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ name: renameValue.value }),
    });
    const j = await r.json();
    if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
    renameTarget.value = null;
    await fetchProjects();
  } catch (e) {
    renameError.value = e.message;
  } finally {
    renaming.value = false;
  }
}

async function archive(p) {
  if (!confirm(`归档项目 "${p.name || p.pid}"?项目数据保留,可在"显示已归档"中恢复。`)) return;
  const r = await fetch(`/api/devloop/projects/${p.pid}`, { method: 'DELETE' });
  if (!r.ok) {
    const j = await r.json().catch(() => ({}));
    return alert('归档失败: ' + (j.error || r.status));
  }
  await fetchProjects();
}

async function restore(p) {
  const r = await fetch(`/api/devloop/projects/${p.pid}/restore`, { method: 'POST' });
  if (!r.ok) {
    const j = await r.json().catch(() => ({}));
    return alert('恢复失败: ' + (j.error || r.status));
  }
  await fetchProjects();
}

async function hardDelete(p) {
  const msg = p.mode === 'sandbox'
    ? `永久删除 "${p.name || p.pid}"?\n\n会递归删除目录:\n${p.root_path}\n\n此操作不可撤销!`
    : `从 DevLoop 注册表中移除 "${p.name || p.pid}"?\n\n(linked 模式:你的目录 ${p.root_path} 会保留)`;
  if (!confirm(msg)) return;
  const r = await fetch(`/api/devloop/projects/${p.pid}?hard=true`, { method: 'DELETE' });
  if (!r.ok) {
    const j = await r.json().catch(() => ({}));
    return alert('删除失败: ' + (j.error || r.status));
  }
  await fetchProjects();
}

function formatTime(iso) {
  if (!iso) return '—';
  try {
    const d = new Date(iso);
    return `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')} ${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`;
  } catch { return iso; }
}

onMounted(() => {
  fetchProjects();
  fetchCompanies();
});
</script>

<style scoped>
.devloop-projects { max-width: 1280px; margin: 0 auto; padding: 16px 24px; }
.hdr { display:flex; align-items:center; justify-content:space-between; padding-bottom:12px; border-bottom:1px solid #eee; }
.hdr h1 { font-size:20px; margin:0; }
.hdr-actions { display:flex; align-items:center; gap:14px; }
.archived-toggle { font-size:13px; color:#666; display:flex; align-items:center; gap:6px; cursor:pointer; }
.error-banner { background:#fee; border:1px solid #fcc; color:#c00; padding:8px 12px; border-radius:6px; margin:12px 0; }

.empty { text-align:center; color:#888; padding:48px 0; font-size:14px; }

.proj-table { width:100%; border-collapse:collapse; margin-top:16px; background:#fff; }
.proj-table th, .proj-table td { padding:10px 12px; border-bottom:1px solid #eee; text-align:left; font-size:13px; vertical-align:middle; }
.proj-table th { background:#fafafa; color:#555; font-weight:600; font-size:12px; text-transform:uppercase; letter-spacing:0.04em; }
.proj-table tr.archived { opacity:0.55; background:#fafafa; }
.proj-table tr:hover:not(.archived) { background:#fafbff; }

.name-cell { min-width:240px; }
.name-link { color:#0a6; text-decoration:none; font-weight:500; }
.name-link:hover { text-decoration:underline; }
.idea { color:#888; font-size:12px; margin-top:2px; max-width:340px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }

.pid, code { font-family: ui-monospace, SFMono-Regular, monospace; font-size:12px; background:#f4f4f6; padding:1px 6px; border-radius:3px; }

.status-pill { display:inline-block; padding:2px 8px; border-radius:10px; font-size:11px; font-weight:600; background:#eee; color:#444; }
.s-draft   { background:#eef; color:#446; }
.s-running { background:#fff3cd; color:#7a5; }
.s-shipped { background:#d4f4dd; color:#0a6; }
.s-shipped-final { background:#0a6; color:#fff; }
.s-abandoned { background:#fbe; color:#922; }
.s-l2_blocked  { background:#fdd; color:#a44; }

.actions-col { width:240px; }
.actions-cell { display:flex; gap:6px; }
.actions-cell button { padding:4px 10px; font-size:12px; border:1px solid #ddd; background:#fff; border-radius:4px; cursor:pointer; color:#444; }
.actions-cell button:hover { background:#f4f4f6; border-color:#bbb; }
.actions-cell button.warn { color:#a60; border-color:#fc8; }
.actions-cell button.danger { color:#c00; border-color:#f99; }

button.primary { background:#0a6; color:#fff; border:1px solid #0a6; padding:6px 14px; border-radius:5px; cursor:pointer; font-size:13px; }
button.primary:hover:not(:disabled) { background:#085; }
button.primary:disabled { opacity:0.5; cursor:not-allowed; }

/* Modal */
.modal-mask { position:fixed; inset:0; background:rgba(0,0,0,0.4); display:flex; align-items:center; justify-content:center; z-index:1000; }
.modal { background:#fff; border-radius:8px; padding:20px 24px; min-width:480px; max-width:600px; box-shadow:0 8px 32px rgba(0,0,0,0.25); }
.modal.sm { min-width:360px; }
.modal h2 { font-size:16px; margin:0 0 16px 0; }
.modal label { display:block; margin-bottom:12px; font-size:13px; color:#444; }
.modal label.inline { display:flex; align-items:center; gap:8px; }
.modal input[type="text"], .modal input:not([type]), .modal textarea, .modal select { display:block; width:100%; margin-top:4px; padding:6px 10px; border:1px solid #ccc; border-radius:4px; font-size:13px; box-sizing:border-box; }
.modal textarea { resize:vertical; font-family:inherit; }
.modal .err { color:#c00; font-size:12px; margin:8px 0; padding:6px 10px; background:#fee; border-radius:4px; }
.modal-actions { display:flex; justify-content:flex-end; gap:10px; margin-top:18px; }
.modal-actions button { padding:6px 16px; border:1px solid #ccc; background:#fff; border-radius:5px; cursor:pointer; font-size:13px; }
.modal-actions button:hover:not(:disabled) { background:#f4f4f6; }
</style>