DevLoopNewRequirement.vue 16 KB
<template>
  <div class="new-req-page">
    <header class="hdr">
      <router-link :to="`/devloop/${pid}`" class="back">← Dashboard</router-link>
      <h1>新增需求 · <code>{{ pid }}</code></h1>
    </header>

    <div class="layout">
      <!-- LEFT: chat -->
      <section class="chat-col">
        <div class="chat-hdr">
          <h2>对话草稿</h2>
          <span class="hint-mini">写多条消息、上传文档、扫描代码 → 一次性提交给 PM Agent</span>
        </div>
        <div class="chat-log" ref="chatLogEl">
          <div v-if="messages.length === 0" class="empty-mini">
            写下第一句需求描述,或左下角添加附件 / 代码目录后再继续。
          </div>
          <div v-for="(m, i) in messages" :key="i" :class="['bubble', m.role]">
            <div class="bubble-meta">{{ m.role === 'user' ? '你' : 'Assistant' }}<span v-if="m.llm_stub" class="stub-tag">LLM 占位</span></div>
            <div class="bubble-body">{{ m.content }}</div>
          </div>
          <div v-if="sending" class="bubble assistant pending">Assistant 正在思考…</div>
        </div>
        <div class="chat-input">
          <textarea v-model="draft" rows="3" placeholder="写一段需求 / 追加细节…  Ctrl+Enter 发送"
                    @keydown.ctrl.enter="sendChat" @keydown.meta.enter="sendChat" />
          <div class="chat-actions">
            <button class="ghost" @click="sendChat" :disabled="!draft.trim() || sending">发送一句 (Ctrl+Enter)</button>
          </div>
        </div>
      </section>

      <!-- RIGHT: attachments + scan + submit -->
      <aside class="side-col">
        <div class="card">
          <h3>附件</h3>
          <div class="upload-row">
            <input ref="fileInputEl" type="file" multiple accept=".md,.txt,.html,.htm" @change="onFilesPicked" />
            <button class="ghost" @click="showScanBrowser = true">扫描代码目录…</button>
          </div>
          <div v-if="attachments.length === 0" class="empty-mini">尚未添加任何附件 / 扫描结果。</div>
          <ul v-else class="att-list">
            <li v-for="(a, i) in attachments" :key="i">
              <span class="att-icon">{{ a.kind === 'scan' ? '📂' : '📄' }}</span>
              <span class="att-name" :title="a.name">{{ a.name }}</span>
              <span class="att-size">{{ a.text.length }} 字符</span>
              <button class="x-btn" @click="attachments.splice(i, 1)">×</button>
            </li>
          </ul>
        </div>

        <div class="card submit-card">
          <h3>预览 + 提交</h3>
          <div class="preview-stat">
            消息 {{ userMsgCount }} 条 · 附件 {{ attachments.length }} 项 · 总长 {{ totalChars }} 字符
          </div>
          <button class="primary big" :disabled="!canSubmit || submitting" @click="submitFinal">
            {{ submitting ? '提交中…' : '提交需求 → PM Agent' }}
          </button>
          <div v-if="submitError" class="err">{{ submitError }}</div>
          <div v-if="submitOk" class="ok">{{ submitOk }}</div>
          <details class="preview-details">
            <summary>查看将发送的 feedback 文本</summary>
            <pre class="preview-text">{{ assembledText }}</pre>
          </details>
        </div>
      </aside>
    </div>

    <!-- 扫描目录浏览器(复用 fs/browse) -->
    <div v-if="showScanBrowser" class="modal-mask" @click.self="showScanBrowser = false">
      <div class="modal browser">
        <h2>选择要扫描的目录</h2>
        <p class="hint">递归读取该目录下的 <code>.md</code> / <code>.txt</code> / <code>.html</code> 文件,拼成一段文本作为附件。</p>
        <div class="bcrumb">
          <button class="ghost" :disabled="!browser.parent" @click="browseTo(browser.parent)">↑ 上级</button>
          <button class="ghost" @click="browseTo(browser.home)">🏠 主目录</button>
          <input class="path-input" v-model="browser.pathInput"
                 @keydown.enter="browseTo(browser.pathInput)"
                 placeholder="输入绝对路径回车跳转" />
        </div>
        <div class="cur-path">当前: <code>{{ browser.path || '—' }}</code></div>
        <div v-if="browser.error" class="err">{{ browser.error }}</div>
        <div class="entries">
          <div v-if="browser.loading" class="empty-mini">加载中…</div>
          <div v-else-if="!browser.entries.length" class="empty-mini">(此目录下没有可见的子目录)</div>
          <div v-else
               v-for="e in browser.entries"
               :key="e.abs_path"
               class="entry"
               @dblclick="browseTo(e.abs_path)">
            <span class="ico">📁</span>
            <span>{{ e.name }}</span>
          </div>
        </div>
        <div class="modal-actions">
          <button @click="showScanBrowser = false" :disabled="scanning">取消</button>
          <button class="primary" :disabled="!browser.path || scanning" @click="doScan">
            {{ scanning ? '扫描中…' : '扫描当前目录' }}
          </button>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, nextTick, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';

