2026-04-23 14:36:26 +08:00

2309 lines
123 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ project.name }} · 标伙伴</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<link rel="stylesheet" href="/static/style.css">
<style>
[x-cloak]{display:none!important}
body{font-family:'PingFang SC','Microsoft YaHei',sans-serif;background:#f0f4f8}
.tab-btn{@apply px-4 py-2 text-sm font-medium rounded-lg transition}
.prose-content{white-space:pre-wrap;line-height:1.9;font-size:14px}
.section-item:hover .section-actions{opacity:1}
.section-actions{opacity:0;transition:opacity .15s}
.sidebar-fixed{height:calc(100vh - 4rem);position:sticky;top:4rem;overflow-y:auto}
</style>
</head>
<body class="min-h-screen" x-data="projectApp({{ project.id }})" x-init="init()">
<!-- ── 顶栏 ── -->
<header class="bg-white border-b border-gray-200 sticky top-0 z-50 shadow-sm h-16 flex items-center px-6">
<div class="flex items-center gap-3 flex-1 min-w-0">
<a href="/" class="p-1.5 hover:bg-gray-100 rounded-lg transition text-gray-500">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
</a>
<div class="w-px h-5 bg-gray-200"></div>
<div class="w-7 h-7 rounded-lg bg-gradient-to-br from-blue-600 to-indigo-600 flex items-center justify-center flex-shrink-0">
<svg class="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<h1 class="text-base font-bold text-gray-900 truncate">{{ project.name }}</h1>
</div>
<!-- 步骤指示 -->
<div class="hidden md:flex items-center gap-1 mx-4">
<template x-for="(step, i) in steps" :key="i">
<div class="flex items-center">
<button @click="activeTab=step.key"
class="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium transition"
:class="activeTab===step.key ? 'bg-blue-600 text-white shadow-sm' : 'text-gray-500 hover:bg-gray-100'">
<span x-text="i+1" class="w-4 h-4 rounded-full flex items-center justify-center text-xs"
:class="activeTab===step.key ? 'bg-white/20' : 'bg-gray-200'"></span>
<span x-text="step.label"></span>
</button>
<div x-show="i < steps.length-1" class="w-4 h-px bg-gray-200 mx-1"></div>
</div>
</template>
</div>
<!-- 导出按钮 -->
<button @click="exportDoc()" :disabled="exporting"
class="flex items-center gap-2 px-4 py-2 bg-green-600 hover:bg-green-700 text-white text-sm font-medium rounded-lg shadow-sm transition disabled:opacity-60 flex-shrink-0">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<span x-show="!exporting">导出 Word</span>
<span x-show="exporting">导出中...</span>
</button>
</header>
<!-- ── 主体布局 ── -->
<div class="flex max-w-full">
<!-- 左侧:章节树(仅在生成大纲后显示) -->
<aside x-show="sections.length > 0" class="sidebar-fixed w-72 flex-shrink-0 bg-white border-r border-gray-200 px-3 py-4 hidden lg:block">
<div class="flex items-center justify-between mb-2 px-1">
<span class="text-xs font-semibold text-gray-500 uppercase tracking-wider">标书章节</span>
<div class="flex items-center gap-1.5">
<span x-show="progress.running > 0"
class="flex items-center gap-1 text-xs text-purple-600 font-medium">
<span class="relative flex h-2 w-2">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-purple-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2 w-2 bg-purple-600"></span>
</span>
<span x-text="progress.running + '路'"></span>
</span>
<span class="text-xs text-gray-400" x-text="`${progress.done}/${progress.total}`"></span>
</div>
</div>
<div x-show="progress.total > 0" class="mb-3 px-1">
<div class="h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div class="h-full rounded-full transition-all duration-500"
:class="progress.running > 0 ? 'bg-purple-500' : progress.percent===100 ? 'bg-green-500' : 'bg-blue-500'"
:style="'width:' + progress.percent + '%'"></div>
</div>
</div>
<div class="space-y-0.5">
<template x-for="s in sections" :key="s.id">
<div class="section-item flex items-start gap-2 px-2 py-1.5 rounded-lg hover:bg-gray-50 cursor-pointer group"
:class="selectedSection && selectedSection.id===s.id ? 'bg-blue-50' : ''"
@click="selectSection(s)">
<!-- 缩进 -->
<div class="flex-shrink-0 mt-0.5" :style="'width:' + (s.level-1)*12 + 'px'"></div>
<!-- 状态图标 -->
<div class="flex-shrink-0 w-4 h-4 mt-0.5">
<svg x-show="s.status==='done'" class="w-4 h-4 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"/>
</svg>
<div x-show="s.status==='generating'" class="w-3.5 h-3.5 border-2 border-blue-300 border-t-blue-600 rounded-full animate-spin m-0.5"></div>
<svg x-show="s.status==='error'" class="w-4 h-4 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"/>
</svg>
<div x-show="!['done','generating','error'].includes(s.status)"
class="w-3 h-3 rounded-full border border-gray-300 m-0.5"></div>
</div>
<span class="text-xs leading-5 flex-1 min-w-0 break-words"
:class="[s.level===1 ? 'font-semibold text-gray-800' : s.level===2 ? 'font-medium text-gray-700' : 'text-gray-600',
selectedSection && selectedSection.id===s.id ? 'text-blue-700' : '']"
x-text="s.title"></span>
<!-- 生成按钮 -->
<button x-show="s.is_leaf" class="section-actions flex-shrink-0 text-blue-500 hover:text-blue-700"
@click.stop="generateSection(s.id)" :disabled="s.status==='generating'" title="生成此章节">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
</button>
</div>
</template>
</div>
</aside>
<!-- 右侧:内容区 -->
<main class="flex-1 min-w-0 p-6">
<!-- ═══ TAB 1招标分析 ═══ -->
<div x-show="activeTab==='parse'" class="space-y-5">
<!-- 上传区 -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
<h2 class="text-base font-bold text-gray-800 mb-4 flex items-center gap-2">
<span class="w-6 h-6 bg-blue-100 text-blue-600 rounded-md flex items-center justify-center text-xs font-bold">1</span>
上传招标文件
</h2>
<div class="border-2 border-dashed rounded-xl p-8 text-center transition"
:class="dragover ? 'border-blue-400 bg-blue-50' : 'border-gray-200 hover:border-gray-300'"
@dragover.prevent="dragover=true" @dragleave="dragover=false"
@drop.prevent="onDrop($event)">
<input type="file" id="fileInput" class="hidden" accept=".pdf,.doc,.docx" @change="onFileSelect($event)">
<svg class="w-10 h-10 text-gray-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12"/>
</svg>
<p class="text-sm text-gray-500 mb-1">拖拽文件到此处,或</p>
<button class="text-blue-600 text-sm font-medium hover:underline" @click="$el.closest('.border-dashed').querySelector('#fileInput').click()">
点击选择文件
</button>
<p class="text-xs text-gray-400 mt-2">支持 PDF、DOC、DOCX最大 50MB</p>
</div>
<!-- 已上传文件 -->
<div x-show="project.file_name" class="mt-4 flex items-center justify-between p-3 bg-gray-50 rounded-xl">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-blue-100 rounded-lg flex items-center justify-center">
<svg class="w-4 h-4 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-gray-800" x-text="project.file_name"></p>
<p class="text-xs text-gray-400">已上传</p>
</div>
</div>
<button @click="parseTender()" :disabled="parsing || project.parse_status==='parsing'"
class="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white text-sm rounded-lg transition disabled:opacity-60">
<span x-show="!parsing">AI 解析</span>
<span x-show="parsing" class="flex items-center gap-1.5">
<span class="w-3.5 h-3.5 border-2 border-white/40 border-t-white rounded-full animate-spin"></span>
解析中...
</span>
</button>
</div>
<!-- 上传进度 -->
<div x-show="uploading" class="mt-3">
<div class="flex items-center gap-2 text-sm text-blue-600">
<div class="w-4 h-4 border-2 border-blue-200 border-t-blue-600 rounded-full animate-spin"></div>
上传中...
</div>
</div>
</div>
<!-- 篇幅目标:全功能唯一入口在「解析」;位于上传与清单之间 -->
<div class="p-4 sm:p-5 bg-purple-50 border border-purple-200 rounded-2xl shadow-sm">
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="text-sm font-semibold text-purple-900">篇幅目标(按页数粗略换算)</p>
<p class="text-xs text-purple-700 mt-1">100/150/200/250/300 页或自定义,保存后用于后续章节生成。在此「保存页数设置」会同步全局配置并写入本标书项目。</p>
</div>
<button type="button" @click="savePageSettings()" :disabled="savingPageSettings"
class="self-start lg:self-auto px-3 py-1.5 text-xs font-medium rounded-lg bg-purple-600 text-white hover:bg-purple-700 disabled:opacity-60">
<span x-show="!savingPageSettings">保存页数设置</span>
<span x-show="savingPageSettings">保存中...</span>
</button>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<template x-for="p in [100,150,200,250,300]" :key="'parse-tab-preset-' + p">
<button type="button" @click="setPagePreset(p)"
:class="targetPages === p ? 'bg-purple-600 text-white border-purple-600' : 'bg-white text-purple-700 border-purple-200 hover:bg-purple-100'"
class="px-3 py-1.5 text-xs rounded-lg border transition">
<span x-text="p + '页'"></span>
</button>
</template>
<button type="button" @click="setPagePreset(0)"
:class="targetPages === 0 ? 'bg-purple-600 text-white border-purple-600' : 'bg-white text-purple-700 border-purple-200 hover:bg-purple-100'"
class="px-3 py-1.5 text-xs rounded-lg border transition">
使用原档位
</button>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="text-xs text-purple-800">自定义页数</span>
<input type="number" min="1" max="2000" x-model.number="customTargetPages"
class="w-24 px-2 py-1.5 text-xs border border-purple-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-400 bg-white">
<button type="button" @click="applyCustomPages()"
class="px-2.5 py-1.5 text-xs bg-white border border-purple-300 text-purple-700 rounded-lg hover:bg-purple-100 transition">
应用自定义
</button>
<span class="text-xs text-purple-700">当前:<b x-text="targetPages > 0 ? (targetPages + ' 页') : '使用原篇幅档位'"></b></span>
</div>
<label class="mt-3 flex items-start gap-2 cursor-pointer select-none text-xs text-purple-900">
<input type="checkbox" x-model="noSubchapterLimit" class="mt-0.5 rounded border-purple-400 text-purple-600 focus:ring-purple-500" />
<span>不限制小章节条数(填小目时慎用,可产生极多行)。不勾选时未设页数则按约 100 页限幅。</span>
</label>
</div>
<!-- 生成偏好设置(图表 & 暗标) - 移动到解析页,实现全流程打通 -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
<div class="flex items-start justify-between mb-4">
<h3 class="text-base font-bold text-gray-800 flex items-center gap-2">
<span class="w-6 h-6 bg-teal-100 text-teal-600 rounded-md flex items-center justify-center text-xs font-bold">设置</span>
生成偏好(图表 & 暗标)
</h3>
<div class="flex items-center gap-2 text-xs text-gray-500">
解析后自动应用至大纲和生成
</div>
</div>
<!-- 图表设置按钮 (relocated) -->
<button @click="showDiagramPanel = !showDiagramPanel"
:class="(enableFigure||enableTable) ? 'bg-teal-500 hover:bg-teal-600 text-white border-teal-500' : 'bg-white hover:bg-teal-50 text-gray-600 border-gray-200'"
class="flex items-center gap-1.5 px-3 py-2 border text-sm font-medium rounded-lg transition mr-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
图表设置
<span x-show="enableFigure||enableTable" class="w-1.5 h-1.5 rounded-full bg-white"></span>
</button>
<!-- 暗标设置按钮 (relocated) -->
<button @click="showAnonPanel = !showAnonPanel"
:class="anonEnabled ? 'bg-amber-500 hover:bg-amber-600 text-white border-amber-500' : 'bg-white hover:bg-amber-50 text-gray-600 border-gray-200'"
class="flex items-center gap-1.5 px-3 py-2 border text-sm font-medium rounded-lg transition">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
暗标设置
<span x-show="anonEnabled" class="w-1.5 h-1.5 rounded-full bg-white"></span>
</button>
<!-- 暗标设置面板 (relocated & adapted) -->
<div x-show="showAnonPanel" x-transition class="mt-4 p-5 bg-amber-50 border border-amber-200 rounded-xl">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-3">
<svg class="w-4 h-4 text-amber-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"/>
</svg>
<span class="text-sm font-semibold text-amber-800">暗标模式</span>
<label class="flex items-center gap-2 cursor-pointer select-none">
<div class="relative">
<input type="checkbox" x-model="anonEnabled" class="sr-only">
<div :class="anonEnabled ? 'bg-amber-500' : 'bg-gray-300'" class="w-9 h-5 rounded-full transition"></div>
<div :class="anonEnabled ? 'translate-x-4' : 'translate-x-0'" class="absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform"></div>
</div>
<span class="text-xs text-amber-700" x-text="anonEnabled ? '已启用' : '未启用'"></span>
</label>
</div>
<button @click="saveAnon()" :disabled="savingAnon" class="flex items-center gap-1.5 px-3 py-1.5 bg-amber-500 hover:bg-amber-600 text-white text-xs font-medium rounded-lg transition disabled:opacity-60">
<span x-text="savingAnon ? '保存中...' : '保存暗标要求'"></span>
</button>
</div>
<div x-show="anonEnabled" x-transition>
<p class="text-xs text-amber-700 mb-3">暗标要求将附加到每个章节的 AI 生成规范中。解析阶段可预设,生成时自动注入。</p>
<div class="flex flex-wrap gap-2 mb-3">
<span class="text-xs text-amber-600 font-medium self-center mr-1">常用预设:</span>
<button @click="addAnonPreset('不得出现投标人公司名称、注册地址等任何可识别单位信息')" class="px-2.5 py-1 text-xs bg-white border border-amber-200 text-amber-700 rounded-md hover:bg-amber-100 transition">+ 公司名称</button>
<button @click="addAnonPreset('不得出现项目经理、技术负责人等人员的真实姓名')" class="px-2.5 py-1 text-xs bg-white border border-amber-200 text-amber-700 rounded-md hover:bg-amber-100 transition">+ 人员姓名</button>
<button @click="addAnonPreset('不得出现联系方式(电话、邮箱、网址等)')" class="px-2.5 py-1 text-xs bg-white border border-amber-200 text-amber-700 rounded-md hover:bg-amber-100 transition">+ 联系方式</button>
</div>
<textarea x-model="anonText" rows="4" placeholder="在此输入暗标要求..." class="w-full px-4 py-3 border border-amber-200 rounded-xl text-sm leading-6 focus:outline-none focus:ring-2 focus:ring-amber-400 bg-white resize-y font-mono"></textarea>
</div>
</div>
<!-- 图表设置面板 (relocated & adapted) -->
<div x-show="showDiagramPanel" x-transition class="mt-4 p-5 bg-teal-50 border border-teal-200 rounded-xl">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<svg class="w-4 h-4 text-teal-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z"/>
</svg>
<span class="text-sm font-semibold text-teal-800">图表生成模式</span>
</div>
<button @click="saveDiagram()" :disabled="savingDiagram" class="flex items-center gap-1.5 px-3 py-1.5 bg-teal-500 hover:bg-teal-600 text-white text-xs font-medium rounded-lg transition disabled:opacity-60">
<span x-text="savingDiagram ? '保存中...' : '保存设置'"></span>
</button>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label class="flex items-start gap-3 p-4 bg-white border border-teal-200 rounded-xl cursor-pointer hover:border-teal-400 transition" :class="enableFigure ? 'border-teal-400 ring-1 ring-teal-300' : ''">
<div class="relative mt-0.5 flex-shrink-0">
<input type="checkbox" x-model="enableFigure" class="sr-only">
<div :class="enableFigure ? 'bg-teal-500' : 'bg-gray-300'" class="w-9 h-5 rounded-full transition"></div>
<div :class="enableFigure ? 'translate-x-4' : 'translate-x-0'" class="absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform"></div>
</div>
<div>
<p class="text-sm font-semibold text-gray-800">启用"图"生成</p>
<p class="text-xs text-gray-500 mt-1 leading-5">解析后AI生成章节时自动插入图示导出Word时渲染。</p>
</div>
</label>
<label class="flex items-start gap-3 p-4 bg-white border border-teal-200 rounded-xl cursor-pointer hover:border-teal-400 transition" :class="enableTable ? 'border-teal-400 ring-1 ring-teal-300' : ''">
<div class="relative mt-0.5 flex-shrink-0">
<input type="checkbox" x-model="enableTable" class="sr-only">
<div :class="enableTable ? 'bg-teal-500' : 'bg-gray-300'" class="w-9 h-5 rounded-full transition"></div>
<div :class="enableTable ? 'translate-x-4' : 'translate-x-0'" class="absolute top-0.5 left-0.5 w-4 h-4 bg-white rounded-full shadow transition-transform"></div>
</div>
<div>
<p class="text-sm font-semibold text-gray-800">启用"表"生成</p>
<p class="text-xs text-gray-500 mt-1 leading-5">解析后AI生成章节时自动插入表格导出Word时渲染。</p>
</div>
</label>
</div>
<p class="text-xs text-teal-600 mt-3">设置后保存,解析结果和大纲生成将参考这些偏好。</p>
</div>
</div>
<!-- ── 工程量清单导入(可选)── -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
<div class="flex items-center gap-2 mb-4">
<svg class="w-4 h-4 text-orange-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<h3 class="font-semibold text-gray-800 text-sm">工程量清单导入 <span class="text-xs font-normal text-gray-400 ml-1">(可选)解析后可联动招标内容,让生成内容包含准确工程量</span></h3>
</div>
<!-- 上传区域 -->
<div x-show="!project.boq_file_name"
class="border-2 border-dashed border-orange-200 rounded-xl p-6 text-center hover:border-orange-400 transition cursor-pointer bg-orange-50"
@dragover.prevent="true"
@drop.prevent="uploadBoqFile($event.dataTransfer.files[0])"
@click="$refs.boqFileInput.click()">
<input type="file" x-ref="boqFileInput" class="hidden"
accept=".xlsx,.xls,.csv,.pdf,.docx,.doc"
@change="uploadBoqFile($event.target.files[0])">
<svg class="w-8 h-8 mx-auto mb-2 text-orange-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<p class="text-sm text-orange-600">拖拽清单文件到此处,或点击选择</p>
<p class="text-xs text-gray-400 mt-1">支持 Excelxlsx/xls、CSV、PDF、Word最大 50MB</p>
</div>
<!-- 已上传 -->
<div x-show="project.boq_file_name" class="flex items-center justify-between p-3 bg-orange-50 rounded-xl">
<div class="flex items-center gap-3">
<div class="w-8 h-8 bg-orange-100 rounded-lg flex items-center justify-center flex-shrink-0">
<svg class="w-4 h-4 text-orange-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<div>
<p class="text-sm font-medium text-gray-800" x-text="project.boq_file_name"></p>
<p class="text-xs text-gray-400" x-text="project.boq_status==='done' ? '已解析完成' : project.boq_status==='parsing' ? '解析中...' : '已上传'"></p>
</div>
</div>
<div class="flex items-center gap-2">
<!-- 重新上传 -->
<button @click="project.boq_file_name=''; project.boq_status='none'"
class="text-xs text-gray-400 hover:text-gray-600 transition">重新上传</button>
<!-- 解析按钮 -->
<button @click="parseBoq()"
:disabled="parsingBoq || project.boq_status==='parsing'"
class="flex items-center gap-1.5 px-4 py-2 bg-orange-500 hover:bg-orange-600 text-white text-sm rounded-lg transition disabled:opacity-60">
<span x-show="!parsingBoq && project.boq_status!=='parsing'">AI 解析清单</span>
<span x-show="parsingBoq || project.boq_status==='parsing'" class="flex items-center gap-1.5">
<span class="w-3.5 h-3.5 border-2 border-white/40 border-t-white rounded-full animate-spin"></span>
解析中...
</span>
</button>
</div>
</div>
<!-- 上传进度 -->
<div x-show="uploadingBoq" class="mt-3 flex items-center gap-2 text-sm text-orange-600">
<div class="w-4 h-4 border-2 border-orange-200 border-t-orange-500 rounded-full animate-spin"></div>
上传中...
</div>
<!-- 解析错误 -->
<div x-show="project.boq_status==='error'" class="mt-3 flex items-center gap-2 text-sm text-red-600">
<svg class="w-4 h-4 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"/>
</svg>
<span x-text="'解析失败: ' + (project.boq_error || '未知错误')"></span>
</div>
<!-- 本地清单结构化分析bill-worker 规则 Python 版) -->
<div x-show="project.boq_status==='done' && project.boq_analysis" class="mt-4 space-y-2">
<template x-if="project.boq_analysis && (project.boq_analysis.scanned || project.boq_analysis.no_bill_pages)">
<p class="text-xs text-amber-700 bg-amber-50 border border-amber-100 rounded-lg px-3 py-2">
<span x-show="project.boq_analysis.scanned">本地分析:文本过少,可能为扫描件或未提取到文字层;已仅依赖全文生成摘要。</span>
<span x-show="!project.boq_analysis.scanned && project.boq_analysis.no_bill_pages">本地分析:未识别到清单表页,摘要仍可能基于全文。</span>
</p>
</template>
<template x-if="project.boq_analysis && project.boq_analysis.categories && project.boq_analysis.categories.length && !project.boq_analysis.scanned && !project.boq_analysis.no_bill_pages">
<div class="rounded-xl border border-orange-100 bg-white p-3 text-xs">
<div class="flex items-center justify-between gap-2 mb-2">
<p class="font-semibold text-orange-800 flex items-center gap-1">
<span class="w-1.5 h-1.5 bg-orange-500 rounded-full"></span>
本地清单分析
</p>
<button type="button" @click="showBoqAnalysisDetail = !showBoqAnalysisDetail"
class="text-orange-600 hover:text-orange-800 shrink-0">
<span x-text="showBoqAnalysisDetail ? '收起明细' : '展开明细'"></span>
</button>
</div>
<p class="text-gray-600 mb-1" x-text="project.boq_analysis.project_summary && project.boq_analysis.project_summary.remark"></p>
<p class="text-gray-500">
分部 <span class="font-medium text-gray-700" x-text="boqStructuredCatCount()"></span> 个,
清单项 <span class="font-medium text-gray-700" x-text="boqStructuredItemCount()"></span>
<template x-if="project.boq_analysis._meta">
<span>(识别清单相关页 <span x-text="project.boq_analysis._meta.bill_pages || 0"></span> / 总 <span x-text="project.boq_analysis._meta.total_pages || 0"></span> 页)</span>
</template>
</p>
<div x-show="showBoqAnalysisDetail" class="mt-2 max-h-64 overflow-y-auto border border-gray-100 rounded-lg">
<template x-for="cat in (project.boq_analysis.categories || [])" :key="cat.name">
<div class="border-b border-gray-50 last:border-0 px-2 py-2">
<p class="font-medium text-gray-800 mb-1" x-text="cat.name"></p>
<table class="w-full text-left text-[11px] text-gray-600">
<thead>
<tr class="text-gray-400 border-b border-gray-100">
<th class="py-0.5 pr-2 font-normal">编码</th>
<th class="py-0.5 pr-2 font-normal">名称</th>
<th class="py-0.5 pr-1 font-normal w-10">单位</th>
<th class="py-0.5 font-normal w-14">工程量</th>
</tr>
</thead>
<tbody>
<template x-for="(it, idx) in (cat.items || [])" :key="idx">
<tr class="border-b border-gray-50 align-top">
<td class="py-0.5 pr-2 font-mono text-[10px]" x-text="it.code || '—'"></td>
<td class="py-0.5 pr-2" x-text="it.name"></td>
<td class="py-0.5 pr-1" x-text="it.unit || ''"></td>
<td class="py-0.5" x-text="it.quantity || ''"></td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</div>
</div>
</template>
</div>
<!-- BOQ 摘要显示/编辑(解析完成后) -->
<div x-show="project.boq_status==='done' && project.boq_summary" class="mt-4">
<div class="flex items-center justify-between mb-2">
<p class="text-xs font-semibold text-orange-700 flex items-center gap-1">
<span class="w-1.5 h-1.5 bg-orange-500 rounded-full"></span>
工程量清单摘要
</p>
<div class="flex items-center gap-2">
<template x-if="editBoq">
<div class="flex gap-2">
<button @click="saveBoqSummary()" :disabled="savingBoq"
class="flex items-center gap-1 px-3 py-1 bg-orange-500 hover:bg-orange-600 text-white text-xs rounded-lg transition disabled:opacity-60">
<span x-show="!savingBoq">保存</span>
<span x-show="savingBoq" class="flex items-center gap-1">
<span class="w-3 h-3 border border-white/40 border-t-white rounded-full animate-spin"></span>
保存中
</span>
</button>
<button @click="editBoq=false; editBoqText=project.boq_summary"
class="px-3 py-1 text-xs border border-gray-200 text-gray-500 hover:bg-gray-50 rounded-lg transition">
取消
</button>
</div>
</template>
<template x-if="!editBoq">
<button @click="editBoq=true; editBoqText=project.boq_summary"
class="flex items-center gap-1 px-3 py-1 text-xs border border-gray-200 text-gray-600 hover:bg-gray-50 rounded-lg transition">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
编辑
</button>
</template>
</div>
</div>
<!-- 预览 -->
<div x-show="!editBoq"
class="text-xs text-gray-700 bg-orange-50 rounded-xl p-4 max-h-48 overflow-y-auto whitespace-pre-wrap leading-6 border border-orange-100"
x-text="project.boq_summary"></div>
<!-- 编辑 -->
<div x-show="editBoq">
<textarea x-model="editBoqText" rows="10"
class="w-full px-4 py-3 border border-orange-300 rounded-xl text-xs leading-6 focus:outline-none focus:ring-2 focus:ring-orange-400 resize-y font-mono"
placeholder="工程量清单摘要内容..."></textarea>
<p class="text-xs text-gray-400 mt-1">修改后点击"保存",将在生成章节内容时作为工程量参考</p>
</div>
</div>
</div>
<!-- 解析状态 -->
<div x-show="project.parse_status && project.parse_status !== 'none' && project.parse_status !== 'uploaded'"
class="bg-white rounded-2xl shadow-sm border border-gray-100 p-5">
<div x-show="project.parse_status==='parsing'" class="flex items-center gap-3 text-blue-600">
<div class="w-5 h-5 border-2 border-blue-200 border-t-blue-600 rounded-full animate-spin"></div>
<span class="text-sm font-medium" x-text="project.parse_error || '正在解析,请稍候...'"></span>
</div>
<div x-show="project.parse_status==='error'" class="flex items-center gap-3 text-red-600">
<svg class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"/>
</svg>
<span class="text-sm" x-text="'解析失败: ' + (project.parse_error || '未知错误')"></span>
</div>
</div>
<!-- 解析结果 -->
<div x-show="project.parse_status==='done'" class="space-y-4">
<!-- 标书类型(解析自动识别,影响步骤 3 正文模板) -->
<div class="bg-gradient-to-r from-indigo-50 to-blue-50 rounded-2xl border border-indigo-100 p-5">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h3 class="text-sm font-semibold text-indigo-900">标书类型</h3>
<p class="text-xs text-indigo-700/80 mt-0.5">解析完成后自动识别为工程类 / 服务类 / 货物类;步骤 3 生成章节将套用对应写作模板(施工组织 / 服务方案 / 供货方案)。识别有误可在此修正。</p>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
<select x-model="tenderKind"
class="text-sm border border-indigo-200 rounded-lg px-3 py-2 bg-white text-gray-800 focus:ring-2 focus:ring-indigo-400 focus:outline-none min-w-[11rem]">
<option value="engineering">工程类(施工组织设计)</option>
<option value="service">服务类(服务实施方案)</option>
<option value="goods">货物类(供货技术方案)</option>
</select>
<button type="button" @click="saveTenderData('kind')"
:disabled="savingTenderData"
class="px-3 py-2 text-xs font-medium rounded-lg bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-50">
保存类型
</button>
</div>
</div>
</div>
<!-- 招标文件摘要 -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-gray-800 flex items-center gap-2">
<span class="w-2 h-2 bg-green-500 rounded-full"></span>
招标文件摘要
</h3>
<div class="flex items-center gap-2">
<template x-if="editSummary">
<div class="flex items-center gap-2">
<button @click="saveTenderData('summary')"
:disabled="savingTenderData"
class="flex items-center gap-1 px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-xs font-medium rounded-lg transition disabled:opacity-60">
<span x-show="!savingTenderData">保存</span>
<span x-show="savingTenderData" class="flex items-center gap-1">
<span class="w-3 h-3 border border-white/40 border-t-white rounded-full animate-spin"></span>
保存中
</span>
</button>
<button @click="editSummary=false; editSummaryText=project.summary"
class="px-3 py-1.5 text-xs border border-gray-200 text-gray-500 hover:bg-gray-50 rounded-lg transition">
取消
</button>
</div>
</template>
<template x-if="!editSummary">
<div class="flex items-center gap-2">
<button @click="editSummary=true; editSummaryText=project.summary"
class="flex items-center gap-1 px-3 py-1.5 text-xs border border-gray-200 text-gray-600 hover:bg-gray-50 rounded-lg transition">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
编辑
</button>
<button @click="activeTab='outline'" class="text-xs text-blue-600 hover:underline">
下一步:生成大纲 →
</button>
</div>
</template>
</div>
</div>
<!-- 预览模式 -->
<div x-show="!editSummary"
class="prose-content text-gray-700 bg-gray-50 rounded-xl p-4 max-h-64 overflow-y-auto text-sm"
x-text="project.summary || '摘要生成中...'"></div>
<!-- 编辑模式 -->
<div x-show="editSummary">
<textarea x-model="editSummaryText" rows="12"
class="w-full px-4 py-3 border border-blue-300 rounded-xl text-sm leading-7 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-y font-mono"
placeholder="招标文件摘要内容..."></textarea>
<p class="text-xs text-gray-400 mt-1">修改后点击"保存",将作为生成大纲的依据</p>
</div>
</div>
<!-- 技术评分要求 -->
<div x-show="project.rating_requirements" class="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-gray-800 flex items-center gap-2">
<span class="w-2 h-2 bg-orange-500 rounded-full"></span>
技术评分要求
</h3>
<div class="flex items-center gap-2">
<template x-if="editRating">
<div class="flex items-center gap-2">
<button @click="saveTenderData('rating')"
:disabled="savingTenderData"
class="flex items-center gap-1 px-3 py-1.5 bg-green-600 hover:bg-green-700 text-white text-xs font-medium rounded-lg transition disabled:opacity-60">
<span x-show="!savingTenderData">保存</span>
<span x-show="savingTenderData" class="flex items-center gap-1">
<span class="w-3 h-3 border border-white/40 border-t-white rounded-full animate-spin"></span>
保存中
</span>
</button>
<button @click="editRating=false; editRatingText=project.rating_requirements"
class="px-3 py-1.5 text-xs border border-gray-200 text-gray-500 hover:bg-gray-50 rounded-lg transition">
取消
</button>
</div>
</template>
<template x-if="!editRating">
<button @click="editRating=true; editRatingText=project.rating_requirements"
class="flex items-center gap-1 px-3 py-1.5 text-xs border border-gray-200 text-gray-600 hover:bg-gray-50 rounded-lg transition">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
编辑
</button>
</template>
</div>
</div>
<!-- 预览模式 -->
<div x-show="!editRating"
class="prose-content text-gray-700 bg-orange-50 rounded-xl p-4 max-h-64 overflow-y-auto text-sm"
x-text="project.rating_requirements"></div>
<!-- 编辑模式 -->
<div x-show="editRating">
<textarea x-model="editRatingText" rows="14"
class="w-full px-4 py-3 border border-orange-300 rounded-xl text-sm leading-7 focus:outline-none focus:ring-2 focus:ring-orange-400 resize-y font-mono"
placeholder="技术评分要求内容..."></textarea>
<p class="text-xs text-gray-400 mt-1">修改后点击"保存",将作为生成大纲的依据(只保留技术评分,删除商务/价格评分内容)</p>
</div>
</div>
</div>
</div>
<!-- ═══ TAB 2大纲生成 ═══ -->
<div x-show="activeTab==='outline'" class="space-y-5">
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
<h2 class="text-base font-bold text-gray-800 mb-4 flex items-center gap-2">
<span class="w-6 h-6 bg-indigo-100 text-indigo-600 rounded-md flex items-center justify-center text-xs font-bold">2</span>
生成标书大纲
</h2>
<div x-show="project.parse_status !== 'done'" class="py-8 text-center">
<p class="text-gray-400 text-sm">请先完成招标文件解析</p>
<button @click="activeTab='parse'" class="mt-3 text-blue-600 text-sm hover:underline">返回解析步骤</button>
</div>
<div x-show="project.parse_status === 'done'">
<p class="text-sm text-gray-500 mb-3">AI 将根据招标文件摘要和技术评分标准,生成结构化的四级标书目录。</p>
<div class="mb-4 p-3 bg-slate-50 border border-slate-200 rounded-lg text-xs text-slate-800 leading-relaxed">
<p class="font-semibold text-slate-900 mb-1.5">控长与章节数(建议按顺序执行)</p>
<ol class="list-decimal pl-4 space-y-1.5">
<li><b>定总节数 N</b>:在大纲中合并/删除不需要的编号行,点「保存并更新章节」;步骤 3「生成」中「共 N 个章节」、左侧章节目录与这里的 N 一致,由本步大纲内容决定。</li>
<li><b>用页数约束小目 AI 树</b>在「步骤1 解析」中「上传」与「清单」之间的「篇幅目标」设页使用「AI 自动填充小章节」时一般勿勾选「不限制小章节」;系统将按目标页对<strong>小章节条数</strong>作映射与限幅。</li>
<li><b>全稿再回调</b>:到「生成」用「一键并发生成」后看总字数/估算;若仍过长,回本页大纲继续并节/删行、保存,再回「生成」重跑。</li>
</ol>
</div>
<div class="mb-4 p-3 bg-indigo-50 border border-indigo-200 rounded-xl text-xs text-indigo-900 leading-relaxed">
<p>
<strong>目标页数、不限制小章节</strong>
<button type="button" @click="activeTab = 'parse'" class="text-indigo-600 font-medium hover:underline">步骤1 解析</button>
(上传与清单之间)的「篇幅目标」中设置。
</p>
</div>
<button @click="generateOutline()"
:disabled="project.outline_status==='outline_generating'"
class="flex items-center gap-2 px-5 py-2.5 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-xl shadow-sm transition disabled:opacity-60">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01"/>
</svg>
<span x-show="project.outline_status !== 'outline_generating'">
<span x-text="sections.length > 0 ? '重新生成大纲' : '生成标书大纲'"></span>
</span>
<span x-show="project.outline_status === 'outline_generating'" class="flex items-center gap-1.5">
<span class="w-3.5 h-3.5 border-2 border-white/40 border-t-white rounded-full animate-spin"></span>
正在生成...
</span>
</button>
<p class="text-xs text-gray-400 mt-2" x-show="project.outline && project.outline.length > 0">
当前用于生成的大纲:约 <span x-text="project.outline.length"></span> 字,<span x-text="sections.length"></span> 个章节
</p>
</div>
<!-- 大纲进度 -->
<div x-show="project.outline_status==='outline_generating'" class="mt-4 flex items-center gap-2 text-indigo-600">
<div class="w-4 h-4 border-2 border-indigo-200 border-t-indigo-600 rounded-full animate-spin"></div>
<span class="text-sm">AI 正在生成标书大纲,通常需要 30-60 秒...</span>
</div>
<div x-show="project.outline_status==='outline_error'" class="mt-4 text-red-500 text-sm"
x-text="'生成失败: ' + (project.outline_error || '')"></div>
<!-- 大纲预览 / 编辑 -->
<div x-show="sections.length > 0" class="mt-5">
<div class="flex items-center justify-between mb-3">
<h3 class="font-medium text-gray-700">
大纲
<span x-show="!editOutline">预览</span>
<span x-show="editOutline">编辑</span>
<span x-text="sections.length"></span> 个章节)
</h3>
<div class="flex items-center gap-2">
<!-- 编辑模式按钮组 -->
<template x-if="editOutline">
<div class="flex items-center gap-2">
<button @click="saveOutline()"
:disabled="savingOutline"
class="flex items-center gap-1 px-3 py-1.5 bg-indigo-600 hover:bg-indigo-700 text-white text-xs font-medium rounded-lg transition disabled:opacity-60">
<span x-show="!savingOutline">保存并更新章节</span>
<span x-show="savingOutline" class="flex items-center gap-1">
<span class="w-3 h-3 border border-white/40 border-t-white rounded-full animate-spin"></span>
保存中
</span>
</button>
<button @click="cancelEditOutline()"
class="px-3 py-1.5 text-xs border border-gray-200 text-gray-500 hover:bg-gray-50 rounded-lg transition">
取消
</button>
<button @click="expandOutline()"
:disabled="expandingOutline || !editOutlineText.trim()"
class="flex items-center gap-1 px-3 py-1.5 bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-medium rounded-lg transition disabled:opacity-60">
<span x-show="!expandingOutline">AI自动填充小章节</span>
<span x-show="expandingOutline" class="flex items-center gap-1">
<span class="w-3 h-3 border border-white/40 border-t-white rounded-full animate-spin"></span>
填充中
</span>
</button>
</div>
</template>
<!-- 预览模式按钮组 -->
<template x-if="!editOutline">
<div class="flex items-center gap-2">
<button @click="enterEditOutline()"
class="flex items-center gap-1 px-3 py-1.5 text-xs border border-gray-200 text-gray-600 hover:bg-gray-50 rounded-lg transition">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
编辑大纲
</button>
<button @click="activeTab='content'"
class="flex items-center gap-1 text-xs text-indigo-600 hover:underline font-medium">
下一步:生成内容 →
</button>
</div>
</template>
</div>
</div>
<!-- 填小目前:页数/不限制 在「解析」顶栏篇幅区配置 -->
<div
x-show="editOutline && project.parse_status === 'done'"
class="mb-3 p-3 bg-emerald-50 border border-emerald-200 rounded-xl"
>
<p class="text-xs font-semibold text-emerald-900">「AI 自动填充小章节」与目标页</p>
<p class="text-xs text-emerald-800/95 mt-1 leading-relaxed" x-text="subchapterCountHintText()"></p>
<button type="button" @click="activeTab = 'parse'" class="mt-1.5 text-xs text-emerald-700 font-medium hover:underline">前往「步骤1 解析」修改篇幅与「不限制小章节」</button>
</div>
<!-- 预览模式:章节树 -->
<div x-show="!editOutline" class="bg-gray-50 rounded-xl p-4 max-h-96 overflow-y-auto">
<template x-for="s in sections" :key="s.id">
<div class="flex items-center gap-2 py-0.5" :style="'padding-left:' + (s.level-1)*20 + 'px'">
<span class="text-gray-400 text-xs" x-text="s.level <= 2 ? '▸' : '·'"></span>
<span class="text-sm" :class="s.level===1 ? 'font-semibold text-gray-800' : s.level===2 ? 'font-medium text-gray-700' : 'text-gray-600'"
x-text="s.title"></span>
</div>
</template>
</div>
<!-- 编辑模式:原始大纲文本 -->
<div x-show="editOutline">
<div class="mb-2 p-3 bg-amber-50 border border-amber-200 rounded-xl text-xs text-amber-700 flex items-start gap-2">
<svg class="w-4 h-4 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<span>保存后将按新大纲重新划分章节,<b>已生成的章节正文内容将被清除</b>需重新生成。编辑时请保持层级编号格式一、1.1、1.1.1…)不变。</span>
</div>
<textarea x-model="editOutlineText" @input="outlineDirty=true" rows="20"
class="w-full px-4 py-3 border border-indigo-300 rounded-xl text-sm leading-6 focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-y font-mono"
placeholder="在此编辑大纲文本,每行一个章节项..."></textarea>
<p class="text-xs text-gray-400 mt-1">格式示例:第一行为标书名称,章节用"一、""1.1""1.1.1""1.1.1.1"等格式</p>
</div>
</div>
</div>
</div>
<!-- ═══ TAB 3内容生成 ═══ -->
<div x-show="activeTab==='content'" class="space-y-5">
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
<div class="flex items-start justify-between mb-4">
<h2 class="text-base font-bold text-gray-800 flex items-center gap-2">
<span class="w-6 h-6 bg-purple-100 text-purple-600 rounded-md flex items-center justify-center text-xs font-bold">3</span>
章节内容生成
</h2>
<button @click="generateAllSections()"
:disabled="progress.running > 0"
class="flex items-center gap-2 px-4 py-2 bg-purple-600 hover:bg-purple-700 text-white text-sm font-medium rounded-lg transition disabled:opacity-60">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<span x-show="progress.running === 0">一键并发生成</span>
<span x-show="progress.running > 0" class="flex items-center gap-1.5">
<span class="w-3 h-3 border-2 border-white/40 border-t-white rounded-full animate-spin"></span>
并发生成中...
</span>
</button>
</div>
<div class="mb-5 p-3 bg-slate-50 border border-slate-200 rounded-lg text-xs text-slate-800 leading-relaxed">
<p class="font-semibold text-slate-900 mb-1">篇幅与章节数</p>
<p class="text-slate-700">
<strong>目标页数</strong>只在
<button type="button" @click="activeTab = 'parse'" class="text-indigo-600 font-medium hover:underline">步骤1 解析</button>
「上传」与「清单」之间的「篇幅目标」中配置。本页仅并发生成与查看进度。列表「共 N 节」与大纲行数相关,需并节/删行时回<strong>步骤2 大纲</strong>
</p>
</div>
<!-- 并发进度面板 -->
<div x-show="progress.total > 0" class="mb-5 p-4 rounded-xl border"
:class="progress.running > 0 ? 'bg-purple-50 border-purple-200' : 'bg-gray-50 border-gray-200'">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-3 text-sm">
<span class="font-medium text-gray-700">生成进度</span>
<span x-show="progress.running > 0"
class="flex items-center gap-1.5 text-purple-600 font-medium">
<span class="relative flex h-2.5 w-2.5">
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-purple-400 opacity-75"></span>
<span class="relative inline-flex rounded-full h-2.5 w-2.5 bg-purple-600"></span>
</span>
<span x-text="progress.running"></span> 路并发
</span>
</div>
<span class="text-sm font-bold" :class="progress.percent===100 ? 'text-green-600' : 'text-gray-700'"
x-text="progress.percent + '%'"></span>
</div>
<div class="h-2.5 bg-white rounded-full overflow-hidden shadow-inner">
<div class="h-full rounded-full transition-all duration-500"
:class="progress.running > 0 ? 'bg-gradient-to-r from-purple-500 to-indigo-500' : progress.percent===100 ? 'bg-green-500' : 'bg-purple-500'"
:style="'width:'+progress.percent+'%'"></div>
</div>
<div class="flex justify-between mt-2 text-xs text-gray-500">
<div class="flex gap-4">
<span class="flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-green-500"></span>
已完成 <b x-text="progress.done" class="text-gray-700"></b>
</span>
<span x-show="progress.running > 0" class="flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-purple-500 animate-pulse"></span>
生成中 <b x-text="progress.running" class="text-gray-700"></b>
</span>
<span x-show="progress.errors > 0" class="flex items-center gap-1">
<span class="w-2 h-2 rounded-full bg-red-400"></span>
失败 <b x-text="progress.errors" class="text-red-600"></b>
</span>
</div>
<span><b x-text="progress.total" class="text-gray-700"></b> 个章节</span>
</div>
</div>
<div x-show="sections.length === 0" class="py-8 text-center">
<p class="text-gray-400 text-sm">请先生成标书大纲</p>
<button @click="activeTab='outline'" class="mt-3 text-indigo-600 text-sm hover:underline">返回生成大纲</button>
</div>
<!-- 章节列表 -->
<div x-show="sections.length > 0" class="space-y-1">
<template x-for="s in sections" :key="s.id">
<div class="section-item flex items-center gap-3 px-3 py-2 rounded-xl hover:bg-gray-50 cursor-pointer transition"
:class="selectedSection && selectedSection.id===s.id ? 'bg-blue-50 ring-1 ring-blue-200' : ''"
@click="selectSection(s); activeTab='editor'"
:style="s.level > 1 ? 'margin-left:' + (s.level-1)*20 + 'px' : ''">
<!-- 状态 -->
<div class="w-5 h-5 flex-shrink-0 flex items-center justify-center">
<svg x-show="s.status==='done'" class="w-4.5 h-4.5 text-green-500" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"/>
</svg>
<div x-show="s.status==='generating'" class="w-4 h-4 border-2 border-purple-300 border-t-purple-600 rounded-full animate-spin"></div>
<svg x-show="s.status==='error'" class="w-4 h-4 text-red-400" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V10a1 1 0 00-1-1zm1 8a1 1 0 100-2 1 1 0 000 2z"/>
</svg>
<div x-show="!['done','generating','error'].includes(s.status)"
class="w-3.5 h-3.5 rounded-full border-2 border-gray-300"></div>
</div>
<span class="flex-1 text-sm min-w-0 truncate"
:class="s.level===1 ? 'font-semibold text-gray-800' : s.level===2 ? 'font-medium text-gray-700' : 'text-gray-600'"
x-text="s.title"></span>
<span x-show="s.has_content && s.status==='done'" class="text-xs text-green-600 bg-green-50 px-2 py-0.5 rounded-full flex-shrink-0">已生成</span>
<span x-show="!s.is_leaf" class="text-xs text-gray-400 flex-shrink-0">章节</span>
<div class="section-actions flex gap-1 flex-shrink-0">
<button x-show="s.is_leaf" @click.stop="generateSection(s.id)"
:disabled="s.status==='generating'"
class="px-2.5 py-1 text-xs bg-purple-100 text-purple-700 hover:bg-purple-200 rounded-lg transition disabled:opacity-50">
生成
</button>
<button @click.stop="selectSection(s); activeTab='editor'"
class="px-2.5 py-1 text-xs bg-gray-100 text-gray-600 hover:bg-gray-200 rounded-lg transition">
查看
</button>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- ═══ TAB 4编辑器 ═══ -->
<div x-show="activeTab==='editor'" class="space-y-4">
<div x-show="!selectedSection" class="bg-white rounded-2xl shadow-sm border border-gray-100 p-12 text-center">
<p class="text-gray-400">从左侧章节列表或内容列表中选择一个章节</p>
<button @click="activeTab='content'" class="mt-3 text-purple-600 text-sm hover:underline">查看章节列表</button>
</div>
<div x-show="selectedSection" class="bg-white rounded-2xl shadow-sm border border-gray-100">
<!-- 编辑器头部 -->
<div class="flex items-start justify-between px-6 py-4 border-b border-gray-100 gap-4">
<div class="min-w-0">
<h3 class="font-bold text-gray-900 text-base" x-text="selectedSection && selectedSection.title"></h3>
</div>
<div class="flex flex-col items-end gap-2 flex-shrink-0">
<!-- 生成模式切换(仅叶节点) -->
<div x-show="selectedSection && selectedSection.is_leaf"
class="flex rounded-lg border border-gray-200 overflow-hidden text-xs">
<button @click="switchGenMode('auto')"
:class="genMode==='auto' ? 'bg-purple-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'"
class="px-3 py-1.5 flex items-center gap-1 transition">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
自动生成
</button>
<button @click="switchGenMode('chat')"
:class="genMode==='chat' ? 'bg-indigo-600 text-white' : 'bg-white text-gray-500 hover:bg-gray-50'"
class="px-3 py-1.5 flex items-center gap-1 transition border-l border-gray-200">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-3 3v-3z"/>
</svg>
对话生成
</button>
</div>
<!-- 自动模式操作按钮 -->
<div x-show="genMode==='auto'" class="flex items-center gap-2">
<button x-show="selectedSection && selectedSection.is_leaf"
@click="generateSection(selectedSection.id)"
:disabled="selectedSection && selectedSection.status==='generating'"
class="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-purple-600 hover:bg-purple-700 text-white rounded-lg transition disabled:opacity-60">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
<span x-show="selectedSection && selectedSection.status !== 'generating'">AI 生成</span>
<span x-show="selectedSection && selectedSection.status === 'generating'">生成中...</span>
</button>
<button @click="saveContent()" x-show="editMode"
class="px-3 py-1.5 text-sm bg-green-600 hover:bg-green-700 text-white rounded-lg transition">
保存
</button>
<button @click="editMode=!editMode"
class="px-3 py-1.5 text-sm border border-gray-200 text-gray-600 hover:bg-gray-50 rounded-lg transition"
x-text="editMode ? '预览' : '编辑'"></button>
</div>
<!-- 对话模式操作按钮 -->
<div x-show="genMode==='chat'" class="flex items-center gap-2">
<button @click="chatMessages=[]; chatSectionId=null; switchGenMode('chat')"
class="px-3 py-1.5 text-sm border border-gray-200 text-gray-500 hover:bg-gray-50 rounded-lg transition flex items-center gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
清空对话
</button>
</div>
</div>
</div>
<!-- 生成中提示 -->
<div x-show="selectedSection && selectedSection.status==='generating'"
class="px-6 py-3 bg-purple-50 border-b border-purple-100 flex items-center gap-2 text-purple-700 text-sm">
<div class="w-4 h-4 border-2 border-purple-300 border-t-purple-600 rounded-full animate-spin"></div>
AI 正在生成内容,请稍候...
</div>
<!-- ── 自动生成模式内容区 ── -->
<div x-show="genMode==='auto'" class="p-6">
<!-- 章节引言(非叶节点) -->
<div x-show="sectionContent && sectionContent.intro_content" class="mb-4 p-4 bg-blue-50 rounded-xl border border-blue-100">
<p class="text-xs font-medium text-blue-600 mb-2">章节引言</p>
<p class="text-sm text-gray-700 whitespace-pre-wrap leading-7" x-text="sectionContent && sectionContent.intro_content"></p>
</div>
<!-- 编辑模式 -->
<div x-show="editMode">
<textarea x-model="editContent" rows="20"
class="w-full px-4 py-3 border border-gray-200 rounded-xl text-sm leading-7 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none font-mono"
placeholder="在此输入或编辑章节内容..."></textarea>
</div>
<!-- 预览模式 -->
<div x-show="!editMode" class="min-h-48">
<div x-show="!sectionContent || (!sectionContent.content && !sectionContent.intro_content)"
class="py-16 text-center text-gray-400">
<svg class="w-10 h-10 mx-auto mb-3 text-gray-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<p class="text-sm">暂无内容,点击"AI 生成"或切换"对话生成"模式</p>
</div>
<div x-show="sectionContent && sectionContent.content"
class="prose-content text-gray-800 leading-8"
x-text="sectionContent && sectionContent.content"></div>
</div>
</div>
<!-- ── 对话生成模式内容区 ── -->
<div x-show="genMode==='chat'" class="flex flex-col" style="height:600px">
<!-- 消息列表 -->
<div id="chatMsgList" class="flex-1 overflow-y-auto px-6 py-4 space-y-4">
<template x-for="msg in chatMessages" :key="msg.id">
<div>
<!-- 用户消息(右侧) -->
<div x-show="msg.role==='user'" class="flex justify-end">
<div class="max-w-[80%] bg-indigo-600 text-white rounded-2xl rounded-tr-sm px-4 py-3 text-sm">
<p x-text="msg.content" class="whitespace-pre-wrap leading-6"></p>
</div>
</div>
<!-- AI 消息(左侧) -->
<div x-show="msg.role==='assistant'" class="flex justify-start gap-2.5">
<div class="w-7 h-7 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center flex-shrink-0 mt-1">
<span class="text-white text-xs font-bold">AI</span>
</div>
<div class="max-w-[85%]">
<div :class="msg.isError ? 'bg-red-50 border-red-200 text-red-700' : 'bg-gray-50 border-gray-200 text-gray-800'"
class="border rounded-2xl rounded-tl-sm px-4 py-3 text-sm">
<p x-text="msg.content" class="whitespace-pre-wrap leading-7"></p>
</div>
<!-- 采用内容按钮(非欢迎语、非错误) -->
<button x-show="!msg.isWelcome && !msg.isError"
@click="applyChatContent(msg.content)"
class="mt-2 flex items-center gap-1.5 text-xs text-indigo-600 hover:text-indigo-800 font-medium transition">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
采用此内容 → 填入编辑框
</button>
</div>
</div>
</div>
</template>
<!-- 加载动画 -->
<div x-show="chatLoading" class="flex justify-start gap-2.5">
<div class="w-7 h-7 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center flex-shrink-0 mt-1">
<span class="text-white text-xs font-bold">AI</span>
</div>
<div class="bg-gray-50 border border-gray-200 rounded-2xl rounded-tl-sm px-5 py-3.5">
<div class="flex gap-1.5 items-center">
<span class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay:0ms"></span>
<span class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay:150ms"></span>
<span class="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style="animation-delay:300ms"></span>
</div>
</div>
</div>
</div>
<!-- 输入区 -->
<div class="border-t border-gray-100 px-6 py-4 bg-gray-50 rounded-b-2xl">
<div class="flex gap-3 items-end">
<textarea x-model="chatInput"
@keydown.enter.prevent="if(!$event.shiftKey){ sendChatMessage() }"
:disabled="chatLoading"
rows="3"
placeholder="描述您的需求,例如:请生成该章节完整内容,重点强调质量管控措施并给出具体标准编号… (Enter 发送Shift+Enter 换行)"
class="flex-1 px-4 py-3 border border-gray-200 rounded-xl text-sm resize-none focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white leading-6 disabled:opacity-60"></textarea>
<button @click="sendChatMessage()"
:disabled="chatLoading || !chatInput.trim()"
class="flex-shrink-0 px-5 py-3 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-xl transition disabled:opacity-50 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8"/>
</svg>
发送
</button>
</div>
<p class="text-xs text-gray-400 mt-2">点击 AI 回复下方的「采用此内容 → 填入编辑框」将内容写入编辑器,再点「保存」完成。</p>
</div>
</div>
</div>
</div>
<!-- ═══ TAB 5合规检查 ═══ -->
<div x-show="activeTab==='check'" class="space-y-4">
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-6">
<h2 class="text-base font-bold text-gray-800 mb-2 flex items-center gap-2">
<span class="w-6 h-6 bg-orange-100 text-orange-600 rounded-md flex items-center justify-center text-xs font-bold">4</span>
合规性检查
</h2>
<p class="text-sm text-gray-500 mb-4">AI 将对照招标要求检查标书内容的覆盖情况,给出改进建议。</p>
<button @click="runCheck()" :disabled="checking"
class="flex items-center gap-2 px-5 py-2.5 bg-orange-500 hover:bg-orange-600 text-white text-sm font-medium rounded-xl transition disabled:opacity-60">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<span x-show="!checking">开始合规检查</span>
<span x-show="checking" class="flex items-center gap-1.5">
<span class="w-3.5 h-3.5 border-2 border-white/40 border-t-white rounded-full animate-spin"></span>
检查中...
</span>
</button>
<!-- 检查结果:用 x-if 而非 x-shownull 时完全不渲染内部绑定 -->
<template x-if="checkResult">
<div class="mt-6 space-y-4">
<!-- 总分 -->
<div class="flex items-center gap-4 p-4 bg-gradient-to-r from-green-50 to-emerald-50 rounded-xl border border-green-100">
<div class="w-16 h-16 rounded-full border-4 flex items-center justify-center flex-shrink-0"
:class="(checkResult.overall_score||0) >= 80 ? 'border-green-400' : (checkResult.overall_score||0) >= 60 ? 'border-yellow-400' : 'border-red-400'">
<span class="text-xl font-bold" x-text="checkResult.overall_score || '--'"></span>
</div>
<div>
<p class="font-bold text-gray-800 text-lg" x-text="checkResult.status || '—'"></p>
<p class="text-sm text-gray-500">综合评分</p>
</div>
</div>
<!-- 检查项目 -->
<template x-if="checkResult.items && checkResult.items.length">
<div class="space-y-2">
<h4 class="font-medium text-gray-700 text-sm">检查项目</h4>
<template x-for="item in checkResult.items" :key="item.requirement">
<div class="flex items-start gap-3 p-3 rounded-xl border"
:class="item.covered ? 'bg-green-50 border-green-100' : 'bg-red-50 border-red-100'">
<svg x-show="item.covered" class="w-4 h-4 text-green-500 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"/>
</svg>
<svg x-show="!item.covered" class="w-4 h-4 text-red-400 flex-shrink-0 mt-0.5" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V10a1 1 0 00-1-1zm1 8a1 1 0 100-2 1 1 0 000 2z"/>
</svg>
<div>
<p class="text-sm font-medium text-gray-800" x-text="item.requirement"></p>
<p class="text-xs text-gray-500 mt-0.5" x-text="item.note"></p>
</div>
</div>
</template>
</div>
</template>
<!-- 缺失要点 -->
<template x-if="checkResult.missing_points && checkResult.missing_points.length">
<div class="p-4 bg-yellow-50 rounded-xl border border-yellow-100">
<h4 class="font-medium text-yellow-800 text-sm mb-2">⚠️ 未覆盖要点</h4>
<ul class="space-y-1">
<template x-for="pt in checkResult.missing_points" :key="pt">
<li class="text-sm text-yellow-700 flex items-start gap-2">
<span class="mt-1 flex-shrink-0">·</span><span x-text="pt"></span>
</li>
</template>
</ul>
</div>
</template>
<!-- 改进建议 -->
<template x-if="checkResult.suggestions && checkResult.suggestions.length">
<div class="p-4 bg-blue-50 rounded-xl border border-blue-100">
<h4 class="font-medium text-blue-800 text-sm mb-2">💡 改进建议</h4>
<ul class="space-y-1">
<template x-for="sg in checkResult.suggestions" :key="sg">
<li class="text-sm text-blue-700 flex items-start gap-2">
<span class="mt-1 flex-shrink-0">·</span><span x-text="sg"></span>
</li>
</template>
</ul>
</div>
</template>
</div>
</template>
</div>
<!-- 技术暗标 HTML 格式清标(与 AI 合规检查独立;建议 Word 另存为网页等含内联样式的 HTML -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-6 mt-6">
<h2 class="text-base font-bold text-gray-800 mb-2 flex items-center gap-2">
<span class="w-6 h-6 bg-teal-100 text-teal-700 rounded-md flex items-center justify-center text-xs font-bold"></span>
技术暗标格式清标
</h2>
<p class="text-sm text-gray-500 mb-3">对导出的技术暗标 HTML 做版式规则检查(身份信息、标题/正文、目录、图表位置、颜色、页边距等),不调用大模型。请粘贴完整 HTML 或选择本地 .html 文件。</p>
<p class="text-xs text-amber-700 bg-amber-50 border border-amber-100 rounded-lg px-3 py-2 mb-4">说明:检查依赖内联 <code class="bg-amber-100 px-0.5 rounded">style</code>;从 Word 另存为「网页」或含完整样式的 HTML 效果最佳;纯标签无样式时部分项易判为不通过。</p>
<label class="flex items-center gap-2 cursor-pointer mb-4">
<input type="checkbox" x-model="darkBidCheckEnabled" class="rounded border-gray-300 text-teal-600 focus:ring-teal-500" />
<span class="text-sm font-medium text-gray-800">启用清暗标格式检查</span>
</label>
<div x-show="darkBidCheckEnabled" x-transition class="space-y-4">
<textarea x-model="darkBidHtml" rows="8" placeholder="在此粘贴技术暗标完整 HTML 源码…"
class="w-full px-4 py-3 border border-teal-200 rounded-xl text-xs font-mono leading-relaxed focus:outline-none focus:ring-2 focus:ring-teal-500 resize-y"></textarea>
<div class="flex flex-wrap items-center gap-3">
<label class="text-sm text-gray-600 cursor-pointer">
<input type="file" accept=".html,.htm" class="sr-only" @change="onDarkBidFile($event)">
<span class="px-3 py-1.5 border border-gray-300 rounded-lg hover:bg-gray-50">选择 .html 文件</span>
</label>
<span x-show="darkBidFileError" class="text-xs text-red-500" x-text="darkBidFileError"></span>
</div>
<button type="button" @click="runDarkBidCheck()" :disabled="darkBidChecking"
class="flex items-center gap-2 px-5 py-2.5 bg-teal-600 hover:bg-teal-700 text-white text-sm font-medium rounded-xl transition disabled:opacity-60">
<span x-show="!darkBidChecking" class="flex items-center gap-1.5">运行清标</span>
<span x-show="darkBidChecking" class="flex items-center gap-1.5">
<span class="w-3.5 h-3.5 border-2 border-white/40 border-t-white rounded-full animate-spin"></span>
清标中…
</span>
</button>
</div>
<template x-if="darkBidCheckResult && darkBidCheckResult.details">
<div class="mt-6 space-y-4 p-4 rounded-xl border"
:class="darkBidCheckResult.overall ? 'bg-emerald-50 border-emerald-200' : 'bg-rose-50 border-rose-200'">
<p class="font-bold text-sm" :class="darkBidCheckResult.overall ? 'text-emerald-800' : 'text-rose-800'">
<span x-text="darkBidCheckResult.overall ? '总评:通过' : '总评:未通过'"></span>
</p>
<ul class="space-y-2 text-sm">
<template x-for="row in darkBidCheckResult.details" :key="row.rule">
<li class="flex items-start gap-2" :class="row.passed ? 'text-gray-800' : 'text-rose-700'">
<span x-text="row.passed ? '✓' : '✗'"></span>
<span><b x-text="row.rule + ''"></b><span x-text="row.message"></span></li>
</template>
</ul>
<template x-if="darkBidCheckResult.violations && darkBidCheckResult.violations.length">
<div class="mt-3 text-xs text-gray-600 space-y-2">
<p class="font-medium text-gray-700">违规模块(节选)</p>
<template x-for="v in darkBidCheckResult.violations" :key="v.rule + v.message">
<div class="pl-2 border-l-2 border-rose-300">
<p class="text-rose-800 font-medium" x-text="v.rule"></p>
<p class="text-gray-600" x-text="v.message"></p>
<ul class="list-disc pl-4 mt-1 break-all" x-show="v.elements && v.elements.length">
<template x-for="(el, idx) in v.elements" :key="idx">
<li x-text="(el && el.length > 180) ? el.slice(0, 180) + '…' : el"></li>
</template>
</ul>
</div>
</template>
</div>
</template>
</div>
</template>
</div>
</div>
<!-- ═══ TAB 6知识库 ═══ -->
<div x-show="activeTab==='knowledge'" class="space-y-5">
<!-- 状态栏 -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-5">
<div class="flex items-center justify-between flex-wrap gap-3">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-teal-50 flex items-center justify-center flex-shrink-0">
<svg class="w-5 h-5 text-teal-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg>
</div>
<div>
<h2 class="text-base font-bold text-gray-800">企业知识库</h2>
<p class="text-xs text-gray-400 mt-0.5">上传历史标书AI 生成时自动检索企业优势内容</p>
</div>
</div>
<!-- 检索模式 chip -->
<div x-show="knowledgeStatus" class="flex items-center gap-2 flex-wrap">
<!-- 向量语义检索 -->
<span x-show="knowledgeStatus && knowledgeStatus.search_mode === 'vector'"
class="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-full bg-teal-50 text-teal-700 border border-teal-200">
<span class="w-2 h-2 rounded-full bg-teal-400 inline-block"></span>
语义向量检索 · <span x-text="(knowledgeStatus && knowledgeStatus.doc_count) || 0"></span> 个文本块
</span>
<!-- 关键词降级检索 -->
<span x-show="knowledgeStatus && knowledgeStatus.search_mode === 'keyword'"
class="inline-flex items-center gap-1.5 text-xs font-medium px-3 py-1.5 rounded-full bg-amber-50 text-amber-700 border border-amber-200">
<span class="w-2 h-2 rounded-full bg-amber-400 inline-block"></span>
关键词检索模式 · <span x-text="(knowledgeStatus && knowledgeStatus.doc_count) || 0"></span> 个文本块
</span>
</div>
</div>
<!-- 关键词模式说明DeepSeek/Ollama 无 Embedding API 时提示) -->
<div x-show="knowledgeStatus && knowledgeStatus.search_mode === 'keyword' && knowledgeFiles.length > 0"
class="mt-4 p-4 bg-amber-50 border border-amber-200 rounded-xl text-sm text-amber-800">
<p class="font-medium mb-1">💡 当前使用关键词检索</p>
<p class="text-xs text-amber-700">DeepSeek / Ollama 暂不提供 Embedding API知识库将以关键词匹配方式检索相关内容。
切换为 Qwen 或 OpenAI 模型(在首页 AI 配置中设置)可启用更精准的语义向量检索。</p>
</div>
</div>
<!-- 上传区 -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-5">
<h3 class="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2">
<svg class="w-4 h-4 text-teal-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
添加知识文档
</h3>
<!-- 拖拽上传区 -->
<label
@dragover.prevent="knowledgeDragover=true"
@dragleave="knowledgeDragover=false"
@drop.prevent="onKnowledgeDrop($event)"
:class="knowledgeDragover ? 'border-teal-400 bg-teal-50' : 'border-gray-200 bg-gray-50 hover:border-teal-300 hover:bg-teal-50/50'"
class="flex flex-col items-center justify-center border-2 border-dashed rounded-xl p-8 cursor-pointer transition-all">
<input type="file" class="hidden" accept=".pdf,.doc,.docx" @change="onKnowledgeSelect($event)">
<div x-show="!knowledgeUploading && knowledgeProcessing.length === 0">
<svg class="w-10 h-10 text-teal-300 mx-auto mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 13h6m-3-3v6m5 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
<p class="text-sm font-medium text-gray-600 text-center">拖拽文件到此处,或点击选择</p>
<p class="text-xs text-gray-400 mt-1 text-center">支持 PDF、DOC、DOCX 格式</p>
</div>
<div x-show="knowledgeUploading" class="flex flex-col items-center">
<div class="w-8 h-8 border-3 border-teal-200 border-t-teal-500 rounded-full animate-spin mb-3"></div>
<p class="text-sm text-teal-600">正在上传文件...</p>
</div>
<div x-show="!knowledgeUploading && knowledgeProcessing.length > 0" class="flex flex-col items-center">
<div class="w-8 h-8 border-3 border-teal-200 border-t-teal-500 rounded-full animate-spin mb-3"></div>
<p class="text-sm text-teal-600">AI 正在向量化文档,请稍候...</p>
<p class="text-xs text-gray-400 mt-1" x-text="'处理中:' + knowledgeProcessing.join(', ')"></p>
</div>
</label>
<p class="text-xs text-gray-400 mt-3">
推荐上传:历史技术方案、同类项目标书、企业资质简介、施工工法说明等。<br>
上传后 AI 在生成章节内容时将自动检索相关片段作为写作参考。
</p>
</div>
<!-- 文件列表 -->
<div class="bg-white rounded-2xl shadow-sm border border-gray-100 p-5">
<h3 class="text-sm font-semibold text-gray-700 mb-4 flex items-center gap-2">
<svg class="w-4 h-4 text-teal-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
已上传文件
<span class="ml-auto text-xs font-normal text-gray-400" x-text="knowledgeFiles.length + ' 个文件'"></span>
</h3>
<!-- 处理中的文件(还未出现在列表里) -->
<template x-for="fn in knowledgeProcessing.filter(fn => !knowledgeFiles.some(f => f.name === fn))" :key="fn">
<div class="flex items-center gap-3 px-4 py-3 mb-2 rounded-xl border border-teal-100 bg-teal-50">
<div class="w-4 h-4 border-2 border-teal-300 border-t-teal-600 rounded-full animate-spin flex-shrink-0"></div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-teal-700 truncate" x-text="fn"></p>
<p class="text-xs text-teal-500">正在向量化入库...</p>
</div>
</div>
</template>
<!-- 已完成的文件 -->
<template x-if="knowledgeFiles.length > 0">
<div class="space-y-2">
<template x-for="file in knowledgeFiles" :key="file.name">
<div class="flex items-center gap-3 px-4 py-3 rounded-xl border border-gray-100 bg-gray-50 hover:bg-gray-100 transition group">
<div class="w-8 h-8 rounded-lg bg-teal-100 flex items-center justify-center flex-shrink-0">
<svg class="w-4 h-4 text-teal-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-800 truncate" x-text="file.name"></p>
<p class="text-xs text-gray-400" x-text="file.chunks + ' 个文本块 · ' + (file.added_at || '').slice(0, 10)"></p>
</div>
<button @click.stop="deleteKnowledgeFile(file.name)"
class="opacity-0 group-hover:opacity-100 transition flex-shrink-0 p-1.5 text-gray-400 hover:text-red-500 hover:bg-red-50 rounded-lg">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
</template>
</div>
</template>
<!-- 空状态 -->
<div x-show="knowledgeFiles.length === 0 && knowledgeProcessing.length === 0"
class="py-10 flex flex-col items-center text-gray-300">
<svg class="w-12 h-12 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
</svg>
<p class="text-sm">知识库暂无文件</p>
<p class="text-xs mt-1">上传历史标书后AI 生成内容时将自动引用</p>
</div>
</div>
<!-- 使用说明 -->
<div class="bg-gradient-to-br from-teal-50 to-cyan-50 rounded-2xl border border-teal-100 p-5">
<h3 class="text-sm font-semibold text-teal-800 mb-3 flex items-center gap-2">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
使用说明
</h3>
<ul class="space-y-2 text-xs text-teal-700">
<li class="flex items-start gap-2">
<span class="flex-shrink-0 w-4 h-4 rounded-full bg-teal-200 text-teal-800 flex items-center justify-center font-bold text-[10px] mt-0.5">1</span>
<span>上传企业历史技术标书、施工方案、资质简介等文档(支持 PDF/DOC/DOCX</span>
</li>
<li class="flex items-start gap-2">
<span class="flex-shrink-0 w-4 h-4 rounded-full bg-teal-200 text-teal-800 flex items-center justify-center font-bold text-[10px] mt-0.5">2</span>
<span>系统自动将文档切分并向量化入库(首次入库需等待 AI 处理完成)</span>
</li>
<li class="flex items-start gap-2">
<span class="flex-shrink-0 w-4 h-4 rounded-full bg-teal-200 text-teal-800 flex items-center justify-center font-bold text-[10px] mt-0.5">3</span>
<span>生成章节内容(步骤 3系统将自动检索知识库中最相关的段落供 AI 参考写作</span>
</li>
<li class="flex items-start gap-2">
<span class="flex-shrink-0 w-4 h-4 rounded-full bg-teal-200 text-teal-800 flex items-center justify-center font-bold text-[10px] mt-0.5">4</span>
<span>知识库为全局共享,对所有项目均有效;可随时添加或删除文档</span>
</li>
</ul>
</div>
</div>
</main>
</div>
<!-- 底部导航(移动端) -->
<nav class="fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 flex md:hidden z-40">
<template x-for="(step, i) in steps" :key="i">
<button @click="activeTab=step.key" class="flex-1 py-3 flex flex-col items-center gap-0.5"
:class="activeTab===step.key ? 'text-blue-600' : 'text-gray-400'">
<span class="text-lg" x-text="step.icon"></span>
<span class="text-xs" x-text="step.label"></span>
</button>
</template>
</nav>
<script>
const PROJECT_ID = {{ project.id }}
function projectApp(projectId) {
return {
projectId,
activeTab: 'parse',
project: {},
sections: [],
selectedSection: null,
sectionContent: null,
editMode: false,
editContent: '',
progress: { total: 0, done: 0, running: 0, errors: 0, percent: 0 },
checkResult: null,
checking: false,
darkBidCheckEnabled: false,
darkBidHtml: '',
darkBidChecking: false,
darkBidCheckResult: null,
darkBidFileError: '',
exporting: false,
parsing: false,
uploading: false,
dragover: false,
knowledgeFiles: [],
knowledgeStatus: null,
knowledgeUploading: false,
knowledgeDragover: false,
knowledgeProcessing: [],
knowledgePollTimer: null,
pollTimer: null,
editSummary: false,
editSummaryText: '',
editRating: false,
editRatingText: '',
savingTenderData: false,
uploadingBoq: false,
parsingBoq: false,
editBoq: false,
editBoqText: '',
tenderKind: 'engineering',
savingBoq: false,
showBoqAnalysisDetail: false,
editOutline: false,
editOutlineText: '',
savingOutline: false,
expandingOutline: false,
outlineDirty: false,
genMode: 'auto',
chatMessages: [],
chatInput: '',
chatLoading: false,
chatSectionId: null,
showAnonPanel: false,
anonEnabled: false,
anonText: '',
savingAnon: false,
showDiagramPanel: false,
enableFigure: false,
enableTable: false,
savingDiagram: false,
targetPages: 0,
customTargetPages: 150,
/** 为 true 时 expand-outline 不限制小章节;否则在页数为 0 时由服务端回退 100 页/约 7086 条 */
noSubchapterLimit: false,
savingPageSettings: false,
steps: [
{ key: 'parse', label: '解析', icon: '📄' },
{ key: 'outline', label: '大纲', icon: '📋' },
{ key: 'content', label: '生成', icon: '✍️' },
{ key: 'editor', label: '编辑', icon: '📝' },
{ key: 'check', label: '检查', icon: '✅' },
{ key: 'knowledge', label: '知识库', icon: '🗂️' },
],
async init() {
await this.loadProject(true)
await this.loadPageSettings()
await this.loadSections()
await this.loadKnowledge()
await this.loadKnowledgeStatus()
this.startPolling()
},
boqStructuredItemCount() {
const a = this.project.boq_analysis
if (!a || !a.categories) return 0
return a.categories.reduce((s, c) => s + ((c.items && c.items.length) || 0), 0)
},
boqStructuredCatCount() {
const a = this.project.boq_analysis
if (!a || !a.categories) return 0
return a.categories.length
},
async loadProject(isInitial = false) {
const localOutlineDraft = this.editOutline && this.outlineDirty ? this.editOutlineText : ''
const res = await fetch(`/api/projects/${this.projectId}`)
this.project = await res.json()
if (localOutlineDraft) {
// 编辑未保存时,保护草稿不被轮询数据覆盖
this.editOutlineText = localOutlineDraft
}
this.tenderKind = this.project.tender_kind || 'engineering'
this.parsing = this.project.parse_status === 'parsing'
// 只在首次加载时同步用户可编辑的设置状态,轮询更新时不覆盖,
// 避免用户改动开关后被下一次 poll 自动还原
if (isInitial) {
const anon = this.project.anon_requirements || ''
this.anonText = anon
this.anonEnabled = anon.trim().length > 0
this.enableFigure = !!this.project.enable_figure
this.enableTable = !!this.project.enable_table
}
// BOQ 解析状态:轮询中同步(完成/出错时停止 loading
if (this.parsingBoq && this.project.boq_status !== 'parsing') {
this.parsingBoq = false
}
},
async loadSections() {
const res = await fetch(`/api/projects/${this.projectId}/sections`)
const data = await res.json()
this.sections = data.sections || []
await this.updateProgress()
},
async loadKnowledge() {
const res = await fetch('/api/knowledge/files')
const data = await res.json()
this.knowledgeFiles = data.files || []
},
async loadKnowledgeStatus() {
try {
const res = await fetch('/api/knowledge/status')
this.knowledgeStatus = await res.json()
this.knowledgeProcessing = (this.knowledgeStatus && this.knowledgeStatus.processing) || []
} catch (e) {
this.knowledgeStatus = { available: false, reason: '无法连接服务器' }
}
},
// 知识库:拖拽上传
onKnowledgeDrop(event) {
this.knowledgeDragover = false
const file = event.dataTransfer.files[0]
if (file) this.uploadKnowledgeFile(file)
},
onKnowledgeSelect(event) {
const file = event.target.files[0]
if (file) this.uploadKnowledgeFile(file)
event.target.value = ''
},
async uploadKnowledgeFile(file) {
this.knowledgeUploading = true
const fd = new FormData()
fd.append('file', file)
try {
const res = await fetch('/api/knowledge/upload', { method: 'POST', body: fd })
const data = await res.json()
if (data.success || data.queued) {
// 上传后台处理中,轮询直到文件出现在列表
this._startKnowledgePoll(data.filename)
} else {
alert('上传失败: ' + (data.error || '未知错误'))
}
} catch (e) {
alert('上传失败: ' + e.message)
} finally {
this.knowledgeUploading = false
}
},
_startKnowledgePoll(filename) {
if (this.knowledgePollTimer) clearInterval(this.knowledgePollTimer)
this.knowledgeProcessing = [...new Set([...this.knowledgeProcessing, filename])]
this.knowledgePollTimer = setInterval(async () => {
await this.loadKnowledge()
await this.loadKnowledgeStatus()
// 当处理中的文件出现在列表里,视为完成
const done = this.knowledgeProcessing.filter(
fn => this.knowledgeFiles.some(f => f.name === fn)
)
if (done.length > 0) {
this.knowledgeProcessing = this.knowledgeProcessing.filter(fn => !done.includes(fn))
}
if (this.knowledgeProcessing.length === 0) {
clearInterval(this.knowledgePollTimer)
this.knowledgePollTimer = null
}
}, 2000)
},
async deleteKnowledgeFile(fileName) {
if (!confirm(`确定删除知识库文件「${fileName}」?此操作不可恢复。`)) return
const res = await fetch('/api/knowledge/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_name: fileName })
})
const data = await res.json()
if (data.success) {
await this.loadKnowledge()
await this.loadKnowledgeStatus()
} else {
alert('删除失败: ' + (data.error || ''))
}
},
async updateProgress() {
if (this.sections.length === 0) return
const res = await fetch(`/api/projects/${this.projectId}/section-progress`)
this.progress = await res.json()
},
async loadPageSettings() {
try {
const ptp = parseInt(this.project && this.project.target_pages, 10)
if (!Number.isNaN(ptp) && ptp > 0) {
this.targetPages = ptp
this.customTargetPages = ptp
return
}
const res = await fetch('/api/config')
const cfg = await res.json()
this.targetPages = parseInt(cfg.target_pages || 0, 10) || 0
this.customTargetPages = this.targetPages > 0 ? this.targetPages : 150
} catch (e) {
// 忽略读取失败,沿用默认值
}
},
setPagePreset(pages) {
this.targetPages = pages
if (pages > 0) this.customTargetPages = pages
},
applyCustomPages() {
const n = parseInt(this.customTargetPages || 0, 10)
if (!n || n < 1) {
alert('请输入有效页数')
return
}
this.targetPages = n
},
/** 与 utils.volume_chapters 中全标小章节行合计公式一致,仅作界面说明(实际随机在后端) */
subchapterCountHintText() {
if (this.noSubchapterLimit) {
return '已勾选「不限制小章节」:本次将不按页数限幅,章节数可能达到数百。'
}
const p = parseInt(this.targetPages || 0, 10) || 0
if (p <= 0) {
return '未选目标页时:填充仍按「默认 100 页」估算小章节(全标小章节行约 7086避免一次生成数百节。请勾选「不限制」以关闭。'
}
const m = 0.67
const b = 11
const nBase = m * p + b
const mid = Math.round(nBase)
const lo = Math.round(nBase * 0.9)
const hi = Math.round(nBase * 1.1)
return `全标子章节行合计约 ${lo}${hi} 条(中值约 ${mid},由 0.67×页+11 再经 ±10% 随机后取整,并分配到各主章)。`
},
/**
* @param { { silent?: boolean } } [options] 注JSDoc 中双花括号需拆开写,避免被 Jinja 当作模板
* @returns {Promise<boolean>} 成功为 true
*/
async savePageSettings(options) {
const silent = options && options.silent
if (!silent) this.savingPageSettings = true
try {
const pages = parseInt(this.targetPages || 0, 10) || 0
const res = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_pages: pages })
})
const data = await res.json()
if (!data.success) {
if (silent) console.error('savePageSettings', data.error || '')
else alert('保存失败: ' + (data.error || ''))
return false
}
if (!silent) {
alert('页数已保存同步全局并写入本标书。在「步骤1 解析」→「篇幅目标」可随时修改。')
}
const r2 = await fetch(`/api/projects/${this.projectId}/tender-data`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_pages: pages })
})
const d2 = await r2.json()
if (!r2.ok || d2.error) {
const err = (d2 && d2.error) || r2.statusText || '请求失败'
if (silent) console.error('target_pages 写入项目失败', err)
else alert('项目级页数保存失败: ' + err)
return false
}
if (this.project) this.project.target_pages = pages
return true
} catch (e) {
if (silent) console.error('savePageSettings', e)
else alert('保存失败: ' + e.message)
return false
} finally {
if (!silent) this.savingPageSettings = false
}
},
startPolling() {
const poll = async () => {
const prevParse = this.project.parse_status
const prevOutline = this.project.outline_status
await this.loadProject()
await this.loadSections()
if (prevParse === 'parsing' && this.project.parse_status === 'done') {
this.parsing = false
}
if (prevOutline === 'outline_generating' && this.project.outline_status === 'outline_done') {
this.activeTab = 'content'
}
if (this.selectedSection) {
const updated = this.sections.find(s => s.id === this.selectedSection.id)
if (updated) {
const wasGenerating = this.selectedSection.status === 'generating'
this.selectedSection = updated
if (wasGenerating && updated.status === 'done') {
await this.loadSectionContent(updated.id)
}
}
}
// Adaptive interval: fast when busy, slow when idle
const busy = this.progress.running > 0 || this.parsing || this.project.outline_status === 'outline_generating'
const interval = busy ? 1500 : 5000
this.pollTimer = setTimeout(poll, interval)
}
this.pollTimer = setTimeout(poll, 2000)
},
// ── 文件操作 ──
onDrop(event) {
this.dragover = false
const file = event.dataTransfer.files[0]
if (file) this.uploadFile(file)
},
onFileSelect(event) {
const file = event.target.files[0]
if (file) this.uploadFile(file)
},
async uploadFile(file) {
this.uploading = true
const fd = new FormData()
fd.append('file', file)
const res = await fetch(`/api/projects/${this.projectId}/upload`, { method: 'POST', body: fd })
const data = await res.json()
this.uploading = false
if (data.success) {
this.project.file_name = data.file_name
this.project.parse_status = 'uploaded'
} else {
alert('上传失败: ' + (data.error || ''))
}
},
async parseTender() {
this.parsing = true
this.project.parse_status = 'parsing'
const res = await fetch(`/api/projects/${this.projectId}/parse`, { method: 'POST' })
const data = await res.json()
if (!data.success) {
this.parsing = false
alert('启动解析失败: ' + (data.error || ''))
}
},
// ── 保存手动编辑的解析结果 ──
async saveTenderData(field) {
this.savingTenderData = true
const payload = {}
if (field === 'summary') {
payload.summary = this.editSummaryText
} else if (field === 'rating') {
payload.rating_requirements = this.editRatingText
} else if (field === 'kind') {
payload.tender_kind = this.tenderKind
}
try {
const res = await fetch(`/api/projects/${this.projectId}/tender-data`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
})
const data = await res.json()
if (data.success) {
if (field === 'summary') {
this.project.summary = this.editSummaryText
this.editSummary = false
} else if (field === 'rating') {
this.project.rating_requirements = this.editRatingText
this.editRating = false
} else if (field === 'kind') {
this.project.tender_kind = this.tenderKind
}
} else {
alert('保存失败: ' + (data.error || ''))
}
} catch (e) {
alert('保存失败: ' + e.message)
} finally {
this.savingTenderData = false
}
},
// ── 工程量清单 ──
async uploadBoqFile(file) {
this.uploadingBoq = true
const fd = new FormData()
fd.append('file', file)
try {
const res = await fetch(`/api/projects/${this.projectId}/upload-boq`, { method: 'POST', body: fd })
const data = await res.json()
if (data.success) {
this.project.boq_file_name = data.file_name
this.project.boq_status = 'uploaded'
} else {
alert('上传失败: ' + (data.error || ''))
}
} catch (e) {
alert('上传失败: ' + e.message)
} finally {
this.uploadingBoq = false
}
},
async parseBoq() {
this.parsingBoq = true
this.project.boq_status = 'parsing'
try {
const res = await fetch(`/api/projects/${this.projectId}/parse-boq`, { method: 'POST' })
const data = await res.json()
if (!data.success) alert('解析启动失败: ' + (data.error || ''))
} catch (e) {
alert('解析失败: ' + e.message)
this.parsingBoq = false
}
},
async saveBoqSummary() {
this.savingBoq = true
try {
await fetch(`/api/projects/${this.projectId}/boq`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ boq_summary: this.editBoqText })
})
this.project.boq_summary = this.editBoqText
this.editBoq = false
} catch (e) {
alert('保存失败: ' + e.message)
} finally {
this.savingBoq = false
}
},
// ── 保存手动编辑的大纲 ──
async saveOutline() {
if (!this.editOutlineText.trim()) return
const hasDone = this.sections.some(s => s.status === 'done')
if (hasDone && !confirm('已有章节内容生成完成,保存新大纲后这些内容将被清除,确定继续?')) return
this.savingOutline = true
try {
const res = await fetch(`/api/projects/${this.projectId}/outline`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ outline: this.editOutlineText })
})
const data = await res.json()
if (data.success) {
// 用后端重排序号后的规范文本更新本地,下次打开编辑器显示正确编号
this.project.outline = data.normalized_outline || this.editOutlineText
this.editOutlineText = this.project.outline
this.outlineDirty = false
this.editOutline = false
await this.loadProject()
await this.loadSections()
alert(`大纲已保存并用于生成(章节数:${data.section_count || this.sections.length},持久化长度:${data.persisted_outline_len || this.project.outline.length}`)
} else {
alert('保存失败: ' + (data.error || ''))
}
} catch (e) {
alert('保存失败: ' + e.message)
} finally {
this.savingOutline = false
}
},
// ── 大纲生成 ──
async generateOutline() {
const hasExistingOutline = !!(this.project.outline && this.project.outline.trim())
let force = false
if (hasExistingOutline) {
if (!confirm('当前项目已有大纲,重新生成会覆盖现有大纲。是否继续覆盖?')) return
force = true
}
this.project.outline_status = 'outline_generating'
try {
const res = await fetch(`/api/projects/${this.projectId}/generate-outline`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ force })
})
let data = {}
try {
data = await res.json()
} catch (e) {
await this.loadProject()
alert('服务器响应异常,请重试。')
return
}
if (!res.ok) {
await this.loadProject()
alert('启动失败: ' + (data.error || res.statusText || 'HTTP ' + res.status))
return
}
if (!data.success) {
await this.loadProject()
alert('启动失败: ' + (data.error || ''))
return
}
await this.loadProject()
await this.loadSections()
} catch (e) {
await this.loadProject()
alert('启动失败: ' + (e.message || String(e)))
}
},
enterEditOutline() {
this.editOutline = true
this.editOutlineText = this.project.outline || ''
this.outlineDirty = false
},
cancelEditOutline() {
this.editOutline = false
this.editOutlineText = this.project.outline || ''
this.outlineDirty = false
},
async expandOutline() {
if (!this.editOutlineText.trim()) return
this.expandingOutline = true
try {
const saved = await this.savePageSettings({ silent: true })
if (saved === false) {
alert('页数设置未能保存到服务器,请检查网络后重试。')
return
}
const res = await fetch(`/api/projects/${this.projectId}/expand-outline`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
outline: this.editOutlineText,
target_pages: parseInt(this.targetPages, 10) || 0,
no_subchapter_limit: !!this.noSubchapterLimit
})
})
const data = await res.json()
if (data.success) {
const normalized = data.normalized_outline || data.expanded_outline || this.editOutlineText
this.project.outline = normalized
this.editOutlineText = normalized
this.outlineDirty = false
await this.loadProject()
await this.loadSections()
const u = (data.used_target_pages !== undefined && data.used_target_pages !== null)
? (data.no_subchapter_limit ? '不限制' : (data.used_target_pages + ' 页限幅'))
: ''
const umsg = u ? `,小章节策略:${u}` : ''
alert(`AI填充已保存并用于生成章节数${data.section_count || this.sections.length},持久化长度:${data.persisted_outline_len || this.project.outline.length}${umsg}`)
} else {
alert('AI填充失败: ' + (data.error || ''))
}
} catch (e) {
alert('AI填充失败: ' + e.message)
} finally {
this.expandingOutline = false
}
},
// ── 章节生成 ──
async generateSection(sectionId) {
const s = this.sections.find(x => x.id === sectionId)
if (s) s.status = 'generating'
if (this.selectedSection && this.selectedSection.id === sectionId) {
this.selectedSection.status = 'generating'
}
const res = await fetch(`/api/projects/${this.projectId}/generate-section`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ section_id: sectionId })
})
await this.updateProgress()
},
async generateAllSections() {
const pending = this.progress.total - this.progress.done - this.progress.errors
if (!confirm(`将并发生成 ${pending > 0 ? pending : this.progress.total} 个章节内容,确定继续?`)) return
const res = await fetch(`/api/projects/${this.projectId}/generate-all-sections`, { method: 'POST' })
const data = await res.json()
if (!data.success) alert('启动失败: ' + (data.error || ''))
},
// ── 章节选择与编辑 ──
async selectSection(s) {
if (this.chatSectionId !== s.id) {
this.chatMessages = []
this.chatSectionId = s.id
}
this.selectedSection = s
this.editMode = false
await this.loadSectionContent(s.id)
},
switchGenMode(mode) {
this.genMode = mode
if (mode === 'chat' && this.chatMessages.length === 0 && this.selectedSection) {
this.chatMessages = [{
role: 'assistant',
content: `您好!我可以协助您以对话方式生成「${this.selectedSection.title}」的章节内容。\n\n您可以这样提问:\n· 请生成该章节完整内容\n· 重点强调××方面的技术措施\n· 帮我补充关于××的具体数据和标准编号\n· 将刚才的内容改得更具体一些\n\n每条 AI 回复下方有「采用此内容」按钮,点击后内容将填入左侧编辑框。`,
id: Date.now(),
isWelcome: true
}]
}
},
async sendChatMessage() {
const text = this.chatInput.trim()
if (!text || this.chatLoading || !this.selectedSection) return
const userMsg = { role: 'user', content: text, id: Date.now() }
this.chatMessages.push(userMsg)
this.chatInput = ''
this.chatLoading = true
this.$nextTick(() => {
const el = document.getElementById('chatMsgList')
if (el) el.scrollTop = el.scrollHeight
})
const sendMsgs = this.chatMessages.filter(m => !m.isWelcome)
try {
const res = await fetch(
`/api/projects/${this.projectId}/sections/${this.selectedSection.id}/chat`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: sendMsgs })
}
)
const data = await res.json()
if (data.success) {
this.chatMessages.push({ role: 'assistant', content: data.content, id: Date.now() })
} else {
this.chatMessages.push({ role: 'assistant', content: '生成失败:' + (data.error || '未知错误'), id: Date.now(), isError: true })
}
} catch (e) {
this.chatMessages.push({ role: 'assistant', content: '请求失败:' + e.message, id: Date.now(), isError: true })
} finally {
this.chatLoading = false
this.$nextTick(() => {
const el = document.getElementById('chatMsgList')
if (el) el.scrollTop = el.scrollHeight
})
}
},
applyChatContent(content) {
this.editContent = content
if (this.sectionContent) this.sectionContent.content = content
this.genMode = 'auto'
this.editMode = true
},
async loadSectionContent(sectionId) {
const res = await fetch(`/api/projects/${this.projectId}/sections/${sectionId}`)
const data = await res.json()
this.sectionContent = data
this.editContent = data.content || ''
},
async saveContent() {
if (!this.selectedSection) return
await fetch(`/api/projects/${this.projectId}/sections/${this.selectedSection.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: this.editContent })
})
if (this.sectionContent) this.sectionContent.content = this.editContent
this.editMode = false
const s = this.sections.find(x => x.id === this.selectedSection.id)
if (s) { s.status = 'done'; s.has_content = true }
},
// ── 合规检查 ──
async runCheck() {
this.checking = true
this.checkResult = null
const res = await fetch(`/api/projects/${this.projectId}/check`, { method: 'POST' })
const data = await res.json()
this.checking = false
if (data.error) {
alert('检查失败: ' + data.error)
} else {
this.checkResult = data
}
},
onDarkBidFile(event) {
this.darkBidFileError = ''
const f = event.target.files && event.target.files[0]
if (!f) return
if (!/\.html?$/i.test(f.name)) {
this.darkBidFileError = '请选择 .html / .htm 文件'
return
}
const reader = new FileReader()
reader.onload = () => { this.darkBidHtml = reader.result || '' }
reader.onerror = () => { this.darkBidFileError = '读取文件失败' }
reader.readAsText(f, 'UTF-8')
event.target.value = ''
},
async runDarkBidCheck() {
if (!this.darkBidCheckEnabled) {
alert('请先勾选「启用清暗标格式检查」')
return
}
const html = (this.darkBidHtml || '').trim()
if (!html) {
alert('请粘贴 HTML 或选择 .html 文件')
return
}
this.darkBidChecking = true
this.darkBidCheckResult = null
try {
const res = await fetch(`/api/projects/${this.projectId}/check-dark-bid-format`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ html })
})
const data = await res.json()
if (!res.ok || data.error) {
alert('清标失败: ' + (data.error || res.statusText || res.status))
} else {
this.darkBidCheckResult = data
}
} catch (e) {
alert('清标失败: ' + (e.message || e))
} finally {
this.darkBidChecking = false
}
},
// ── 导出 ──
async exportDoc() {
if (this.sections.length === 0) { alert('请先生成大纲'); return }
this.exporting = true
const res = await fetch(`/api/projects/${this.projectId}/export`, { method: 'POST' })
const data = await res.json()
this.exporting = false
if (data.url) {
const a = document.createElement('a')
a.href = data.url
a.download = data.filename
a.click()
} else {
alert('导出失败: ' + (data.error || ''))
}
},
// ── 知识库 ──
async uploadKnowledge(event) {
const file = event.target.files[0]
if (!file) return
const fd = new FormData()
fd.append('file', file)
const res = await fetch('/api/knowledge/upload', { method: 'POST', body: fd })
const data = await res.json()
if (data.success) {
await this.loadKnowledge()
alert(`上传成功,共处理 ${data.chunks} 个文本块`)
} else {
alert('上传失败: ' + (data.error || ''))
}
},
async deleteKnowledge(fileName) {
if (!confirm(`删除知识文件"${fileName}"`)) return
const res = await fetch('/api/knowledge/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_name: fileName })
})
const data = await res.json()
if (data.success) await this.loadKnowledge()
else alert('删除失败: ' + (data.error || ''))
},
// ── 暗标设置 ──
addAnonPreset(text) {
const sep = this.anonText.trim() ? '\n' : ''
this.anonText = (this.anonText.trim() + sep + '- ' + text).trimStart()
},
async saveAnon() {
this.savingAnon = true
const anon = this.anonEnabled ? this.anonText.trim() : ''
await fetch(`/api/projects/${this.projectId}/anon`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ anon_requirements: anon })
})
this.project.anon_requirements = anon
this.savingAnon = false
},
async saveDiagram() {
this.savingDiagram = true
await fetch(`/api/projects/${this.projectId}/diagram`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enable_figure: this.enableFigure, enable_table: this.enableTable })
})
this.project.enable_figure = this.enableFigure
this.project.enable_table = this.enableTable
this.savingDiagram = false
}
}
}
</script>
<!-- 页脚版权声明 -->
<footer class="py-4 px-6 border-t border-gray-200 bg-white text-center text-xs text-gray-500 space-y-1">
<p class="font-medium text-gray-600">© 标书老崔</p>
<p>本工具仅限学习交流免费使用,生成的技术方案请人工核对。本工具不会在任何平台售卖,请注意甄别。</p>
</footer>
</body>
</html>