const route = useRoute();
const router = useRouter();
const pid = route.params.pid;

const messages = ref([]);   // [{role, content, llm_stub?}]
const draft = ref('');
const sending = ref(false);
const chatLogEl = ref(null);

const fileInputEl = ref(null);
const attachments = ref([]); // [{kind:'file'|'scan', name, text}]

const showScanBrowser = ref(false);
const scanning = ref(false);
const browser = ref({ path:'', parent:null, home:'', entries:[], pathInput:'', loading:false, error:'' });

const submitting = ref(false);
const submitError = ref('');
const submitOk = ref('');

const userMsgCount = computed(() => messages.value.filter(m => m.role === 'user').length);
const totalChars = computed(() =>
  messages.value.reduce((s, m) => s + (m.content?.length || 0), 0)
  + attachments.value.reduce((s, a) => s + (a.text?.length || 0), 0)
);
const assembledText = computed(() => {
  const userTurns = messages.value.filter(m => m.role === 'user').map((m, i) => `[消息 ${i+1}] ${m.content}`).join('\n\n');
  const att = attachments.value.length
    ? '\n\n--- 附件内容 ---\n\n' + attachments.value.map(a => `### ${a.name}\n\n${a.text}`).join('\n\n---\n\n')
    : '';
  return userTurns + att;
});
const canSubmit = computed(() => userMsgCount.value > 0 || attachments.value.length > 0);

async function sendChat() {
  if (!draft.value.trim() || sending.value) return;
  const text = draft.value.trim();
  draft.value = '';
  messages.value.push({ role: 'user', content: text });
  await nextTick(); scrollChatToBottom();
  sending.value = true;
  try {
    const r = await fetch(`/api/devloop/projects/${pid}/draft-chat`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ messages: messages.value.map(m => ({ role: m.role, content: m.content })) }),
    });
    const j = await r.json();
    if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
    messages.value.push({ role: 'assistant', content: j.content || '', llm_stub: !!j.llm_stub });
  } catch (e) {
    messages.value.push({ role: 'assistant', content: `(后端错误: ${e.message})`, llm_stub: true });
  } finally {
    sending.value = false;
    await nextTick(); scrollChatToBottom();
  }
}

function scrollChatToBottom() {
  if (chatLogEl.value) chatLogEl.value.scrollTop = chatLogEl.value.scrollHeight;
}

async function onFilesPicked(ev) {
  const files = Array.from(ev.target.files || []);
  if (!files.length) return;
  const form = new FormData();
  for (const f of files) form.append('files', f);
  try {
    const r = await fetch(`/api/devloop/projects/${pid}/intake/files`, { method: 'POST', body: form });
    const j = await r.json();
    if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
    for (const f of (j.files || [])) {
      if (f.skipped) {
        alert(`跳过 ${f.name}: ${f.reason}`);
        continue;
      }
      attachments.value.push({ kind: 'file', name: f.name, text: f.text || '' });
    }
  } catch (e) {
    alert('上传失败: ' + e.message);
  } finally {
    if (fileInputEl.value) fileInputEl.value.value = '';
  }
}

async function browseTo(p) {
  browser.value.loading = true; browser.value.error = '';
  try {
    const url = p ? `/api/devloop/fs/browse?path=${encodeURIComponent(p)}` : '/api/devloop/fs/browse';
    const r = await fetch(url);
    const j = await r.json();
    if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
    browser.value.path = j.path;
    browser.value.parent = j.parent;
    browser.value.home = j.home;
    browser.value.entries = j.entries || [];
    browser.value.pathInput = j.path;
  } catch (e) {
    browser.value.error = e.message;
  } finally {
    browser.value.loading = false;
  }
}

async function doScan() {
  if (!browser.value.path) return;
  scanning.value = true;
  try {
    const r = await fetch(`/api/devloop/projects/${pid}/intake/scan-dir`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ path: browser.value.path, max_files: 30, max_bytes: 500000 }),
    });
    const j = await r.json();
    if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
    if (!j.files.length) {
      alert('该目录下未找到 md/txt/html 文件。');
      return;
    }
    const text = j.files.map(f => `## ${f.rel}\n\n${f.text}`).join('\n\n---\n\n');
    const name = `${j.files.length} 个文件@${shortPath(browser.value.path)}`
      + (j.truncated ? ' (已截断)' : '');
    attachments.value.push({ kind: 'scan', name, text });
    showScanBrowser.value = false;
  } catch (e) {
    alert('扫描失败: ' + e.message);
  } finally {
    scanning.value = false;
  }
}

function shortPath(p) {
  if (!p) return '';
  if (browser.value.home && p.startsWith(browser.value.home)) return '~' + p.slice(browser.value.home.length);
  return p.length > 36 ? '…' + p.slice(-33) : p;
}

async function submitFinal() {
  if (!canSubmit.value || submitting.value) return;
  submitting.value = true; submitError.value = ''; submitOk.value = '';
  try {
    const r = await fetch(`/api/devloop/projects/${pid}/feedback`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ text: assembledText.value, by: 'human' }),
    });
    const j = await r.json();
    if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`);
    submitOk.value = `已提交 → PRD bumped to rev ${j.prd_rev_after} · status=${j.status}`;
    setTimeout(() => router.push(`/devloop/${pid}`), 1200);
  } catch (e) {
    submitError.value = e.message;
  } finally {
    submitting.value = false;
  }
}

onMounted(() => { /* nothing pre-loaded */ });
</script>

<style scoped>
.new-req-page { max-width: 1280px; margin: 0 auto; padding: 16px 24px; }
.hdr { display:flex; gap:14px; align-items:center; padding-bottom:8px; border-bottom:1px solid #eee; margin-bottom:16px; }
.back { color:#888; text-decoration:none; font-size:13px; }
.back:hover { color:#222; }
h1 { font-size:18px; margin:0; }
h1 code { font-family: ui-monospace, monospace; font-size:14px; background:#f4f4f6; padding:2px 8px; border-radius:4px; }

.layout { display:grid; grid-template-columns: 1fr 380px; gap:16px; }
@media (max-width: 1000px) { .layout { grid-template-columns: 1fr; } }

.chat-col, .side-col { display:flex; flex-direction:column; gap:12px; }
.chat-col { background:#fff; border:1px solid #e7e7eb; border-radius:8px; padding:14px; min-height: 500px; }
.chat-hdr { display:flex; justify-content:space-between; align-items:baseline; }
.chat-hdr h2 { font-size:14px; font-weight:600; margin:0; color:#555; }
.hint-mini { font-size:11px; color:#888; }
.chat-log { flex:1; overflow-y:auto; max-height: 420px; padding:8px; background:#fafafa; border-radius:6px; min-height:200px; }
.empty-mini { color:#888; font-size:13px; text-align:center; padding:24px; }

.bubble { margin-bottom:10px; padding:10px 12px; border-radius:8px; max-width:85%; word-break:break-word; white-space:pre-wrap; font-size:13px; line-height:1.5; }
.bubble.user { background:#dff; margin-left:auto; }
.bubble.assistant { background:#fff; border:1px solid #eee; }
.bubble.pending { color:#888; font-style:italic; }
.bubble-meta { font-size:10px; color:#888; margin-bottom:4px; font-weight:600; }
.stub-tag { background:#fa3; color:#fff; font-size:10px; padding:1px 6px; border-radius:6px; margin-left:6px; font-weight:400; }

.chat-input { display:flex; flex-direction:column; gap:8px; }
.chat-input textarea { width:100%; padding:8px; border:1px solid #ccc; border-radius:5px; font-family:inherit; font-size:13px; box-sizing:border-box; resize:vertical; }
.chat-actions { display:flex; justify-content:flex-end; }

.card { background:#fff; border:1px solid #e7e7eb; border-radius:8px; padding:14px; }
.card h3 { font-size:13px; font-weight:600; margin:0 0 10px 0; color:#555; }
.upload-row { display:flex; gap:6px; align-items:center; flex-wrap:wrap; margin-bottom:10px; }
.upload-row input[type=file] { font-size:12px; }
.att-list { list-style:none; padding:0; margin:0; }
.att-list li { display:flex; align-items:center; gap:8px; padding:6px 0; border-bottom:1px solid #f3f3f3; font-size:12px; }
.att-icon { font-size:14px; }
.att-name { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.att-size { color:#888; font-size:11px; }
.x-btn { background:none; border:none; color:#c66; cursor:pointer; font-size:16px; padding:0 4px; }
.x-btn:hover { color:#c00; }

.submit-card .preview-stat { font-size:12px; color:#666; margin-bottom:10px; }
button.primary { background:#0a6; color:#fff; border:1px solid #0a6; border-radius:5px; padding:6px 14px; cursor:pointer; font-size:13px; }
button.primary:hover:not(:disabled) { background:#085; }
button.primary:disabled { background:#bbb; border-color:#bbb; cursor:not-allowed; }
button.primary.big { width:100%; padding:10px; font-size:14px; font-weight:600; }
button.ghost { background:#fff; border:1px solid #ccc; padding:5px 12px; border-radius:5px; cursor:pointer; font-size:12px; color:#444; }
button.ghost:hover:not(:disabled) { background:#f4f4f6; }
button.ghost:disabled { opacity:0.5; cursor:not-allowed; }
.err { color:#c00; font-size:12px; margin-top:8px; padding:6px; background:#fee; border-radius:4px; }
.ok  { color:#070; font-size:12px; margin-top:8px; padding:6px; background:#dfd; border-radius:4px; }
.preview-details { margin-top:10px; font-size:12px; }
.preview-details summary { cursor:pointer; color:#0a6; }
.preview-text { background:#fafafa; padding:10px; border-radius:5px; font-size:11px; line-height:1.4; max-height:200px; overflow-y:auto; white-space:pre-wrap; word-break:break-word; }

/* Browser 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:680px; box-shadow:0 8px 32px rgba(0,0,0,0.25); }
.modal.browser { min-width:560px; }
.modal h2 { font-size:16px; margin:0 0 8px 0; }
.modal .hint { color:#666; font-size:12px; margin:0 0 12px 0; }
.modal code { background:#f4f4f6; padding:1px 5px; border-radius:3px; font-size:11px; font-family: ui-monospace, monospace; }
.bcrumb { display:flex; gap:6px; align-items:center; margin-bottom:8px; }
.bcrumb .path-input { flex:1; padding:5px 10px; border:1px solid #ccc; border-radius:4px; font-size:12px; font-family: ui-monospace, monospace; }
.cur-path { font-size:12px; color:#666; margin:6px 0; }
.entries { border:1px solid #eee; border-radius:6px; padding:6px 0; max-height:280px; min-height:160px; overflow-y:auto; background:#fafafa; }
.entry { display:flex; gap:10px; padding:6px 14px; cursor:pointer; }
.entry:hover { background:#eef; }
.modal-actions { display:flex; justify-content:flex-end; gap:10px; margin-top:14px; }
.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; }
.modal-actions button:disabled { opacity:0.5; cursor:not-allowed; }
</style>