新增: Phase2 阶段收尾(Sprint 1-20)

重构:删 5 零引用 crate(df-evolve/plugin/stages/task/traceability)+ 清死模块、ai.rs 拆 11 子 module、ai.ts 拆 6 composable、i18n 拆目录
功能:知识库全栈(df-project/scan + CRUD + 时间线 + 前端)、Settings 拆分、appSettings KV 迁移、模型池、LLM 并发 Semaphore
修复:审批持久化根治、ConditionEngine 默认拒绝、NodeRegistry unimplemented 清除、promote 补偿删除、工具结果截断 50KB、路径校验防 symlink 逃逸
文档:B-03 人工审批设计、决策记录三分档、规格契约自检、经验记录、todo 看板、PROGRESS 更新

详见 PROGRESS.md。src-tauri/儿童每日打卡应用/ 与本项目无关,已排除。
This commit is contained in:
2026-06-14 14:08:20 +08:00
parent 98393b4908
commit cf017f81e2
167 changed files with 19549 additions and 6886 deletions

View File

@@ -1,10 +1,10 @@
<template>
<div class="ideas">
<header class="page-header">
<h1>💡 灵感</h1>
<h1>{{ $t('ideas.title') }}</h1>
<div class="header-actions">
<button class="btn btn-ghost" @click="refresh">🔄 刷新</button>
<button class="btn btn-primary" @click="openCaptureModal"> 捕捉灵感</button>
<button class="btn btn-ghost" @click="refresh">{{ $t('ideas.refresh') }}</button>
<button class="btn btn-primary" @click="openCaptureModal">{{ $t('ideas.capture') }}</button>
</div>
</header>
@@ -14,8 +14,7 @@
<input
v-model="searchQuery"
type="text"
placeholder="搜索灵感..."
@input="filterIdeas"
:placeholder="$t('ideas.searchPlaceholder')"
/>
</div>
<button
@@ -25,7 +24,7 @@
:class="{ active: activeFilter === f.key }"
@click="activeFilter = f.key"
>
{{ f.icon }} {{ f.label }}
{{ f.icon }} {{ $t(f.labelKey) }}
</button>
</div>
@@ -47,7 +46,7 @@
</div>
<p class="idea-desc-preview">{{ idea.description?.slice(0, 60) ?? '' }}{{ idea.description && idea.description.length > 60 ? '...' : '' }}</p>
<div class="idea-card-footer">
<span class="status-tag" :class="'status-' + idea.status">{{ statusLabel(idea.status) }}</span>
<span class="status-tag" :class="'status-' + idea.status">{{ $t(statusLabelKey(idea.status)) }}</span>
<span class="idea-date">{{ formatDate(idea.created_at) }}</span>
</div>
</div>
@@ -58,20 +57,22 @@
<section class="idea-detail-panel" v-if="currentIdea">
<div class="detail-header">
<h2 class="detail-title">{{ currentIdea.title }}</h2>
<span class="status-tag" :class="'status-' + currentIdea.status">{{ statusLabel(currentIdea.status) }}</span>
<span class="status-tag" :class="'status-' + currentIdea.status">{{ $t(statusLabelKey(currentIdea.status)) }}</span>
</div>
<p class="detail-desc">{{ currentIdea.description }}</p>
<!-- 对抗式评估 -->
<div class="detail-section">
<h3> 对抗式评估</h3>
<h3>{{ $t('ideas.adversarialTitle') }} <span class="eval-mode-tag">{{ $t('ideas.evalModeHeuristic') }}</span></h3>
<div v-if="adversarialEval" class="adversarial-eval">
<!-- 正反方观点 -->
<div class="debate-container">
<div class="debate-column positive">
<h4>📈 正方观点</h4>
<div class="confidence-bar" :style="{ width: (adversarialEval.positive_strength * 100) + '%' }"></div>
<div class="confidence-text">{{ (adversarialEval.positive_strength * 100).toFixed(0) }}% 置信度</div>
<h4>{{ $t('ideas.positive') }}</h4>
<div class="confidence-bar">
<div class="confidence-fill" :style="{ width: (adversarialEval.positive_strength * 100) + '%' }"></div>
</div>
<div class="confidence-text">{{ $t('ideas.confidence', { n: (adversarialEval.positive_strength * 100).toFixed(0) }) }}</div>
<p class="thesis">{{ adversarialEval.positive.thesis }}</p>
<ul>
<li v-for="evidence in adversarialEval.positive.evidence" :key="evidence"> {{ evidence }}</li>
@@ -79,9 +80,11 @@
</div>
<div class="debate-column negative">
<h4>📉 反方观点</h4>
<div class="confidence-bar" :style="{ width: (adversarialEval.negative_strength * 100) + '%' }"></div>
<div class="confidence-text">{{ (adversarialEval.negative_strength * 100).toFixed(0) }}% 置信度</div>
<h4>{{ $t('ideas.negative') }}</h4>
<div class="confidence-bar">
<div class="confidence-fill" :style="{ width: (adversarialEval.negative_strength * 100) + '%' }"></div>
</div>
<div class="confidence-text">{{ $t('ideas.confidence', { n: (adversarialEval.negative_strength * 100).toFixed(0) }) }}</div>
<p class="thesis">{{ adversarialEval.negative.thesis }}</p>
<ul>
<li v-for="evidence in adversarialEval.negative.evidence" :key="evidence"> {{ evidence }}</li>
@@ -91,34 +94,36 @@
<!-- AI 分析师结论 -->
<div class="analyst-conclusion">
<h4>🧠 AI 分析师结论</h4>
<h4>{{ $t('ideas.analystTitle') }}</h4>
<div class="assessment-badge" :class="assessmentClass(adversarialEval.recommendation)">
{{ assessmentLabel(adversarialEval.recommendation) }}
</div>
<p class="final-score">综合评分: {{ adversarialEval.final_score.toFixed(1) }}/10</p>
<p class="final-score">{{ $t('ideas.finalScore', { score: adversarialEval.final_score.toFixed(1) }) }}</p>
<div class="net-sentiment" :class="sentimentClass(adversarialEval.net_sentiment)">
整体倾向: {{ adversarialEval.net_sentiment > 0 ? '积极' : '谨慎' }}
({{ (adversarialEval.net_sentiment * 100).toFixed(0) }})
{{ $t('ideas.netSentiment', { tone: adversarialEval.net_sentiment > 0 ? $t('ideas.sentimentPositive') : $t('ideas.sentimentNegative'), n: (adversarialEval.net_sentiment * 100).toFixed(0) }) }}
</div>
<p class="summary">{{ adversarialEval.analyst.summary }}</p>
</div>
<!-- 行动建议 -->
<div class="action-recommendations">
<h4>💡 行动建议</h4>
<h4>{{ $t('ideas.actionTitle') }}</h4>
<ul>
<li v-for="action in adversarialEval.action_items" :key="action"> {{ action }}</li>
</ul>
</div>
</div>
<div v-else class="eval-report" style="opacity:0.5">
<button class="btn-evaluate" @click="evaluateCurrentIdea">🔍 开始对抗评估</button>
<button class="btn-evaluate" :disabled="evaluating" @click="evaluateCurrentIdea">
{{ evaluating ? $t('ideas.evaluating') : $t('ideas.startEval') }}
</button>
<div v-if="evalError" class="eval-error"> {{ evalError }}</div>
</div>
</div>
<!-- 传统评分雷达图 -->
<div class="detail-section">
<h3>📊 多维评分</h3>
<h3>{{ $t('ideas.multiScoreTitle') }}</h3>
<div class="radar-chart" v-if="parseScores(currentIdea).length > 0">
<div class="radar-row" v-for="dim in parseScores(currentIdea)" :key="dim.name">
<span class="radar-label">{{ dim.name }}</span>
@@ -128,28 +133,24 @@
<span class="radar-value">{{ dim.score }}</span>
</div>
</div>
<div v-else class="eval-report" style="opacity:0.5">暂无评估</div>
<div v-else class="eval-report" style="opacity:0.5">{{ $t('ideas.noEval') }}</div>
</div>
<!-- 标签 -->
<div class="detail-section">
<h3>🏷 标签</h3>
<h3>{{ $t('ideas.tagsTitle') }}</h3>
<div class="tag-list" v-if="parseTags(currentIdea).length > 0">
<span class="tag" v-for="tag in parseTags(currentIdea)" :key="tag">{{ tag }}</span>
</div>
<div v-else class="eval-report" style="opacity:0.5">暂无标签</div>
<div v-else class="eval-report" style="opacity:0.5">{{ $t('ideas.noTags') }}</div>
</div>
<!-- 状态管理 -->
<div class="detail-section">
<h3>📋 状态管理</h3>
<h3>{{ $t('ideas.statusTitle') }}</h3>
<div class="status-controls">
<select v-model="currentStatus" @change="updateIdeaStatus" class="status-select">
<option value="draft">📝 草稿</option>
<option value="pending_review"> 待评估</option>
<option value="approved"> 已批准</option>
<option value="promoted">🚀 已立项</option>
<option value="rejected"> 已拒绝</option>
<select :value="currentStatus" @change="onStatusChange" class="status-select">
<option v-for="s in statusOptions" :key="s.value" :value="s.value">{{ $t(s.labelKey) }}</option>
</select>
</div>
</div>
@@ -162,9 +163,9 @@
class="btn btn-primary"
@click="promoteToProject"
>
🚀 立项为项目
{{ $t('ideas.promoteToProject') }}
</button>
<button class="btn btn-ghost" @click="deleteCurrentIdea">🗑 删除想法</button>
<button class="btn btn-ghost" @click="deleteCurrentIdea">{{ $t('ideas.deleteIdea') }}</button>
</div>
</div>
</section>
@@ -173,7 +174,7 @@
<section class="idea-detail-panel idea-empty" v-else>
<div class="empty-state">
<div class="empty-icon">💡</div>
<p>选择一个想法查看详情</p>
<p>{{ $t('ideas.emptyState') }}</p>
</div>
</section>
</div>
@@ -181,14 +182,14 @@
<!-- 捕捉想法模态框 -->
<div class="modal-overlay" v-if="showCaptureModal" @click.self="showCaptureModal = false">
<div class="modal-box">
<h3> 捕捉新想法</h3>
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">标题</label>
<input v-model="newIdeaTitle" placeholder="一句话描述你的想法..." @keyup.enter="confirmCapture" />
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">描述</label>
<textarea v-model="newIdeaDesc" placeholder="详细说明(可选)..." rows="3" style="resize:vertical"></textarea>
<h3>{{ $t('ideas.captureTitle') }}</h3>
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('ideas.fieldTitle') }}</label>
<input v-model="newIdeaTitle" :placeholder="$t('ideas.titlePlaceholder')" @keyup.enter="confirmCapture" />
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('ideas.fieldDesc') }}</label>
<textarea v-model="newIdeaDesc" :placeholder="$t('ideas.descPlaceholder')" rows="3" style="resize:vertical"></textarea>
<div class="modal-actions">
<button class="btn-cancel" @click="showCaptureModal = false">取消</button>
<button class="btn-confirm" @click="confirmCapture">确认</button>
<button class="btn-cancel" @click="showCaptureModal = false">{{ $t('common.cancel') }}</button>
<button class="btn-confirm" @click="confirmCapture">{{ $t('common.confirm') }}</button>
</div>
</div>
</div>
@@ -198,10 +199,12 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useProjectStore } from '../stores/project'
import { formatDate } from '../utils/time'
import type { IdeaRecord } from '../api/types'
const { t } = useI18n()
const router = useRouter()
const store = useProjectStore()
@@ -216,13 +219,27 @@ const showCaptureModal = ref(false)
const newIdeaTitle = ref('')
const newIdeaDesc = ref('')
const filters: { key: FilterKey; label: string; icon: string }[] = [
{ key: 'all', label: '全部', icon: '📋' },
{ key: 'hot', label: '热门', icon: '🔥' },
{ key: 'pending', label: '待评估', icon: '⏳' },
{ key: 'promoted', label: '已立项', icon: '🚀' },
const filters: { key: FilterKey; labelKey: string; icon: string }[] = [
{ key: 'all', labelKey: 'ideas.filter.all', icon: '📋' },
{ key: 'hot', labelKey: 'ideas.filter.hot', icon: '🔥' },
{ key: 'pending', labelKey: 'ideas.filter.pending', icon: '⏳' },
{ key: 'promoted', labelKey: 'ideas.filter.promoted', icon: '🚀' },
]
// ── 状态映射(单一数据源,列表/详情/下拉共用) ──
const statusOptions: { value: string; labelKey: string }[] = [
{ value: 'draft', labelKey: 'ideas.status.draft' },
{ value: 'pending_review', labelKey: 'ideas.status.pending_review' },
{ value: 'approved', labelKey: 'ideas.status.approved' },
{ value: 'promoted', labelKey: 'ideas.status.promoted' },
{ value: 'rejected', labelKey: 'ideas.status.rejected' },
]
// 任意 status 字符串 → 对应 i18n key;未知状态回退到原值显示
function statusLabelKey(status: string): string {
return statusOptions.find(s => s.value === status)?.labelKey ?? status
}
const filteredIdeas = computed(() => {
let ideas = store.ideas
@@ -247,10 +264,6 @@ const filteredIdeas = computed(() => {
return ideas
})
function filterIdeas() {
// filteredIdeas 是 computed会自动响应变化
}
const currentIdea = computed(() => {
if (!selectedId.value) return null
return store.ideas.find(i => i.id === selectedId.value) ?? null
@@ -313,19 +326,23 @@ const adversarialEval = computed<AdversarialEval | null>(() => {
})
function assessmentClass(recommendation: string) {
return recommendation.toLowerCase().replace(/ /g, '-')
// 映射到 CSS 定义的 badge 颜色类(.immediate/.soon/.conditional/.revised/.defer/.cancel
const map: Record<string, string> = {
'immediate action': 'immediate',
'soon': 'soon',
'with resources': 'conditional',
'research more': 'revised',
'monitor': 'defer',
'cancel': 'cancel',
}
return map[recommendation.toLowerCase()] ?? 'conditional'
}
function assessmentLabel(recommendation: string) {
const map: Record<string, string> = {
'immediate action': '🚀 立即行动',
'soon': '📅 尽快行动',
'with resources': '📦 配置资源后行动',
'research more': '🔍 需要更多研究',
'monitor': '👁️ 持续监控',
'cancel': '❌ 取消想法'
}
return map[recommendation] ?? recommendation
const key = `ideas.assessment.${recommendation}`
// 未命中 i18n key 时回退到原始 recommendation 字符串
const translated = t(key)
return translated === key ? recommendation : translated
}
function sentimentClass(sentiment: number) {
@@ -339,17 +356,6 @@ function scoreClass(score: number | null) {
return 'score-low'
}
function statusLabel(status: string) {
const map: Record<string, string> = {
draft: '📝 草稿',
pending_review: '⏳ 待评估',
approved: '✅ 已批准',
promoted: '🚀 已立项',
rejected: '❌ 已拒绝',
}
return map[status] ?? status
}
// formatDate 由 ../utils/time 提供(统一毫秒字符串解析,根治 Invalid Date
function openCaptureModal() {
@@ -369,74 +375,49 @@ async function confirmCapture() {
async function deleteCurrentIdea() {
if (!currentIdea.value) return
if (!confirm(t('ideas.confirmDelete', { title: currentIdea.value.title }))) return
await store.deleteIdea(currentIdea.value.id)
selectedId.value = null
}
async function promoteToProject() {
if (!currentIdea.value) return
// 创建新项目基于想法store.createProject 第 3 参 = idea_id
const project = await store.createProject(
currentIdea.value.title,
currentIdea.value.description,
currentIdea.value.id,
)
const projectId = project.id
// 更新想法状态和晋升信息store.updateIdea 单字段,分两次调用)
await store.updateIdea(currentIdea.value.id, 'status', 'promoted')
await store.updateIdea(currentIdea.value.id, 'promoted_to', projectId)
// 跳转到项目详情
router.push(`/projects/${projectId}`)
// 刷新想法列表
await store.loadIdeas()
try {
const res = await store.promoteIdea(currentIdea.value.id)
router.push(`/projects/${res.project_id}`)
} catch (e: any) {
const msg = e?.toString() ?? t('ideas.promoteFailed')
console.error(t('ideas.promoteFailed'), e)
alert(msg)
}
}
async function refresh() {
await store.loadIdeas()
}
const evaluating = ref(false)
const evalError = ref('')
async function evaluateCurrentIdea() {
if (!currentIdea.value) return
if (!currentIdea.value || evaluating.value) return
// 模拟对抗式评估(实际应该调用后端 API
// 这里使用模拟数据展示界面
const mockEval: AdversarialEval = {
positive_strength: 0.75,
negative_strength: 0.65,
net_sentiment: 0.1,
recommendation: 'with resources',
final_score: 6.5,
summary: '该想法整体价值评估中等偏上,建议在有条件的情况下执行。主要价值在于技术创新性较强,需要关注风险控制和资源投入。',
action_items: [
'确认资源预算',
'评估ROI',
'制定风险预案'
],
positive: {
thesis: '技术创新性强,潜在回报高',
evidence: ['技术栈成熟', '市场需求明确', '团队有相关经验'],
},
negative: {
thesis: '资源投入大,存在执行风险',
evidence: ['开发周期长', '需要额外人力', '竞品已有类似方案'],
},
analyst: {
summary: '综合正反方观点,建议在资源到位后启动,并设立阶段性验收点控制风险。',
},
evaluating.value = true
evalError.value = ''
try {
await store.evaluateIdea(currentIdea.value.id)
} catch (e: any) {
evalError.value = e?.toString() ?? t('ideas.evalFailed')
console.error(t('ideas.evalFailed'), e)
} finally {
evaluating.value = false
}
// 更新想法的评估结果(实际应该调用 API
await store.updateIdea(currentIdea.value.id, 'ai_analysis', JSON.stringify(mockEval, null, 2))
}
async function updateIdeaStatus() {
if (!currentIdea.value || !currentStatus.value) return
await store.updateIdea(currentIdea.value.id, 'status', currentStatus.value)
async function onStatusChange(e: Event) {
if (!currentIdea.value) return
const newStatus = (e.target as HTMLSelectElement).value
await store.updateIdea(currentIdea.value.id, 'status', newStatus)
}
onMounted(async () => {
@@ -629,25 +610,14 @@ onMounted(async () => {
overflow: hidden;
}
.debate-column.positive .confidence-bar::after {
content: '';
position: absolute;
left: 0;
top: 0;
.confidence-fill {
height: 100%;
background: currentColor;
border-radius: var(--df-radius-xs);
transition: width 0.4s;
}
.debate-column.negative .confidence-bar::after {
content: '';
position: absolute;
left: 0;
top: 0;
height: 100%;
background: currentColor;
border-radius: var(--df-radius-xs);
}
.debate-column.positive .confidence-fill { background: var(--df-success); }
.debate-column.negative .confidence-fill { background: var(--df-danger); }
.confidence-text {
font-size: 11px;
@@ -765,6 +735,23 @@ onMounted(async () => {
background: var(--df-accent-hover);
}
.eval-error {
margin-top: 8px;
font-size: 12px;
color: var(--df-danger);
}
.eval-mode-tag {
font-size: 11px;
font-weight: 400;
padding: 1px 8px;
border-radius: var(--df-radius-xs);
background: rgba(255, 217, 61, 0.15);
color: var(--df-warning);
margin-left: 6px;
vertical-align: middle;
}
/* ===== 右侧详情 ===== */
.idea-detail-panel {
background: var(--df-bg-card);

View File

@@ -2,337 +2,685 @@
<div class="knowledge">
<!-- 页面头部 -->
<header class="page-header">
<h1>📚 知识库</h1>
<h1>{{ $t('knowledge.title') }}</h1>
<div class="header-actions">
<button class="btn btn-primary" @click="addKnowledge">+ 新增知识</button>
<button class="btn btn-primary" @click="openCreateModal">{{ $t('knowledge.add') }}</button>
</div>
</header>
<!-- 搜索栏 -->
<div class="search-bar">
<input
v-model="searchQuery"
type="text"
class="search-input"
placeholder="搜索知识标题、标签、内容..."
/>
</div>
<!-- 分类标签页 -->
<div class="category-tabs">
<button
v-for="cat in categories"
:key="cat.key"
class="tab-btn"
:class="{ 'tab-active': activeCategory === cat.key }"
@click="activeCategory = cat.key"
>
<span class="tab-icon">{{ cat.icon }}</span>
<span class="tab-label">{{ cat.label }}</span>
<span class="tab-count">{{ getCategoryCount(cat.key) }}</span>
<!-- 顶层 Tab: 知识库 / 审核收件箱 -->
<div class="top-tabs">
<button class="top-tab" :class="{ active: topTab === 'library' }" @click="switchTopTab('library')">
{{ $t('knowledge.tab.library') }}
</button>
<button class="top-tab" :class="{ active: topTab === 'inbox' }" @click="switchTopTab('inbox')">
{{ $t('knowledge.tab.inbox') }}
<span v-if="store.candidates.length" class="inbox-badge">{{ store.candidates.length }}</span>
</button>
</div>
<!-- 知识卡片列表 -->
<div class="knowledge-grid">
<div class="knowledge-card" v-for="item in filteredItems" :key="item.id">
<div class="card-header">
<span class="card-title">{{ item.title }}</span>
<span class="card-score" :class="scoreClass(item.score)">{{ item.score }}</span>
<!-- 左右分栏: 列表 + 详情 -->
<div class="kn-layout">
<!-- ===== 左侧:列表 ===== -->
<div class="kn-list-panel">
<!-- 搜索栏(仅知识库) -->
<div v-if="topTab === 'library'" class="search-bar">
<input
v-model="searchQuery"
type="text"
class="search-input"
:placeholder="$t('knowledge.searchPlaceholder')"
@input="onSearchInput"
/>
</div>
<div class="card-desc">{{ item.description }}</div>
<div class="card-tags">
<span class="tag" v-for="tag in item.tags" :key="tag">{{ tag }}</span>
<!-- 分类(仅知识库) -->
<div v-if="topTab === 'library'" class="category-tabs">
<button
v-for="cat in categories"
:key="cat.key"
class="cat-chip"
:class="{ 'cat-active': activeKind === cat.key }"
@click="activeKind = cat.key"
>
<span>{{ cat.icon }}</span>
<span>{{ catLabel(cat.key) }}</span>
<span class="cat-count">{{ getCategoryCount(cat.key) }}</span>
</button>
</div>
<div class="card-footer">
<span class="card-reuse">🔄 复用 {{ item.reuseCount }} </span>
<span class="card-category">{{ getCategoryLabel(item.category) }}</span>
<span class="card-time">{{ item.updatedAt }}</span>
<button class="btn btn-ghost" @click="deleteKnowledge(item.id)">删除</button>
<!-- 卡片列表 -->
<div class="list-cards">
<div
v-for="item in listItems"
:key="item.id"
class="kn-card"
:class="{ selected: selectedId === item.id }"
@click="selectKnowledge(item.id)"
>
<div class="kn-card-head">
<span class="kn-card-title">{{ item.title }}</span>
<span class="kn-card-status" :class="'st-' + item.status">{{ statusLabel(item.status) }}</span>
</div>
<div class="kn-card-desc">{{ item.content }}</div>
<div class="kn-card-meta">
<span class="meta-kind">{{ kindLabel(item.kind) }}</span>
<span v-if="item.confidence" :class="'conf-' + item.confidence">{{ confidenceLabel(item.confidence) }}</span>
<span class="meta-reuse">🔄 {{ item.reuse_count }}</span>
</div>
</div>
<div v-if="listItems.length === 0" class="list-empty">
<div>{{ topTab === 'inbox' ? $t('knowledge.emptyInbox') : $t('knowledge.emptyLibrary') }}</div>
</div>
</div>
</div>
<!-- ===== 右侧:详情 ===== -->
<div class="kn-detail-panel">
<template v-if="detail">
<!-- 基本信息 -->
<section class="detail-section">
<div class="detail-head">
<div class="detail-title-row">
<span class="detail-kind-icon">{{ kindIcon(detail.knowledge.kind) }}</span>
<span v-if="!editing" class="detail-title">{{ detail.knowledge.title }}</span>
<input v-else v-model="editForm.title" class="edit-input edit-input-title" />
</div>
<div class="detail-actions" v-if="!editing">
<button class="btn btn-ghost btn-sm" @click="startEdit">{{ $t('knowledge.edit') }}</button>
<template v-if="topTab === 'inbox'">
<button class="btn btn-primary btn-sm" @click="publishCurrent">{{ $t('knowledge.publish') }}</button>
<button class="btn btn-ghost btn-sm" @click="rejectCurrent">{{ $t('knowledge.reject') }}</button>
</template>
<button v-else class="btn btn-ghost btn-sm" @click="archiveCurrent">{{ $t('knowledge.archive') }}</button>
</div>
<div class="detail-actions" v-else>
<button class="btn btn-ghost btn-sm" @click="cancelEdit">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary btn-sm" @click="saveEdit" :disabled="!editForm.title.trim()">{{ $t('common.save') }}</button>
</div>
</div>
<div class="detail-badges">
<span class="kn-card-status" :class="'st-' + detail.knowledge.status">{{ statusLabel(detail.knowledge.status) }}</span>
<span v-if="detail.knowledge.confidence" :class="'conf-' + detail.knowledge.confidence">{{ $t('knowledge.confidenceBadge', { label: confidenceLabel(detail.knowledge.confidence) }) }}</span>
<span class="badge-plain">{{ $t('knowledge.reuseCount', { n: detail.knowledge.reuse_count }) }}</span>
<span class="badge-plain">{{ kindLabel(detail.knowledge.kind) }}</span>
</div>
<div class="detail-field">
<label>{{ $t('knowledge.contentLabel') }}</label>
<div v-if="!editing" class="detail-content">{{ detail.knowledge.content }}</div>
<textarea v-else v-model="editForm.content" class="edit-input edit-textarea"></textarea>
</div>
<div class="detail-field">
<label>{{ $t('knowledge.tagsLabel') }}</label>
<div v-if="!editing" class="detail-tags">
<span class="tag" v-for="tag in parseTags(detail.knowledge.tags)" :key="tag">{{ tag }}</span>
<span v-if="parseTags(detail.knowledge.tags).length === 0" class="muted">{{ $t('knowledge.tagsNone') }}</span>
</div>
<input v-else v-model="editForm.tagsInput" class="edit-input" :placeholder="$t('knowledge.tagsPlaceholder')" />
</div>
</section>
<!-- 溯源 -->
<section class="detail-section">
<div class="section-title">{{ $t('knowledge.traceTitle') }}</div>
<div class="trace-row">
<span class="trace-label">{{ $t('knowledge.traceMethod') }}</span>
<span>{{ originMethod }}</span>
</div>
<div class="trace-row" v-if="originConvTitle">
<span class="trace-label">{{ $t('knowledge.traceSource') }}</span>
<span>{{ originConvTitle }}</span>
</div>
<div class="trace-row" v-if="originTime">
<span class="trace-label">{{ $t('knowledge.traceTime') }}</span>
<span>{{ originTime }}</span>
</div>
<div class="detail-field" v-if="reasoningText">
<label>{{ $t('knowledge.reasoningLabel') }}</label>
<div class="reasoning-box">{{ reasoningText }}</div>
</div>
</section>
<!-- 引用 -->
<section class="detail-section">
<div class="section-title">{{ $t('knowledge.refTitle', { n: referenceEvents.length }) }}</div>
<div v-if="referenceEvents.length === 0" class="muted">{{ $t('knowledge.refEmpty') }}</div>
<div v-else class="ref-list">
<div v-for="ref in referenceEvents.slice(0, refLimit)" :key="ref.id" class="ref-item">
<span class="ref-time">{{ relativeTime(ref.timestamp) }}</span>
<span class="ref-conv">{{ refConvTitle(ref) }}</span>
<span class="ref-query" v-if="refQuery(ref)">{{ refQuery(ref) }}</span>
</div>
<button v-if="referenceEvents.length > refLimit" class="btn btn-ghost btn-sm load-more" @click="refLimit += 10">
{{ $t('knowledge.loadMore', { n: referenceEvents.length - refLimit }) }}
</button>
</div>
</section>
<!-- 生命周期时间线 -->
<section class="detail-section">
<div class="section-title">{{ $t('knowledge.lifecycleTitle') }}</div>
<div class="timeline">
<div v-for="(node, idx) in timelineNodes" :key="node.id" class="tl-node">
<div class="tl-dot">{{ node.icon }}</div>
<div v-if="idx < timelineNodes.length - 1" class="tl-line"></div>
<div class="tl-body">
<div class="tl-label">{{ node.label }}</div>
<div class="tl-time">{{ relativeTime(node.timestamp) }}</div>
<div class="tl-summary" v-if="node.summary">{{ node.summary }}</div>
</div>
</div>
</div>
</section>
</template>
<div v-else class="detail-empty">
<div class="detail-empty-icon">👈</div>
<div>{{ $t('knowledge.detailEmptyHint') }}</div>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="filteredItems.length === 0" class="empty-state">
<div class="empty-icon">📭</div>
<div class="empty-text">没有找到匹配的知识条目</div>
<!-- ===== 新增知识对话框 ===== -->
<div v-if="showCreateModal" class="modal-overlay" @click.self="closeCreateModal">
<div class="modal-box">
<div class="modal-title">{{ $t('knowledge.createTitle') }}</div>
<div class="form-row">
<label>{{ $t('knowledge.kindLabel') }}</label>
<select v-model="form.kind" class="form-select">
<option v-for="k in KNOWLEDGE_KINDS" :key="k.key" :value="k.key">{{ k.icon }} {{ kindText(k.key) }}</option>
</select>
</div>
<div class="form-row">
<label>{{ $t('knowledge.titleLabel') }}</label>
<input v-model="form.title" type="text" class="form-input" :placeholder="$t('knowledge.titlePlaceholder')" />
</div>
<div class="form-row">
<label>{{ $t('knowledge.contentLabel') }}</label>
<textarea v-model="form.content" class="form-input form-textarea" :placeholder="$t('knowledge.contentPlaceholder')"></textarea>
</div>
<div class="form-row">
<label>{{ $t('knowledge.tagsFieldLabel') }}</label>
<input v-model="form.tagsInput" type="text" class="form-input" :placeholder="$t('knowledge.tagsFieldPlaceholder')" />
</div>
<div class="form-row">
<label>{{ $t('knowledge.confidenceLabel') }}</label>
<select v-model="form.confidence" class="form-select">
<option value="">{{ $t('knowledge.confidenceNone') }}</option>
<option value="high">{{ $t('knowledge.confidence.high') }}</option>
<option value="medium">{{ $t('knowledge.confidence.medium') }}</option>
<option value="low">{{ $t('knowledge.confidence.low') }}</option>
</select>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" @click="closeCreateModal">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary" @click="submitCreate" :disabled="!form.title.trim()">{{ $t('knowledge.create') }}</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useKnowledgeStore, KNOWLEDGE_KINDS, parseTags } from '../stores/knowledge'
import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '../api/types'
interface KnowledgeItem {
id: number
title: string
description: string
category: string
tags: string[]
reuseCount: number
score: number
updatedAt: string
}
const { t } = useI18n()
const store = useKnowledgeStore()
// 顶层 Tab: library(知识库) | inbox(审核收件箱)
const topTab = ref<'library' | 'inbox'>('library')
const categories = [
{ key: 'all', label: '全部', icon: '📦' },
{ key: 'review', label: '审查规则', icon: '🔍' },
{ key: 'prompt', label: 'Prompt模板', icon: '💬' },
{ key: 'pitfall', label: '踩坑经验', icon: '⚠️' },
{ key: 'diagnosis', label: '诊断知识', icon: '🩺' },
{ key: 'deploy', label: '部署经验', icon: '🚀' },
{ key: 'all', icon: '📦' },
...KNOWLEDGE_KINDS.map(k => ({ key: k.key, icon: k.icon })),
]
const activeCategory = ref('all')
// 分类 chip 的展示文案:"全部" 走 categoryAll,其余走 knowledge.kind.<key>
function catLabel(key: string): string {
return key === 'all' ? t('knowledge.categoryAll') : kindText(key)
}
// 纯类型文案(无 icon),用于下拉/分类
function kindText(key: string): string {
return t(`knowledge.kind.${key}`)
}
const activeKind = ref('all')
const searchQuery = ref('')
const knowledgeItems = ref<KnowledgeItem[]>(loadKnowledgeItems())
let searchTimer: ReturnType<typeof setTimeout> | null = null
// 从 localStorage 加载知识数据
function loadKnowledgeItems(): KnowledgeItem[] {
const saved = localStorage.getItem('devflow-knowledge')
if (saved) {
try {
return JSON.parse(saved)
} catch {
// 解析失败,使用默认数据
function onSearchInput() {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
if (searchQuery.value.trim()) {
store.search(searchQuery.value)
} else {
store.loadList()
}
}
return getDefaultData()
}, 300)
}
// 获取默认数据
function getDefaultData(): KnowledgeItem[] {
return [
// 审查规则
{ id: 1, title: 'SQL 注入防护检查', description: '所有 SQL 拼接必须使用参数化查询,禁止字符串拼接用户输入', category: 'review', tags: ['安全', 'SQL', 'Go'], reuseCount: 23, score: 95, updatedAt: '3 天前' },
{ id: 2, title: '错误处理规范', description: '禁止吞掉 error必须向上传播或记录日志', category: 'review', tags: ['Go', '规范', '错误处理'], reuseCount: 18, score: 88, updatedAt: '1 周前' },
{ id: 3, title: 'API 响应格式一致性', description: '统一使用 { code, message, data } 结构, HTTP 状态码语义正确', category: 'review', tags: ['API', '规范'], reuseCount: 15, score: 82, updatedAt: '5 天前' },
// Prompt 模板
{ id: 4, title: 'SQL 查询生成 Prompt', description: '根据自然语言生成 SQL包含表结构上下文和示例输出格式', category: 'prompt', tags: ['SQL', 'LLM', '模板'], reuseCount: 142, score: 91, updatedAt: '昨天' },
{ id: 5, title: '代码审查 Prompt', description: 'AI 代码审查专用 Prompt涵盖安全、性能、可维护性维度', category: 'prompt', tags: ['代码审查', 'LLM'], reuseCount: 87, score: 85, updatedAt: '3 天前' },
{ id: 6, title: 'API 文档生成 Prompt', description: '从 Handler 代码自动生成 API 文档的 Prompt 模板', category: 'prompt', tags: ['文档', 'LLM', '自动生成'], reuseCount: 34, score: 78, updatedAt: '1 周前' },
// 踩坑经验
{ id: 7, title: 'Go context 传递陷阱', description: 'goroutine 中必须传递 context 而非创建新的,否则超时控制失效', category: 'pitfall', tags: ['Go', '并发', 'context'], reuseCount: 12, score: 92, updatedAt: '2 天前' },
{ id: 8, title: 'Vue 3 作用域坑', description: 'v-for 内 ref 绑定不会自动响应式,需使用数组形式', category: 'pitfall', tags: ['Vue', '前端', '响应式'], reuseCount: 8, score: 75, updatedAt: '1 周前' },
{ id: 9, title: 'Docker 网络模式选择', description: 'host 模式在 Mac/Win 上无效,必须用端口映射', category: 'pitfall', tags: ['Docker', '网络'], reuseCount: 6, score: 70, updatedAt: '2 周前' },
// 诊断知识
{ id: 10, title: 'MySQL 慢查询诊断流程', description: 'EXPLAIN → 索引检查 → 慢查询日志分析 → 优化建议', category: 'diagnosis', tags: ['MySQL', '性能', '诊断'], reuseCount: 31, score: 89, updatedAt: '4 天前' },
{ id: 11, title: 'Go 内存泄漏排查', description: 'pprof heap 分析 → goroutine 泄漏检查 → GC 调优', category: 'diagnosis', tags: ['Go', '内存', 'pprof'], reuseCount: 9, score: 83, updatedAt: '1 周前' },
{ id: 12, title: '前端白屏诊断', description: 'Console 错误 → 网络请求 → 路由配置 → 构建产物检查', category: 'diagnosis', tags: ['前端', '调试', 'Vue'], reuseCount: 14, score: 80, updatedAt: '5 天前' },
// 部署经验
{ id: 13, title: 'Go 二进制热更新', description: 'kill + mv + nohup 启动,无需 systemd 的轻量部署方案', category: 'deploy', tags: ['Go', '部署', 'Linux'], reuseCount: 19, score: 86, updatedAt: '昨天' },
{ id: 14, title: 'SCP 上传最佳实践', description: '使用 ssh-proxy upload 代替 scp支持配置化管理', category: 'deploy', tags: ['SCP', '部署', '工具'], reuseCount: 11, score: 72, updatedAt: '3 天前' },
{ id: 15, title: 'Nginx 反向代理配置', description: 'u-ask 的 Nginx 配置模板,含 WebSocket 支持和 gzip', category: 'deploy', tags: ['Nginx', '配置', '反向代理'], reuseCount: 7, score: 77, updatedAt: '1 周前' },
]
}
// 保存到 localStorage
function saveToLocalStorage() {
localStorage.setItem('devflow-knowledge', JSON.stringify(knowledgeItems.value))
}
// 添加知识
function addKnowledge() {
// TODO: 实现添加知识的对话框逻辑
console.log('添加知识')
}
// 删除知识
function deleteKnowledge(id: number) {
const index = knowledgeItems.value.findIndex(item => item.id === id)
if (index !== -1) {
knowledgeItems.value.splice(index, 1)
saveToLocalStorage()
function switchTopTab(tab: 'library' | 'inbox') {
topTab.value = tab
selectedId.value = null
detail.value = null
if (tab === 'inbox') {
store.loadCandidates()
} else {
store.loadList()
}
}
const filteredItems = computed(() => {
let items = knowledgeItems.value
if (activeCategory.value !== 'all') {
items = items.filter(i => i.category === activeCategory.value)
// 左侧列表(随 Tab 切换数据源 + 知识库的分类过滤)
const listItems = computed(() => {
const src = topTab.value === 'inbox' ? store.candidates : store.items
if (topTab.value === 'library' && activeKind.value !== 'all') {
return src.filter(i => i.kind === activeKind.value)
}
if (searchQuery.value.trim()) {
const q = searchQuery.value.toLowerCase()
items = items.filter(i =>
i.title.toLowerCase().includes(q) ||
i.tags.some(t => t.toLowerCase().includes(q)) ||
i.description.toLowerCase().includes(q)
)
}
return items
return src
})
function getCategoryCount(key: string): number {
if (key === 'all') return knowledgeItems.value.length
return knowledgeItems.value.filter(i => i.category === key).length
if (key === 'all') return store.items.length
return store.items.filter(i => i.kind === key).length
}
function getCategoryLabel(key: string): string {
const cat = categories.find(c => c.key === key)
return cat ? cat.label : key
// ===== 详情选中 + 加载 =====
const selectedId = ref<string | null>(null)
const detail = ref<KnowledgeDetailPayload | null>(null)
const refLimit = ref(10)
async function selectKnowledge(id: string) {
selectedId.value = id
refLimit.value = 10
try {
detail.value = await store.getDetail(id)
} catch (e: any) {
console.error('加载详情失败', e)
}
}
function scoreClass(score: number): string {
if (score >= 90) return 'score-high'
if (score >= 75) return 'score-mid'
return 'score-low'
// 列表数据变化时(如刷新),若选中项已被移除则清空详情
watch(listItems, items => {
if (selectedId.value && !items.some(i => i.id === selectedId.value)) {
selectedId.value = null
detail.value = null
}
})
// ===== 编辑 =====
const editing = ref(false)
const editForm = ref({ title: '', content: '', tagsInput: '', confidence: '' })
function startEdit() {
if (!detail.value) return
const k = detail.value.knowledge
editForm.value = {
title: k.title,
content: k.content,
tagsInput: parseTags(k.tags).join(', '),
confidence: k.confidence ?? '',
}
editing.value = true
}
function cancelEdit() {
editing.value = false
}
async function saveEdit() {
if (!detail.value || !editForm.value.title.trim()) return
const tags = editForm.value.tagsInput.split(',').map(t => t.trim()).filter(Boolean)
await store.update(detail.value.knowledge.id, {
title: editForm.value.title.trim(),
content: editForm.value.content,
tags: JSON.stringify(tags),
confidence: editForm.value.confidence, // 空串=清空(后端映射 None),非 undefined
})
editing.value = false
// 重新拉详情
await selectKnowledge(detail.value.knowledge.id)
}
// ===== 审核操作(收件箱) =====
async function publishCurrent() {
if (!detail.value) return
await store.updateStatus(detail.value.knowledge.id, 'published')
selectedId.value = null
detail.value = null
}
async function rejectCurrent() {
if (!detail.value) return
await store.archive(detail.value.knowledge.id)
selectedId.value = null
detail.value = null
}
async function archiveCurrent() {
if (!detail.value) return
await store.archive(detail.value.knowledge.id)
selectedId.value = null
detail.value = null
}
// ===== 新增对话框 =====
const showCreateModal = ref(false)
const form = ref({ kind: 'pitfall', title: '', content: '', tagsInput: '', confidence: '' })
function openCreateModal() {
form.value = { kind: 'pitfall', title: '', content: '', tagsInput: '', confidence: '' }
showCreateModal.value = true
}
function closeCreateModal() {
showCreateModal.value = false
}
async function submitCreate() {
const tags = form.value.tagsInput.split(',').map(t => t.trim()).filter(Boolean)
await store.create({
kind: form.value.kind,
title: form.value.title.trim(),
content: form.value.content.trim(),
tags: JSON.stringify(tags),
confidence: form.value.confidence || undefined,
})
showCreateModal.value = false
switchTopTab('inbox')
}
// ===== 事件解析辅助 =====
function parseContext(e: KnowledgeEventRecord): Record<string, any> {
if (!e.context_json) return {}
try { return JSON.parse(e.context_json) } catch { return {} }
}
function refConvTitle(e: KnowledgeEventRecord): string {
return parseContext(e).conv_title || parseContext(e).conv_id || ''
}
function refQuery(e: KnowledgeEventRecord): string {
return parseContext(e).query || ''
}
// 溯源:提取事件(created/extracted)
const extractedEvent = computed(() => detail.value?.events.find(e => e.event_type === 'extracted'))
const createdEvent = computed(() => detail.value?.events.find(e => e.event_type === 'created'))
const originMethod = computed(() => {
if (extractedEvent.value) return t('knowledge.originExtracted')
if (createdEvent.value) return t('knowledge.originManual')
return t('knowledge.originUnknown')
})
const originConvTitle = computed(() => {
if (!extractedEvent.value) return ''
const ctx = parseContext(extractedEvent.value)
return ctx.conv_title || ''
})
const originTime = computed(() => {
const e = extractedEvent.value || createdEvent.value
return e ? relativeTime(e.timestamp) : ''
})
const reasoningText = computed(() => {
// 优先取主表 reasoning 字段,降级取 extracted 事件 context
if (detail.value?.knowledge.reasoning) return detail.value.knowledge.reasoning
if (extractedEvent.value) return parseContext(extractedEvent.value).reasoning || ''
return ''
})
// 引用事件(倒序)
const referenceEvents = computed(() =>
(detail.value?.events ?? []).filter(e => e.event_type === 'referenced').reverse()
)
// 生命周期时间线节点(正序 + 未来占位)
const timelineNodes = computed(() => {
const events = detail.value?.events ?? []
const nodes = events.map(e => {
const ctx = parseContext(e)
let icon = '•', label = e.event_type, summary = ''
if (e.event_type === 'extracted') {
icon = '🤖'; label = t('knowledge.timeline.extracted')
summary = ctx.conv_title ? t('knowledge.timeline.summarySource', { title: ctx.conv_title }) : ''
} else if (e.event_type === 'created') {
icon = '✍️'; label = t('knowledge.timeline.created')
} else if (e.event_type === 'status_changed') {
const to = ctx.to
if (to === 'published') { icon = '✅'; label = t('knowledge.timeline.published') }
else if (to === 'archived') { icon = '🗄️'; label = t('knowledge.timeline.archived') }
else if (to === 'pending_review') { icon = '👀'; label = t('knowledge.timeline.enterReview') }
else { icon = '🔄'; label = t('knowledge.timeline.statusChanged', { to }) }
} else if (e.event_type === 'referenced') {
icon = '🔄'; label = t('knowledge.timeline.referenced')
summary = ctx.conv_title ? t('knowledge.timeline.summaryConv', { title: ctx.conv_title }) : ''
}
return { id: e.id, icon, label, summary, timestamp: e.timestamp }
})
return nodes
})
// ===== 标签/时间辅助 =====
function kindLabel(key: string): string {
const k = KNOWLEDGE_KINDS.find(c => c.key === key)
return k ? `${k.icon} ${kindText(key)}` : key
}
function kindIcon(key: string): string {
return KNOWLEDGE_KINDS.find(c => c.key === key)?.icon ?? '📄'
}
function statusLabel(status: string): string {
const key = `knowledge.status.${status}`
const msg = t(key)
return msg === key ? status : msg
}
function confidenceLabel(c: string): string {
const key = `knowledge.confidence.${c}`
const msg = t(key)
return msg === key ? c : msg
}
// 相对时间:复用 common.* 既有 key(justNow/minutesAgo/hoursAgo/dayAgo),30 天以上回退绝对值
function relativeTime(millisStr: string): string {
const ms = Number(millisStr)
if (!ms) return ''
const diff = Date.now() - ms
const min = Math.floor(diff / 60000)
if (min < 1) return t('common.justNow')
if (min < 60) return t('common.minutesAgo', { n: min })
const hr = Math.floor(min / 60)
if (hr < 24) return t('common.hoursAgo', { n: hr })
const day = Math.floor(hr / 24)
if (day < 30) return t('common.dayAgo', { n: day })
return t('common.ago', { time: new Date(ms).toLocaleDateString() })
}
onMounted(() => {
store.loadList()
store.loadCandidates()
})
</script>
<style scoped>
.knowledge { padding: 16px 20px 20px; }
.knowledge { padding: 16px 20px 20px; height: 100%; display: flex; flex-direction: column; }
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
display: flex; justify-content: space-between; align-items: center;
margin-bottom: var(--df-gap-page);
}
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
.header-actions { display: flex; gap: 8px; }
/* ===== 顶层 Tab ===== */
.top-tabs { display: flex; gap: 4px; margin-bottom: var(--df-gap-page); border-bottom: 0.5px solid var(--df-border); }
.top-tab {
padding: 8px 16px; border: none; background: transparent; color: var(--df-text-secondary);
font-size: 13px; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.15s;
display: flex; align-items: center; gap: 6px;
}
.top-tab:hover { color: var(--df-text); }
.top-tab.active { color: var(--df-accent); border-bottom-color: var(--df-accent); }
.inbox-badge {
font-size: 11px; background: var(--df-danger); color: #fff;
padding: 1px 6px; border-radius: var(--df-radius); min-width: 16px; text-align: center;
}
/* ===== 左右分栏 ===== */
.kn-layout {
display: grid; grid-template-columns: 360px 1fr; gap: var(--df-gap-page);
flex: 1; min-height: 0;
}
.kn-list-panel, .kn-detail-panel {
background: var(--df-bg-panel, var(--df-bg-card));
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
overflow: hidden; display: flex; flex-direction: column;
}
.kn-detail-panel { overflow-y: auto; padding: 20px; }
/* 左侧搜索 + 分类 */
.search-bar { padding: 12px 12px 0; }
.search-input {
width: 100%; padding: 8px 12px; background: var(--df-bg-card);
border: 0.5px solid var(--df-border); border-radius: var(--df-radius);
color: var(--df-text); font-size: 13px; outline: none; box-sizing: border-box;
}
.search-input:focus { border-color: var(--df-accent); }
.category-tabs { display: flex; flex-wrap: wrap; gap: 4px; padding: 10px 12px; }
.cat-chip {
display: flex; align-items: center; gap: 4px; padding: 4px 8px;
border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm);
background: transparent; color: var(--df-text-secondary); font-size: 12px; cursor: pointer;
}
.cat-chip:hover { background: var(--df-bg-card); }
.cat-active { background: var(--df-accent); color: #fff; border-color: var(--df-accent); }
.cat-count { font-size: 10px; opacity: 0.8; }
/* 左侧卡片列表 */
.list-cards { flex: 1; overflow-y: auto; padding: 8px 12px 12px; display: flex; flex-direction: column; gap: 8px; }
.kn-card {
background: var(--df-bg-card); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius); padding: 10px 12px; cursor: pointer; transition: all 0.15s;
}
.kn-card:hover { border-color: var(--df-accent); }
.kn-card.selected { border-color: var(--df-accent); box-shadow: inset 3px 0 0 var(--df-accent); }
.kn-card-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; margin-bottom: 6px; }
.kn-card-title { font-size: 13px; font-weight: 500; color: var(--df-text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.kn-card-desc {
font-size: 12px; color: var(--df-text-secondary); line-height: 1.4; margin-bottom: 6px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.kn-card-meta { display: flex; gap: 8px; align-items: center; font-size: 11px; color: var(--df-text-dim); flex-wrap: wrap; }
.meta-kind { background: rgba(90,99,128,0.2); padding: 1px 6px; border-radius: var(--df-radius-sm); }
.meta-reuse { color: var(--df-info); }
.list-empty { text-align: center; padding: 40px 0; color: var(--df-text-dim); font-size: 13px; }
/* ===== 详情面板 ===== */
.detail-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--df-text-dim); gap: 12px; }
.detail-empty-icon { font-size: 40px; }
.detail-section { padding-bottom: 18px; margin-bottom: 18px; border-bottom: 0.5px solid var(--df-border); }
.detail-section:last-child { border-bottom: none; margin-bottom: 0; }
.detail-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; margin-bottom: 12px; }
.detail-title-row { display: flex; align-items: center; gap: 8px; min-width: 0; flex: 1; }
.detail-kind-icon { font-size: 20px; }
.detail-title { font-size: 18px; font-weight: 500; color: var(--df-text); word-break: break-word; }
.detail-actions { display: flex; gap: 6px; flex-shrink: 0; }
.detail-badges { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; font-size: 11px; }
.badge-plain { color: var(--df-text-dim); }
.detail-field { margin-bottom: 12px; }
.detail-field label { display: block; font-size: 12px; color: var(--df-text-secondary); margin-bottom: 4px; }
.detail-content { font-size: 13px; color: var(--df-text); line-height: 1.6; white-space: pre-wrap; }
.detail-tags { display: flex; gap: 6px; flex-wrap: wrap; }
.tag { font-size: 11px; padding: 2px 8px; border-radius: var(--df-radius-xs); background: rgba(108,99,255,0.1); color: var(--df-accent); }
.muted { color: var(--df-text-dim); font-size: 12px; }
/* 状态徽章 / 置信度 */
.kn-card-status { font-size: 11px; font-weight: 500; padding: 2px 8px; border-radius: var(--df-radius-xs); white-space: nowrap; }
.st-candidate { background: rgba(255,217,61,0.15); color: var(--df-warning); }
.st-pending_review { background: rgba(108,159,255,0.15); color: var(--df-info); }
.st-published { background: rgba(100,255,218,0.15); color: var(--df-success); }
.st-archived { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
.conf-high { background: rgba(100,255,218,0.15); color: var(--df-success); padding: 1px 6px; border-radius: var(--df-radius-xs); }
.conf-medium { background: rgba(255,217,61,0.15); color: var(--df-warning); padding: 1px 6px; border-radius: var(--df-radius-xs); }
.conf-low { background: rgba(255,107,107,0.15); color: var(--df-danger); padding: 1px 6px; border-radius: var(--df-radius-xs); }
/* 溯源 */
.section-title { font-size: 13px; font-weight: 500; color: var(--df-text); margin-bottom: 10px; }
.trace-row { display: flex; gap: 12px; font-size: 12px; margin-bottom: 6px; }
.trace-label { color: var(--df-text-dim); min-width: 72px; }
.reasoning-box {
font-size: 13px; color: var(--df-text-secondary); line-height: 1.6;
background: var(--df-bg-card); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius); padding: 10px 12px; white-space: pre-wrap;
}
/* 引用 */
.ref-list { display: flex; flex-direction: column; gap: 8px; }
.ref-item {
display: flex; gap: 10px; align-items: center; font-size: 12px;
padding: 6px 10px; background: var(--df-bg-card); border-radius: var(--df-radius-sm);
}
.ref-time { color: var(--df-text-dim); white-space: nowrap; }
.ref-conv { color: var(--df-text); }
.ref-query { color: var(--df-text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.load-more { align-self: flex-start; margin-top: 4px; }
/* 生命周期时间线 */
.timeline { display: flex; flex-direction: column; }
.tl-node { display: flex; gap: 12px; position: relative; padding-bottom: 4px; }
.tl-dot {
width: 28px; height: 28px; border-radius: 50%; flex-shrink: 0;
display: flex; align-items: center; justify-content: center; font-size: 13px;
background: var(--df-bg-card); border: 0.5px solid var(--df-border); z-index: 1;
}
.tl-line { position: absolute; left: 14px; top: 28px; bottom: -4px; width: 0.5px; background: var(--df-border); }
.tl-body { padding-top: 4px; }
.tl-label { font-size: 13px; color: var(--df-text); }
.tl-time { font-size: 11px; color: var(--df-text-dim); }
.tl-summary { font-size: 11px; color: var(--df-text-secondary); margin-top: 2px; }
/* ===== 编辑输入 ===== */
.edit-input {
width: 100%; padding: 8px 10px; background: var(--df-bg-card);
border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm);
color: var(--df-text); font-size: 13px; outline: none; box-sizing: border-box;
}
.edit-input:focus { border-color: var(--df-accent); }
.edit-input-title { font-size: 16px; font-weight: 500; }
.edit-textarea { min-height: 100px; resize: vertical; font-family: inherit; }
/* ===== 按钮 ===== */
.btn {
padding: 8px 16px;
border: none;
border-radius: var(--df-radius-sm);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
padding: 8px 16px; border: none; border-radius: var(--df-radius-sm);
font-size: 13px; cursor: pointer; transition: all 0.15s;
}
.btn-sm { padding: 4px 10px; font-size: 12px; }
.btn-primary { background: var(--df-accent); color: #fff; }
.btn-primary:hover { background: var(--df-accent-hover); }
.btn-accent { background: rgba(100,255,218,0.15); color: var(--df-success); border: 0.5px solid rgba(100,255,218,0.3); }
.btn-accent:hover { background: rgba(100,255,218,0.25); }
.btn-primary:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
/* ===== 搜索栏 ===== */
.search-bar { margin-bottom: var(--df-gap-page); }
.search-input {
width: 100%;
max-width: 480px;
padding: 10px 16px;
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
color: var(--df-text);
font-size: 14px;
outline: none;
transition: border-color 0.2s;
/* ===== 新增对话框 ===== */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.5);
display: flex; align-items: center; justify-content: center; z-index: 1000;
}
.search-input::placeholder { color: var(--df-text-dim); }
.search-input:focus { border-color: var(--df-accent); }
/* ===== 分类标签 ===== */
.category-tabs {
display: flex;
gap: 8px;
margin-bottom: var(--df-gap-page);
flex-wrap: wrap;
.modal-box {
background: var(--df-bg-panel, var(--df-bg-card)); border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg); padding: 24px; width: 90%; max-width: 520px;
}
.tab-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius);
background: transparent;
color: var(--df-text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.15s;
.modal-title { font-size: 16px; font-weight: 500; color: var(--df-text); margin-bottom: 16px; }
.form-row { margin-bottom: 12px; }
.form-row label { display: block; font-size: 12px; color: var(--df-text-secondary); margin-bottom: 4px; }
.form-input, .form-select {
width: 100%; padding: 8px 10px; background: var(--df-bg-card);
border: 0.5px solid var(--df-border); border-radius: var(--df-radius-sm);
color: var(--df-text); font-size: 13px; outline: none; box-sizing: border-box;
}
.tab-btn:hover { background: var(--df-bg-card); color: var(--df-text); }
.tab-active {
background: var(--df-accent) !important;
color: #fff !important;
border-color: var(--df-accent) !important;
}
.tab-icon { font-size: 14px; }
.tab-count {
font-size: 11px;
background: rgba(255,255,255,0.15);
padding: 1px 6px;
border-radius: var(--df-radius);
min-width: 18px;
text-align: center;
}
.tab-active .tab-count { background: rgba(255,255,255,0.25); }
/* ===== 知识卡片网格 ===== */
.knowledge-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
gap: var(--df-gap-grid);
}
.knowledge-card {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
padding: var(--df-pad-panel);
transition: border-color 0.15s, transform 0.15s;
}
.knowledge-card:hover {
border-color: var(--df-accent);
transform: translateY(-1px);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.card-title { font-size: 14px; font-weight: 500; color: var(--df-text); }
.card-score {
font-size: 12px;
font-weight: 500;
padding: 2px 8px;
border-radius: var(--df-radius-xs);
}
.score-high { background: rgba(100,255,218,0.15); color: var(--df-success); }
.score-mid { background: rgba(255,217,61,0.15); color: var(--df-warning); }
.score-low { background: rgba(255,107,107,0.15); color: var(--df-danger); }
.card-desc {
font-size: 13px;
color: var(--df-text-secondary);
line-height: 1.5;
margin-bottom: 12px;
}
.card-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
.tag {
font-size: 11px;
padding: 2px 8px;
border-radius: var(--df-radius-xs);
background: rgba(108,99,255,0.1);
color: var(--df-accent);
}
.card-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 11px;
color: var(--df-text-dim);
}
.card-reuse { color: var(--df-info); }
.card-category {
background: rgba(90,99,128,0.2);
padding: 2px 6px;
border-radius: var(--df-radius-sm);
}
/* ===== 空状态 ===== */
.empty-state {
text-align: center;
padding: 60px 0;
}
.empty-icon { font-size: 48px; margin-bottom: 12px; }
.empty-text { font-size: 14px; color: var(--df-text-dim); }
</style>
.form-input:focus, .form-select:focus { border-color: var(--df-accent); }
.form-textarea { min-height: 80px; resize: vertical; font-family: inherit; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
</style>

View File

@@ -3,35 +3,36 @@
<!-- 页面头部 -->
<header class="page-header">
<div class="header-left">
<router-link to="/projects" class="back-link"> 项目列表</router-link>
<router-link to="/projects" class="back-link">{{ $t('projectDetail.backToList') }}</router-link>
<h1>{{ currentProject?.name ?? '...' }}</h1>
<span class="stage-badge" :class="'stage-' + stageKey">{{ statusLabel }}</span>
<span class="stage-badge" :class="'stage-' + stageKey">{{ $t(statusLabel) }}</span>
</div>
<div class="header-actions">
<button class="btn btn-ghost" @click="handleSync">🔄 同步</button>
<button class="btn btn-primary" @click="showNewTaskModal = true">+ 新任务</button>
<button class="btn btn-ghost" @click="handleSync">{{ $t('projectDetail.sync') }}</button>
<button class="btn btn-danger" @click="handleDelete">{{ $t('projectDetail.delete') }}</button>
<button class="btn btn-primary" @click="showNewTaskModal = true">{{ $t('projectDetail.newTask') }}</button>
</div>
</header>
<!-- 新建任务模态框 -->
<div v-if="showNewTaskModal" class="modal-overlay" @click.self="showNewTaskModal = false">
<div class="modal-box">
<h3 class="modal-title">新建任务</h3>
<h3 class="modal-title">{{ $t('projectDetail.newTaskTitle') }}</h3>
<div class="modal-field">
<label>任务标题</label>
<input v-model="newTaskTitle" placeholder="输入任务标题" @keyup.enter="submitNewTask" />
<label>{{ $t('projectDetail.taskTitleLabel') }}</label>
<input v-model="newTaskTitle" :placeholder="$t('projectDetail.taskTitlePlaceholder')" @keyup.enter="submitNewTask" />
</div>
<div class="modal-field">
<label>描述</label>
<textarea v-model="newTaskDesc" placeholder="简要描述" rows="3"></textarea>
<label>{{ $t('projectDetail.descLabel') }}</label>
<textarea v-model="newTaskDesc" :placeholder="$t('projectDetail.descPlaceholder')" rows="3"></textarea>
</div>
<div class="modal-field">
<label>分支名称</label>
<label>{{ $t('projectDetail.branchLabel') }}</label>
<input v-model="newTaskBranch" placeholder="feature/xxx" />
</div>
<div class="modal-actions">
<button class="btn btn-ghost" @click="showNewTaskModal = false">取消</button>
<button class="btn btn-primary" @click="submitNewTask" :disabled="!newTaskTitle.trim()">确认创建</button>
<button class="btn btn-ghost" @click="showNewTaskModal = false">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary" @click="submitNewTask" :disabled="!newTaskTitle.trim()">{{ $t('projectDetail.confirmCreate') }}</button>
</div>
</div>
</div>
@@ -51,7 +52,7 @@
<span v-if="idx < currentStageIndex"></span>
<span v-else>{{ idx + 1 }}</span>
</div>
<div class="stage-label">{{ stage.label }}</div>
<div class="stage-label">{{ $t('projectDetail.' + stage.labelKey) }}</div>
<div v-if="idx < stages.length - 1" class="stage-connector" :class="{ 'connector-done': idx < currentStageIndex }"></div>
</div>
</div>
@@ -61,27 +62,60 @@
<!-- 左栏项目信息 -->
<section class="panel">
<div class="panel-header">
<h2>📋 项目信息</h2>
<h2>{{ $t('projectDetail.infoTitle') }}</h2>
</div>
<div class="project-info" v-if="currentProject">
<div class="info-item">
<span class="label">来源想法</span>
<span class="label">{{ $t('projectDetail.sourceIdea') }}</span>
<router-link v-if="currentProject.idea_id" :to="`/ideas/${currentProject.idea_id}`" class="idea-link">
查看想法详情
{{ $t('projectDetail.viewIdea') }}
</router-link>
<span v-else class="no-idea">原始想法已删除</span>
<span v-else class="no-idea">{{ $t('projectDetail.ideaDeleted') }}</span>
</div>
<!-- 代码目录(绑定 + 重定位) -->
<div class="info-item">
<span class="label">{{ $t('projectDetail.codeDirLabel') }}</span>
<div v-if="currentProject.path" class="path-row">
<span class="path-text" :title="currentProject.path">{{ currentProject.path }}</span>
<button class="btn btn-ghost btn-sm" type="button" @click="relocateDir">{{ $t('projectDetail.relocateDir') }}</button>
</div>
<div v-else class="path-row">
<span class="no-idea">{{ $t('projectDetail.noDirBound') }}</span>
<button class="btn btn-ghost btn-sm" type="button" @click="relocateDir">{{ $t('projectDetail.bindDir') }}</button>
</div>
</div>
<!-- 目录状态(仅绑定时显示) -->
<div class="info-item" v-if="currentProject.path">
<span class="label">{{ $t('projectDetail.dirStatusLabel') }}</span>
<span :class="pathExists === false ? 'path-missing' : 'path-ok'">
{{ pathExists === null ? '检测中…' : pathExists ? '✓ 目录存在' : '⚠ 目录已移走,建议重定位' }}
</span>
</div>
<!-- 技术栈 -->
<div class="info-item" v-if="parseStack(currentProject.stack).length">
<span class="label">{{ $t('projectDetail.techStackLabel') }}</span>
<div class="info-tags">
<span class="tech-tag" v-for="t in parseStack(currentProject.stack)" :key="t">{{ t }}</span>
</div>
</div>
<div class="info-item">
<span class="label">创建时间</span>
<span class="label">{{ $t('projectDetail.createdAt') }}</span>
<span>{{ formatDate(currentProject.created_at) }}</span>
</div>
<div class="info-item">
<span class="label">更新时间</span>
<span class="label">{{ $t('projectDetail.updatedAt') }}</span>
<span>{{ formatDate(currentProject.updated_at) }}</span>
</div>
<div class="info-item">
<span class="label">项目状态</span>
<span class="status-badge" :class="currentProject.status">{{ currentProject.status }}</span>
<span class="label">{{ $t('projectDetail.description') }}</span>
<span>{{ currentProject.description || '—' }}</span>
</div>
<div class="info-item">
<span class="label">{{ $t('projectDetail.projectStatus') }}</span>
<span class="status-badge" :class="currentProject.status">{{ $t(statusLabel) }}</span>
</div>
</div>
</section>
@@ -89,14 +123,14 @@
<!-- 左栏任务列表 -->
<section class="panel">
<div class="panel-header">
<h2>🔀 任务列表</h2>
<span class="task-count">{{ projectTasks.length }} 个任务</span>
<h2>{{ $t('projectDetail.taskListTitle') }}</h2>
<span class="task-count">{{ $t('projectDetail.taskCount', { n: projectTasks.length }) }}</span>
</div>
<div class="task-list">
<div class="task-card" v-for="task in projectTasks" :key="task.id">
<div class="task-top">
<span class="task-title">{{ task.title }}</span>
<span class="task-status" :class="taskStatusClass(task.status)">{{ taskStatusLabel(task.status) }}</span>
<span class="task-status" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</span>
</div>
<div class="task-branch" v-if="task.branch_name">
<span class="branch-icon"></span>
@@ -109,7 +143,7 @@
</div>
</div>
</div>
<div v-if="projectTasks.length === 0" class="empty-hint">暂无任务</div>
<div v-if="projectTasks.length === 0" class="empty-hint">{{ $t('projectDetail.emptyTasks') }}</div>
</section>
<!-- 右栏 -->
@@ -117,8 +151,8 @@
<!-- 工作流执行日志 -->
<section class="panel">
<div class="panel-header">
<h2>📋 工作流日志</h2>
<button class="btn btn-ghost btn-sm" @click="runDemoWorkflow" :disabled="workflowRunning"> 运行测试工作流</button>
<h2>{{ $t('projectDetail.workflowLogTitle') }}</h2>
<button class="btn btn-ghost btn-sm" @click="runDemoWorkflow" :disabled="workflowRunning">{{ $t('projectDetail.runDemoWorkflow') }}</button>
</div>
<div class="log-list" ref="logListRef">
<div class="log-item" v-for="(evt, idx) in formattedEvents" :key="idx" :class="'log-' + evt.level">
@@ -126,36 +160,7 @@
<span class="log-level">{{ evt.level }}</span>
<span class="log-msg">{{ evt.message }}</span>
</div>
<div v-if="formattedEvents.length === 0" class="empty-hint">点击"运行测试工作流"查看实时日志</div>
</div>
</section>
<!-- 项目上下文 -->
<section class="panel">
<div class="panel-header">
<h2>🏷 项目信息</h2>
</div>
<div class="info-grid">
<div class="info-row">
<span class="info-label">状态</span>
<span class="info-value">{{ statusLabel }}</span>
</div>
<div class="info-row">
<span class="info-label">描述</span>
<span class="info-value">{{ currentProject?.description ?? '—' }}</span>
</div>
<div class="info-row">
<span class="info-label">来源想法</span>
<span class="info-value">{{ currentProject?.idea_id ?? '—' }}</span>
</div>
<div class="info-row">
<span class="info-label">创建时间</span>
<span class="info-value">{{ formatDate(currentProject?.created_at ?? '') }}</span>
</div>
<div class="info-row">
<span class="info-label">更新时间</span>
<span class="info-value">{{ formatDate(currentProject?.updated_at ?? '') }}</span>
</div>
<div v-if="formattedEvents.length === 0" class="empty-hint">{{ $t('projectDetail.emptyWorkflowLog') }}</div>
</div>
</section>
</div>
@@ -164,18 +169,18 @@
<!-- 人工审批对话框 -->
<div v-if="showApprovalDialog" class="modal-overlay">
<div class="modal-box" style="width: 500px">
<h3 class="modal-title">需要人工审批</h3>
<h3 class="modal-title">{{ $t('projectDetail.approvalTitle') }}</h3>
<div v-if="store.pendingApproval">
<h3>{{ store.pendingApproval.title }}</h3>
<p style="margin: 16px 0; color: var(--df-text-secondary);">{{ store.pendingApproval.description }}</p>
<div style="margin-bottom: 20px;">
<p style="margin-bottom: 8px; font-size: 14px;">请选择操作</p>
<p style="margin-bottom: 8px; font-size: 14px;">{{ $t('projectDetail.approvalHint') }}</p>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button
v-for="option in store.pendingApproval.options"
:key="option"
v-for="(option, idx) in store.pendingApproval.options"
:key="idx"
class="btn"
:class="option === '同意' ? 'btn-primary' : 'btn-ghost'"
:class="idx === 0 ? 'btn-primary' : 'btn-ghost'"
@click="handleApproval(option)"
>
{{ option }}
@@ -184,7 +189,7 @@
</div>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" @click="showApprovalDialog = false">稍后处理</button>
<button class="btn btn-ghost" @click="showApprovalDialog = false">{{ $t('projectDetail.approvalLater') }}</button>
</div>
</div>
</div>
@@ -193,76 +198,48 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { open } from '@tauri-apps/plugin-dialog'
import { useProjectStore } from '../stores/project'
import { projectApi } from '../api'
import { formatDate } from '../utils/time'
import { projectStatusLabel, projectStageInfo, taskStatusLabel, taskStatusClass } from '../constants/project'
import type { WorkflowEventPayload } from '../api/types'
const route = useRoute()
const router = useRouter()
const store = useProjectStore()
const { t, locale } = useI18n()
const logListRef = ref<HTMLElement | null>(null)
const showApprovalDialog = ref(false)
let unlisten: (() => void) | null = null
// ── 阶段定义 ──
// ── 阶段定义(labelKey 对应 i18n projectDetail.stage*) ──
const stages = [
{ key: 'idea', label: '💡 想法' },
{ key: 'requirement', label: '📋 需求' },
{ key: 'coding', label: '💻 编码' },
{ key: 'testing', label: '🧪 测试' },
{ key: 'release', label: '🚀 发布' },
{ key: 'idea', labelKey: 'stageIdea' },
{ key: 'requirement', labelKey: 'stageRequirement' },
{ key: 'coding', labelKey: 'stageCoding' },
{ key: 'testing', labelKey: 'stageTesting' },
{ key: 'release', labelKey: 'stageRelease' },
]
// ── 状态映射 ──
const stageMap: Record<string, number> = {
planning: 0, in_progress: 2, testing: 3, completed: 4, paused: 2, cancelled: 0,
}
const statusLabels: Record<string, string> = {
planning: '📐 规划中',
in_progress: '💻 进行中',
paused: '⏸ 已暂停',
completed: '✅ 已完成',
cancelled: '❌ 已取消',
}
const taskStatusLabels: Record<string, string> = {
todo: '📋 待开始',
in_progress: '🔄 进行中',
review_ready: '👀 待审查',
merged: '✅ 已合并',
abandoned: '❌ 已放弃',
}
const taskStatusClasses: Record<string, string> = {
todo: 'status-todo',
in_progress: 'status-progress',
review_ready: 'status-review',
merged: 'status-done',
abandoned: 'status-todo',
}
// ── 当前项目 ──
// 状态文案/阶段进度统一走 ../constants/project(与 Projects/Tasks/Dashboard 一致,
// 根治此前 stageMap 含不存在的 testing 状态、三套映射互相矛盾)
const projectId = computed(() => route.params.id as string)
const currentProject = computed(() =>
store.projects.find(p => p.id === projectId.value)
)
const stageKey = computed(() => currentProject.value?.status ?? 'planning')
const statusLabel = computed(() => statusLabels[stageKey.value] ?? stageKey.value)
const currentStageIndex = computed(() => stageMap[stageKey.value] ?? 0)
const statusLabel = computed(() => projectStatusLabel(stageKey.value))
const currentStageIndex = computed(() => projectStageInfo(stageKey.value).stepIndex)
// ── 任务 ──
const projectTasks = computed(() =>
store.tasks.filter(t => t.project_id === projectId.value)
)
function taskStatusLabel(status: string) {
return taskStatusLabels[status] ?? status
}
function taskStatusClass(status: string) {
return taskStatusClasses[status] ?? 'status-todo'
}
// taskStatusLabel / taskStatusClass 由 ../constants/project 提供
// ── 工作流 ──
const workflowRunning = ref(false)
@@ -302,7 +279,7 @@ const formattedEvents = computed<LogEntry[]>(() => {
const type = ev.type ?? 'unknown'
const level = type.includes('error') || type.includes('fail') ? 'error'
: type.includes('warn') ? 'warn' : 'info'
const time = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit', second: '2-digit' })
const time = new Date().toLocaleTimeString(locale.value, { hour: '2-digit', minute: '2-digit', second: '2-digit' })
const message = ev.label
? `[${ev.label}] ${type}: ${ev.output ?? ev.code ?? ev.message ?? JSON.stringify(ev)}`
: `${type}: ${ev.output ?? ev.code ?? ev.message ?? JSON.stringify(ev)}`
@@ -318,12 +295,13 @@ const newTaskBranch = ref('')
async function submitNewTask() {
if (!newTaskTitle.value.trim()) return
await store.createTask({
const r = await store.createTask({
project_id: projectId.value,
title: newTaskTitle.value.trim(),
description: newTaskDesc.value.trim(),
branch_name: newTaskBranch.value.trim() || undefined,
})
if (!r) return // 失败已 toast,保持弹窗不关
showNewTaskModal.value = false
newTaskTitle.value = ''
newTaskDesc.value = ''
@@ -332,7 +310,58 @@ async function submitNewTask() {
// ── 同步 ──
async function handleSync() {
await Promise.all([store.loadProjects(), store.loadTasks(projectId.value)])
await Promise.all([store.loadProjects(), store.loadTasks()])
await checkPath()
}
// ── 代码目录绑定 ──
const pathExists = ref<boolean | null>(null)
// 解析技术栈 JSON 数组字符串(防崩)
function parseStack(stack: string | null): string[] {
if (!stack) return []
try {
const arr = JSON.parse(stack)
return Array.isArray(arr) ? arr : []
} catch {
return []
}
}
// 检查绑定目录是否存在(详情页「目录是否还在」)
async function checkPath() {
const p = currentProject.value
if (!p?.path) { pathExists.value = null; return }
try {
pathExists.value = await projectApi.checkPathExists(p.path)
} catch {
pathExists.value = null
}
}
// 重定位/绑定目录(选目录 → 查重 → 确认 → 后端重探测 stack)
async function relocateDir() {
const p = currentProject.value
if (!p) return
try {
const selected = await open({ directory: true, multiple: false })
if (!selected || Array.isArray(selected)) return
const dir = selected as string
// 查重(排除自身)
try {
const conflict = await projectApi.checkBinding(dir, p.id)
if (conflict) {
alert(`该目录已被项目「${conflict.name}」绑定`)
return
}
} catch { /* ignore */ }
if (!confirm(`将项目目录${currentProject.value?.path ? '重定位' : '绑定'}到:\n${dir}\n\n技术栈将重新探测。确认?`)) return
await store.relocateProjectPath(p.id, dir)
await checkPath()
} catch (e: any) {
console.error('重定位失败:', e)
alert('重定位失败: ' + (e?.toString() ?? '未知错误'))
}
}
// ── 审批处理 ──
@@ -341,6 +370,20 @@ async function handleApproval(decision: string) {
showApprovalDialog.value = false
}
// ── 删除项目(软删 → 回收站,可恢复)──
async function handleDelete() {
const p = currentProject.value
if (!p) return
if (!confirm(t('projectDetail.confirmDelete', { name: p.name }))) return
await store.deleteProject(p.id)
router.push('/projects')
}
// 日志自动滚到底部(让 logListRef 不再是死 ref)
watch(formattedEvents, () => {
if (logListRef.value) logListRef.value.scrollTop = logListRef.value.scrollHeight
})
// 监听审批请求
watch(() => store.pendingApproval, (newApproval) => {
if (newApproval) {
@@ -354,10 +397,14 @@ watch(() => store.pendingApproval, (newApproval) => {
// ── 生命周期 ──
onMounted(async () => {
await store.loadProjects()
await store.loadTasks(projectId.value)
unlisten = (await store.startEventListener()) as any
await store.loadTasks() // 全量加载,详情页用 computed 过滤本项目(避免覆盖全局 tasks 单例)
unlisten = await store.startEventListener()
await checkPath()
})
// 目录变更(重定位后)重新检测存在性
watch(() => currentProject.value?.path, () => { checkPath() })
onUnmounted(() => {
if (unlisten) {
unlisten()
@@ -413,6 +460,21 @@ onUnmounted(() => {
background: rgba(108,99,255,0.1);
color: var(--df-accent);
}
/* ===== 代码目录 / 技术栈 ===== */
.path-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.path-text {
font-size: 12px; font-family: 'SF Mono', 'Fira Code', monospace;
color: var(--df-text-secondary); word-break: break-all;
}
.path-ok { color: var(--df-success); font-size: 12px; }
.path-missing { color: var(--df-danger); font-size: 12px; }
.info-tags { display: flex; flex-wrap: wrap; gap: 6px; }
.info-tags .tech-tag {
font-size: 11px; padding: 2px 8px; border-radius: var(--df-radius-xs);
background: rgba(108, 99, 255, 0.08); color: var(--df-text-secondary);
border: 0.5px solid var(--df-border);
}
</style>
<style scoped>
@@ -457,6 +519,8 @@ onUnmounted(() => {
.btn-ghost { background: transparent; color: var(--df-text-secondary); border: 0.5px solid var(--df-border); }
.btn-ghost:hover { background: var(--df-bg-card); color: var(--df-text); }
.btn-sm { padding: 4px 12px; font-size: 12px; }
.btn-danger { background: transparent; color: var(--df-danger); border: 0.5px solid rgba(240,101,101,0.4); }
.btn-danger:hover { background: rgba(240,101,101,0.12); }
/* ===== 阶段进度条 ===== */
.stage-pipeline {
@@ -609,17 +673,7 @@ onUnmounted(() => {
.log-error .log-level { color: var(--df-danger); }
.log-msg { color: var(--df-text-secondary); flex: 1; }
/* ===== 项目信息 ===== */
.info-grid { display: flex; flex-direction: column; gap: 0; }
.info-row {
display: flex;
justify-content: space-between;
padding: 10px 0;
border-bottom: 0.5px solid var(--df-border);
}
.info-row:last-child { border-bottom: none; }
.info-label { font-size: 13px; color: var(--df-text-dim); }
.info-value { font-size: 13px; color: var(--df-text-secondary); font-weight: 500; }
/* ===== 项目信息(左栏 .project-info/.info-item,右栏重复面板已删)===== */
/* ===== 模态框 ===== */
.modal-overlay {

View File

@@ -1,27 +1,70 @@
<template>
<div class="projects">
<header class="page-header">
<h1>📂 项目列表</h1>
<h1>{{ $t('projects.title') }}</h1>
<div class="header-actions">
<button class="btn btn-primary" @click="showCreateModal = true">+ 新建项目</button>
<button class="btn btn-ghost" @click="openTrash">{{ $t('projects.trash') }}</button>
<button class="btn btn-primary" @click="showCreateModal = true">{{ $t('projects.create') }}</button>
</div>
</header>
<!-- 新建项目模态框 -->
<div v-if="showCreateModal" class="modal-overlay" @click.self="showCreateModal = false">
<div class="modal-box">
<h3 class="modal-title">新建项目</h3>
<h3 class="modal-title">{{ $t('projects.createTitle') }}</h3>
<div class="modal-field">
<label>项目名称</label>
<input v-model="newName" placeholder="输入项目名称" @keyup.enter="submitCreate" />
<label>{{ $t('projects.nameLabel') }}</label>
<input v-model="newName" :placeholder="$t('projects.namePlaceholder')" @keyup.enter="submitCreate" />
</div>
<div class="modal-field">
<label>项目描述</label>
<textarea v-model="newDesc" placeholder="简要描述项目目标" rows="3"></textarea>
<label>{{ $t('projects.descLabel') }}</label>
<textarea v-model="newDesc" :placeholder="$t('projects.descPlaceholder')" rows="3"></textarea>
</div>
<div class="modal-field">
<label>{{ $t('projects.codeDirLabel') }}可选绑定后自动识别技术栈</label>
<div class="dir-row">
<input v-model="newPath" :placeholder="$t('projects.codeDirPlaceholder')" readonly />
<button class="btn btn-ghost btn-sm" type="button" @click="pickDir">{{ $t('projects.selectDir') }}</button>
<button v-if="newPath" class="btn btn-ghost btn-sm" type="button" @click="clearDir">{{ $t('projects.clearDir') }}</button>
</div>
<div v-if="pathWarning" class="path-warning"> {{ pathWarning }}</div>
<div v-if="newPath" class="scan-row">
<button class="btn btn-ghost btn-sm" type="button" @click="aiScan" :disabled="scanning">
{{ scanning ? 'AI 扫描中…' : '🤖 AI 扫描填信息' }}
</button>
<span v-if="scanProjectType" class="tech-tag">{{ scanProjectType }}</span>
</div>
<div v-if="scanError" class="path-warning"> {{ scanError }}</div>
<div v-if="detectedStack.length" class="card-tags" style="margin-top: 8px">
<span class="tech-tag" v-for="t in detectedStack" :key="t">{{ t }}</span>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" @click="showCreateModal = false">取消</button>
<button class="btn btn-primary" @click="submitCreate" :disabled="!newName.trim()">确认创建</button>
<button class="btn btn-ghost" @click="showCreateModal = false">{{ $t('common.cancel') }}</button>
<button class="btn btn-primary" @click="submitCreate" :disabled="!newName.trim()">{{ $t('projects.confirmCreate') }}</button>
</div>
</div>
</div>
<!-- 回收站模态框 -->
<div v-if="showTrashModal" class="modal-overlay" @click.self="showTrashModal = false">
<div class="modal-box" style="width: 520px">
<h3 class="modal-title">{{ $t('projects.trashTitle') }}</h3>
<div v-if="store.deletedProjects.length === 0" class="empty-state" style="padding: 30px 0">{{ $t('projects.trashEmpty') }}</div>
<div v-else class="trash-list">
<div class="trash-item" v-for="p in store.deletedProjects" :key="p.id">
<div class="trash-info">
<span class="trash-name">{{ p.name }}</span>
<span class="trash-date">{{ formatDate(p.updated_at) }} {{ $t('projects.movedIn') }}</span>
</div>
<div class="trash-actions">
<button class="btn btn-ghost btn-sm" @click="handleRestore(p.id)">{{ $t('projects.restore') }}</button>
<button class="btn btn-danger btn-sm" @click="handlePurge(p)">{{ $t('projects.purge') }}</button>
</div>
</div>
</div>
<div class="modal-actions">
<button class="btn btn-ghost" @click="showTrashModal = false">{{ $t('common.close') }}</button>
</div>
</div>
</div>
@@ -33,7 +76,10 @@
<div class="card-title-row">
<h2 class="card-name">{{ project.name }}</h2>
</div>
<span class="stage-badge" :class="stageClass(project.status)">{{ statusLabel(project.status) }}</span>
<div class="card-top-actions">
<span class="stage-badge" :class="stageClass(project.status)">{{ $t(statusLabel(project.status)) }}</span>
<button class="card-del-btn" :title="$t('projects.deleteTitle')" @click.stop="handleDelete(project)">🗑</button>
</div>
</div>
<p class="card-desc">{{ project.description }}</p>
@@ -49,12 +95,17 @@
<span>{{ formatDate(project.updated_at) }}</span>
</div>
</div>
<!-- 技术栈标签 -->
<div class="card-tags" v-if="parseStack(project.stack).length">
<span class="tech-tag" v-for="t in parseStack(project.stack)" :key="t">{{ t }}</span>
</div>
</div>
</div>
<!-- 空状态 -->
<div v-if="!store.loading && store.projects.length === 0" class="empty-state">
暂无项目点击右上角创建第一个项目
{{ $t('projects.empty') }}
</div>
</div>
</template>
@@ -62,50 +113,123 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { open } from '@tauri-apps/plugin-dialog'
import { useProjectStore } from '../stores/project'
import { projectApi } from '../api'
import { formatDate } from '../utils/time'
import { projectStatusLabel as statusLabel, projectBadgeClass as stageClass } from '../constants/project'
import type { ProjectRecord } from '../api/types'
const router = useRouter()
const store = useProjectStore()
const { t } = useI18n()
// ── 状态映射 ──
const statusLabels: Record<string, string> = {
planning: '📐 规划中',
in_progress: '💻 进行中',
paused: '⏸ 已暂停',
completed: '✅ 已完成',
cancelled: '❌ 已取消',
}
const statusStageClass: Record<string, string> = {
planning: 'stage-design',
in_progress: 'stage-coding',
paused: 'stage-testing',
completed: 'stage-release',
cancelled: 'stage-design',
}
function statusLabel(status: string) {
return statusLabels[status] ?? status
}
function stageClass(status: string) {
return statusStageClass[status] ?? 'stage-design'
}
// 状态映射统一走 ../constants/project(与 ProjectDetail/Tasks/Dashboard 一致)
// formatDate 由 ../utils/time 提供(统一毫秒字符串解析,根治 Invalid Date
// ── 新建项目 ──
const showCreateModal = ref(false)
const newName = ref('')
const newDesc = ref('')
const newPath = ref('')
const detectedStack = ref<string[]>([])
const pathWarning = ref('')
const scanning = ref(false)
const scanError = ref('')
const scanProjectType = ref('')
// 解析技术栈 JSON 数组字符串(防崩)
function parseStack(stack: string | null): string[] {
if (!stack) return []
try {
const arr = JSON.parse(stack)
return Array.isArray(arr) ? arr : []
} catch {
return []
}
}
// 选择目录 → 探测技术栈 + 查重
async function pickDir() {
try {
const selected = await open({ directory: true, multiple: false })
if (!selected || Array.isArray(selected)) return
newPath.value = selected as string
detectedStack.value = []
pathWarning.value = ''
// 探测技术栈(失败不阻塞,后端创建时再探)
try { detectedStack.value = await projectApi.scanStack(selected as string) } catch { /* ignore */ }
// 查重(已被其他项目绑定则警告,禁止创建)
try {
const conflict = await projectApi.checkBinding(selected as string)
if (conflict) pathWarning.value = `该目录已被项目「${conflict.name}」绑定`
} catch { /* ignore */ }
} catch (e) {
console.error('选择目录失败:', e)
}
}
function clearDir() {
newPath.value = ''
detectedStack.value = []
pathWarning.value = ''
scanning.value = false
scanError.value = ''
scanProjectType.value = ''
}
// AI 扫描目录 → 自动填描述/技术栈/项目类型(LLM 失败降级规则探测)
async function aiScan() {
if (!newPath.value) return
scanning.value = true
scanError.value = ''
scanProjectType.value = ''
try {
const result = await projectApi.scanWithAi(newPath.value)
detectedStack.value = result.stack
if (result.description) newDesc.value = result.description
scanProjectType.value = result.project_type ?? ''
if (!result.description) scanError.value = 'AI 未能生成描述,可手动填写'
} catch (e: any) {
scanError.value = e?.toString() ?? 'AI 扫描失败'
} finally {
scanning.value = false
}
}
async function submitCreate() {
if (!newName.value.trim()) return
await store.createProject(newName.value.trim(), newDesc.value.trim())
if (pathWarning.value) return // 目录冲突,禁止创建
const stack = detectedStack.value.length ? JSON.stringify(detectedStack.value) : undefined
const r = await store.createProject(newName.value.trim(), newDesc.value.trim(), undefined, newPath.value || undefined, stack)
if (!r) return // 失败已 toast,保持弹窗不关
showCreateModal.value = false
newName.value = ''
newDesc.value = ''
clearDir()
}
// ── 删除 / 回收站 ──
const showTrashModal = ref(false)
async function handleDelete(project: ProjectRecord) {
if (!confirm(t('projects.confirmDelete', { name: project.name }))) return
await store.deleteProject(project.id)
}
async function openTrash() {
await store.loadDeletedProjects()
showTrashModal.value = true
}
async function handleRestore(id: string) {
await store.restoreProject(id)
}
async function handlePurge(project: ProjectRecord) {
if (!confirm(t('projects.confirmPurge', { name: project.name }))) return
await store.purgeProject(project.id)
}
// ── 加载 ──
@@ -137,6 +261,9 @@ onMounted(() => {
}
.btn-primary { background: var(--df-accent); color: #fff; }
.btn-primary:hover { background: var(--df-accent-hover); }
.btn-danger { background: transparent; color: var(--df-danger); border: 0.5px solid rgba(240,101,101,0.4); }
.btn-danger:hover { background: rgba(240,101,101,0.12); }
.btn-sm { padding: 4px 10px; font-size: 12px; }
/* ===== 卡片网格 ===== */
.project-grid {
@@ -241,6 +368,12 @@ onMounted(() => {
border: 0.5px solid var(--df-border);
}
/* ===== 目录选择行 ===== */
.dir-row { display: flex; gap: 8px; align-items: center; }
.dir-row input { flex: 1; cursor: pointer; }
.path-warning { margin-top: 6px; font-size: 12px; color: var(--df-danger); }
.scan-row { display: flex; gap: 8px; align-items: center; margin-top: 8px; }
/* ===== 模态框 ===== */
.modal-overlay {
position: fixed; inset: 0;
@@ -279,6 +412,26 @@ onMounted(() => {
font-size: 14px; color: var(--df-text-dim);
}
/* ===== 卡片删除按钮 + 回收站 ===== */
.card-top-actions { display: flex; align-items: center; gap: 8px; }
.card-del-btn {
background: transparent; border: none; cursor: pointer;
font-size: 14px; padding: 2px 4px; opacity: 0;
transition: opacity 0.15s;
}
.project-card:hover .card-del-btn { opacity: 0.55; }
.card-del-btn:hover { opacity: 1 !important; }
.trash-list { display: flex; flex-direction: column; max-height: 400px; overflow-y: auto; }
.trash-item {
display: flex; justify-content: space-between; align-items: center;
padding: 10px 0; border-bottom: 0.5px solid var(--df-border);
}
.trash-item:last-child { border-bottom: none; }
.trash-info { display: flex; flex-direction: column; gap: 2px; }
.trash-name { font-size: 14px; color: var(--df-text); font-weight: 500; }
.trash-date { font-size: 11px; color: var(--df-text-dim); }
.trash-actions { display: flex; gap: 8px; }
/* ===== 响应式 ===== */
@media (max-width: 900px) {
.projects { padding: 16px; }

View File

@@ -1,82 +1,96 @@
<template>
<div class="settings">
<Transition name="toast">
<div v-if="toast.visible" class="toast" :class="'toast-' + toast.type">{{ toast.msg }}</div>
</Transition>
<Transition name="confirm">
<div v-if="confirmState.visible" class="confirm-mask" @click.self="answerConfirm(false)">
<div class="confirm-box">
<div class="confirm-msg">{{ confirmState.msg }}</div>
<div class="confirm-actions">
<button class="btn btn-ghost btn-sm" @click="answerConfirm(false)">{{ $t('common.cancel') }}</button>
<button class="btn btn-danger btn-sm" @click="answerConfirm(true)">{{ $t('common.delete') }}</button>
</div>
</div>
</div>
</Transition>
<!-- 页面头部 -->
<header class="page-header">
<h1> 设置</h1>
<h1>{{ $t('settings.title') }}</h1>
</header>
<div class="settings-grid">
<!-- AI 模型配置 -->
<section class="panel">
<div class="panel-header">
<h2>🤖 AI 模型配置</h2>
<button class="btn btn-ghost btn-sm" @click="openProviderForm()">+ 添加 Provider</button>
<h2>{{ $t('settings.panelAiProvider') }}</h2>
<button class="btn btn-ghost btn-sm" @click="openProviderForm()">{{ $t('settings.addProvider') }}</button>
</div>
<div class="provider-list" v-if="aiProviders.length > 0">
<div class="provider-card" v-for="p in aiProviders" :key="p.id">
<div class="provider-card" :class="{ 'provider-card--default': p.is_default }" v-for="p in aiProviders" :key="p.id">
<div class="provider-top">
<div class="provider-info">
<span class="provider-name">{{ p.name }}</span>
<span v-if="p.is_default" class="default-badge">默认</span>
<span v-if="p.is_default" class="default-badge">{{ $t('settings.badgeDefault') }}</span>
</div>
<div class="provider-actions">
<button class="btn-link" @click="setDefaultProvider(p.id)" v-if="!p.is_default">设为默认</button>
<button class="btn-link" @click="openProviderForm(p)">编辑</button>
<button class="btn-link btn-link-danger" @click="deleteProvider(p.id)">删除</button>
<button class="btn-link" @click="setDefaultProvider(p.id)" v-if="!p.is_default">{{ $t('settings.actionSetDefault') }}</button>
<button class="btn-link" @click="openProviderForm(p)">{{ $t('common.edit') }}</button>
<button class="btn-link btn-link-danger" @click="deleteProvider(p.id)">{{ $t('common.delete') }}</button>
</div>
</div>
<div class="provider-detail">
<div class="detail-row">
<span class="detail-label">Base URL</span>
<span class="detail-label">{{ $t('settings.detailBaseUrl') }}</span>
<span class="detail-value">{{ p.base_url }}</span>
</div>
<div class="detail-row">
<span class="detail-label">模型</span>
<span class="detail-label">{{ $t('settings.detailModel') }}</span>
<span class="detail-value">{{ p.default_model }}</span>
</div>
<div class="detail-row">
<span class="detail-label">API Key</span>
<span class="detail-label">{{ $t('settings.detailApiKey') }}</span>
<span class="detail-value mask">{{ maskKey(p.api_key) }}</span>
</div>
</div>
</div>
</div>
<div class="empty-hint" v-else>暂无 AI 提供商点击上方按钮添加</div>
<div class="empty-hint" v-else>{{ $t('settings.emptyProvider') }}</div>
</section>
<!-- AI 提供商表单 -->
<section class="panel" v-if="providerForm.visible">
<div class="panel-header">
<h2>{{ providerForm.editId ? '编辑 Provider' : '添加 Provider' }}</h2>
<button class="btn btn-ghost btn-sm" @click="providerForm.visible = false">取消</button>
<h2>{{ providerForm.editId ? $t('settings.editProviderTitle') : $t('settings.addProviderTitle') }}</h2>
<button class="btn btn-ghost btn-sm" @click="providerForm.visible = false">{{ $t('common.cancel') }}</button>
</div>
<div class="form-grid">
<div class="form-field">
<label class="form-label">名称</label>
<input v-model="providerForm.name" class="setting-input" placeholder="如 DeepSeek、GLM-4" />
<label class="form-label">{{ $t('settings.labelName') }}</label>
<input v-model="providerForm.name" class="setting-input" :placeholder="$t('settings.phProviderName')" />
</div>
<div class="form-field">
<label class="form-label">协议类型</label>
<label class="form-label">{{ $t('settings.labelProviderType') }}</label>
<select v-model="providerForm.providerType" class="setting-select">
<option value="openai_compat">OpenAI 兼容DeepSeek / GLM v4 / OpenAI</option>
<option value="anthropic">Anthropic 兼容GLM 订阅 / Claude</option>
<option value="openai_compat">{{ $t('settings.providerTypeOpenaiCompat') }}</option>
<option value="anthropic">{{ $t('settings.providerTypeAnthropic') }}</option>
</select>
</div>
<div class="form-field">
<label class="form-label">Base URL</label>
<input v-model="providerForm.baseUrl" class="setting-input" placeholder="https://api.deepseek.com" />
<label class="form-label">{{ $t('settings.labelBaseUrl') }}</label>
<input v-model="providerForm.baseUrl" class="setting-input" :placeholder="$t('settings.phBaseUrl')" />
</div>
<div class="form-field">
<label class="form-label">API Key</label>
<input v-model="providerForm.apiKey" class="setting-input" type="password" placeholder="sk-..." />
<label class="form-label">{{ $t('settings.labelApiKey') }}</label>
<input v-model="providerForm.apiKey" class="setting-input" type="password" :placeholder="$t('settings.phApiKey')" />
</div>
<div class="form-field">
<label class="form-label">默认模型</label>
<input v-model="providerForm.defaultModel" class="setting-input" placeholder="deepseek-chat" />
<label class="form-label">{{ $t('settings.labelDefaultModel') }}</label>
<input v-model="providerForm.defaultModel" class="setting-input" :placeholder="$t('settings.phDefaultModel')" />
</div>
<div class="form-actions">
<button class="btn btn-primary" @click="saveProvider" :disabled="providerForm.saving">
{{ providerForm.saving ? '保存中...' : '保存' }}
{{ providerForm.saving ? $t('settings.saving') : $t('common.save') }}
</button>
</div>
</div>
@@ -85,8 +99,8 @@
<!-- 连接管理 -->
<section class="panel">
<div class="panel-header">
<h2>🔗 连接管理</h2>
<button class="btn btn-ghost btn-sm" @click="openConnForm()">+ 添加连接</button>
<h2>{{ $t('settings.panelConnection') }}</h2>
<button class="btn btn-ghost btn-sm" @click="openConnForm()">{{ $t('settings.addConnection') }}</button>
</div>
<div class="connection-list" v-if="connections.length > 0">
<div class="connection-card" v-for="conn in connections" :key="conn.id">
@@ -102,27 +116,27 @@
</div>
</div>
<div class="conn-actions">
<button class="btn-link" @click="openConnForm(conn)">编辑</button>
<button class="btn-link btn-link-danger" @click="removeConn(conn.id)">删除</button>
<button class="btn-link" @click="openConnForm(conn)">{{ $t('common.edit') }}</button>
<button class="btn-link btn-link-danger" @click="removeConn(conn.id)">{{ $t('common.delete') }}</button>
</div>
</div>
</div>
<div class="empty-hint" v-else>暂无连接点击上方按钮添加</div>
<div class="empty-hint" v-else>{{ $t('settings.emptyConnection') }}</div>
</section>
<!-- 连接表单 -->
<section class="panel" v-if="connForm.visible">
<div class="panel-header">
<h2>{{ connForm.editId ? '编辑连接' : '添加连接' }}</h2>
<button class="btn btn-ghost btn-sm" @click="connForm.visible = false">取消</button>
<h2>{{ connForm.editId ? $t('settings.editConnectionTitle') : $t('settings.addConnectionTitle') }}</h2>
<button class="btn btn-ghost btn-sm" @click="connForm.visible = false">{{ $t('common.cancel') }}</button>
</div>
<div class="form-grid">
<div class="form-field">
<label class="form-label">名称</label>
<input v-model="connForm.name" class="setting-input" placeholder="flux_dev" />
<label class="form-label">{{ $t('settings.labelName') }}</label>
<input v-model="connForm.name" class="setting-input" :placeholder="$t('settings.phConnName')" />
</div>
<div class="form-field">
<label class="form-label">类型</label>
<label class="form-label">{{ $t('settings.labelType') }}</label>
<select v-model="connForm.type" class="setting-select">
<option value="mysql">MySQL</option>
<option value="ssh">SSH</option>
@@ -131,19 +145,19 @@
</select>
</div>
<div class="form-field">
<label class="form-label">Host</label>
<input v-model="connForm.host" class="setting-input" placeholder="127.0.0.1" />
<label class="form-label">{{ $t('settings.labelHost') }}</label>
<input v-model="connForm.host" class="setting-input" :placeholder="$t('settings.phHost')" />
</div>
<div class="form-field">
<label class="form-label">Port</label>
<input v-model.number="connForm.port" class="setting-input" type="number" placeholder="3306" />
<label class="form-label">{{ $t('settings.labelPort') }}</label>
<input v-model.number="connForm.port" class="setting-input" type="number" :placeholder="$t('settings.phPort')" />
</div>
<div class="form-field">
<label class="form-label">用户名</label>
<input v-model="connForm.user" class="setting-input" placeholder="root" />
<label class="form-label">{{ $t('settings.labelUser') }}</label>
<input v-model="connForm.user" class="setting-input" :placeholder="$t('settings.phUser')" />
</div>
<div class="form-actions">
<button class="btn btn-primary" @click="saveConn">保存</button>
<button class="btn btn-primary" @click="saveConn">{{ $t('common.save') }}</button>
</div>
</div>
</section>
@@ -151,51 +165,51 @@
<!-- 通用设置 -->
<section class="panel">
<div class="panel-header">
<h2>🎨 通用设置</h2>
<h2>{{ $t('settings.panelGeneral') }}</h2>
</div>
<div class="general-settings">
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">主题</span>
<span class="setting-desc">深色 / 浅色模式</span>
<span class="setting-label">{{ $t('settings.labelTheme') }}</span>
<span class="setting-desc">{{ $t('settings.descTheme') }}</span>
</div>
<div class="setting-control">
<select v-model="settings.theme" class="setting-select">
<option value="dark">🌙 深色模式</option>
<option value="light"> 浅色模式</option>
<option value="system">💻 跟随系统</option>
<option value="dark">{{ $t('settings.themeDark') }}</option>
<option value="light">{{ $t('settings.themeLight') }}</option>
<option value="system">{{ $t('settings.themeSystem') }}</option>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">语言</span>
<span class="setting-desc">界面显示语言</span>
<span class="setting-label">{{ $t('settings.labelLanguage') }}</span>
<span class="setting-desc">{{ $t('settings.descLanguage') }}</span>
</div>
<div class="setting-control">
<select v-model="settings.language" class="setting-select">
<option value="zh-CN">简体中文</option>
<option value="en">English</option>
<option value="zh-CN">{{ $t('settings.aiLanguageZh') }}</option>
<option value="en">{{ $t('settings.aiLanguageEn') }}</option>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">AI 对话语言</span>
<span class="setting-desc">AI 回复使用的默认语言</span>
<span class="setting-label">{{ $t('settings.labelAiLanguage') }}</span>
<span class="setting-desc">{{ $t('settings.descAiLanguage') }}</span>
</div>
<div class="setting-control">
<select v-model="settings.aiLanguage" class="setting-select">
<option value="auto">🔄 跟随界面语言</option>
<option value="zh-CN">🇨🇳 简体中文</option>
<option value="en">🇺🇸 English</option>
<option value="auto">{{ $t('settings.aiLanguageAuto') }}</option>
<option value="zh-CN">{{ $t('settings.aiLanguageZh') }}</option>
<option value="en">{{ $t('settings.aiLanguageEn') }}</option>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">AI 自动执行</span>
<span class="setting-desc">AI 可自动执行低风险操作</span>
<span class="setting-label">{{ $t('settings.labelAutoExecute') }}</span>
<span class="setting-desc">{{ $t('settings.descAutoExecute') }}</span>
</div>
<div class="setting-control">
<label class="toggle">
@@ -206,8 +220,8 @@
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">日志级别</span>
<span class="setting-desc">工作流日志详细程度</span>
<span class="setting-label">{{ $t('settings.labelLogLevel') }}</span>
<span class="setting-desc">{{ $t('settings.descLogLevel') }}</span>
</div>
<div class="setting-control">
<select v-model="settings.logLevel" class="setting-select">
@@ -218,6 +232,136 @@
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelShowTokenUsage') }}</span>
<span class="setting-desc">{{ $t('settings.descShowTokenUsage') }}</span>
</div>
<div class="setting-control">
<label class="toggle">
<input type="checkbox" v-model="settings.showTokenUsage" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelGlobalConcurrency') }}</span>
<span class="setting-desc">{{ $t('settings.descGlobalConcurrency') }}</span>
</div>
<div class="setting-control">
<input type="number" class="setting-select" min="1" style="width:80px"
v-model.number="settings.llmGlobalConcurrency"
@change="syncConcurrencyConfig" />
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelPerConvConcurrency') }}</span>
<span class="setting-desc">{{ $t('settings.descPerConvConcurrency') }}</span>
</div>
<div class="setting-control">
<input type="number" class="setting-select" min="1" style="width:80px"
v-model.number="settings.llmPerConvConcurrency"
@change="syncConcurrencyConfig" />
</div>
</div>
</div>
</section>
<section class="panel">
<div class="panel-header">
<h2>{{ $t('settings.panelKnowledge') }}</h2>
</div>
<div class="general-settings">
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelAutoExtract') }}</span>
<span class="setting-desc">{{ $t('settings.descAutoExtract') }}</span>
</div>
<div class="setting-control">
<label class="toggle">
<input type="checkbox" v-model="knowledgeConfig.auto_extract" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelTriggerMode') }}</span>
<span class="setting-desc">{{ $t('settings.descTriggerMode') }}</span>
</div>
<div class="setting-control">
<select v-model="knowledgeConfig.trigger_mode" class="setting-select">
<option value="on_complete">{{ $t('settings.triggerOnComplete') }}</option>
<option value="on_idle">{{ $t('settings.triggerOnIdle') }}</option>
<option value="manual_only">{{ $t('settings.triggerManualOnly') }}</option>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelMinMessages') }}</span>
<span class="setting-desc">{{ $t('settings.descMinMessages') }}</span>
</div>
<div class="setting-control">
<input type="number" min="2" max="50" v-model.number="knowledgeConfig.min_messages" class="setting-select" style="width:80px" />
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelIdleTimeout') }}</span>
<span class="setting-desc">{{ $t('settings.descIdleTimeout') }}</span>
</div>
<div class="setting-control">
<input type="number" min="5" max="600" :value="Math.round(knowledgeConfig.idle_timeout_ms / 1000)" @input="knowledgeConfig.idle_timeout_ms = ($event.target as HTMLInputElement).valueAsNumber * 1000 || 30000" class="setting-select" style="width:80px" />
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelAutoInject') }}</span>
<span class="setting-desc">{{ $t('settings.descAutoInject') }}</span>
</div>
<div class="setting-control">
<label class="toggle">
<input type="checkbox" v-model="knowledgeConfig.auto_inject" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelVectorEnabled') }}</span>
<span class="setting-desc">{{ $t('settings.descVectorEnabled') }}</span>
</div>
<div class="setting-control">
<label class="toggle">
<input type="checkbox" v-model="knowledgeConfig.vector_enabled" />
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="setting-row" v-if="knowledgeConfig.vector_enabled">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelEmbeddingProvider') }}</span>
<span class="setting-desc">{{ $t('settings.descEmbeddingProvider') }}</span>
</div>
<div class="setting-control">
<select v-model="knowledgeConfig.embedding_provider_id" class="setting-select">
<option :value="null">{{ $t('settings.embeddingProviderNone') }}</option>
<option v-for="p in embeddingProviders" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
</div>
<div class="setting-row" v-if="knowledgeConfig.vector_enabled">
<div class="setting-info">
<span class="setting-label">{{ $t('settings.labelEmbeddingModel') }}</span>
<span class="setting-desc">{{ $t('settings.descEmbeddingModel') }}</span>
</div>
<div class="setting-control">
<input type="text" :value="knowledgeConfig.embedding_model ?? ''" @input="knowledgeConfig.embedding_model = ($event.target as HTMLInputElement).value || null" class="setting-select" :placeholder="$t('settings.phEmbeddingModel')" style="width:220px" />
</div>
</div>
</div>
</section>
</div>
@@ -225,17 +369,55 @@
</template>
<script setup lang="ts">
import { reactive, ref, watch, onMounted } from 'vue'
import { aiApi } from '../api'
import { reactive, ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { aiApi, knowledgeApi } from '../api'
import { useAppSettingsStore } from '../stores/appSettings'
import type { AiProviderConfig } from '../api/types'
import i18n from '../i18n'
const { t } = useI18n()
const appSettings = useAppSettingsStore()
// ============================================================
// AI 提供商 — 真实后端 CRUD
// ============================================================
const aiProviders = ref<AiProviderConfig[]>([])
// 顶部轻量提示(错误/警告/完成3s 自动消失;零依赖,替代未接入的 Arco Message
const toast = reactive({ visible: false, msg: '', type: 'info' as 'error' | 'warning' | 'info' })
let _toastTimer: ReturnType<typeof setTimeout> | null = null
/** 安全提取错误消息——Tauri IPC 错误是字符串非 Error 对象 */
function errMsg(e: unknown): string { return e instanceof Error ? e.message : String(e) }
function showToast(msg: string, type: 'error' | 'warning' | 'info' = 'info') {
toast.msg = msg
toast.type = type
toast.visible = true
if (_toastTimer) clearTimeout(_toastTimer)
_toastTimer = setTimeout(() => { toast.visible = false }, 3000)
}
// 自建确认弹层(替代 window.confirm —— 后者带 webview 来源域名/端口,无法去除)
const confirmState = reactive({
visible: false,
msg: '',
resolve: null as null | ((v: boolean) => void),
})
function confirmDialog(msg: string): Promise<boolean> {
return new Promise(resolve => {
confirmState.msg = msg
confirmState.visible = true
confirmState.resolve = resolve
})
}
function answerConfirm(ok: boolean) {
confirmState.visible = false
confirmState.resolve?.(ok)
confirmState.resolve = null
}
const providerForm = reactive({
visible: false,
editId: '' as string,
@@ -251,7 +433,8 @@ async function loadProviders() {
try {
aiProviders.value = await aiApi.listProviders()
} catch (e) {
console.error('加载提供商失败:', e)
console.error(t('settings.toastLoadProviderFail'), e)
showToast(t('settings.toastLoadProviderFail'), 'error')
}
}
@@ -275,7 +458,10 @@ function openProviderForm(p?: AiProviderConfig) {
}
async function saveProvider() {
if (!providerForm.name || !providerForm.baseUrl || !providerForm.apiKey || !providerForm.defaultModel) return
if (!providerForm.name || !providerForm.baseUrl || !providerForm.apiKey || !providerForm.defaultModel) {
showToast(t('settings.toastSaveIncomplete'), 'warning')
return
}
providerForm.saving = true
try {
await aiApi.saveProvider({
@@ -288,25 +474,36 @@ async function saveProvider() {
})
providerForm.visible = false
await loadProviders()
showToast(t('settings.toastSaved'), 'info')
} catch (e) {
console.error('保存提供商失败:', e)
console.error(t('settings.toastSaveFail', { msg: errMsg(e) }), e)
showToast(t('settings.toastSaveFail', { msg: errMsg(e) }), 'error')
} finally {
providerForm.saving = false
}
}
async function deleteProvider(id: string) {
// 暂无后端 delete 命令,通过 save 空 id 不行,需要后端支持
// 先从前端移除,后续补 delete IPC
aiProviders.value = aiProviders.value.filter(p => p.id !== id)
const p = aiProviders.value.find(x => x.id === id)
if (!await confirmDialog(t('settings.confirmDeleteProvider', { name: p?.name || id }))) return
try {
await aiApi.deleteProvider(id)
await loadProviders()
showToast(t('settings.toastDeleted'), 'info')
} catch (e) {
console.error(t('settings.toastDeleteFail', { msg: errMsg(e) }), e)
showToast(t('settings.toastDeleteFail', { msg: errMsg(e) }), 'error')
}
}
async function setDefaultProvider(id: string) {
try {
await aiApi.setProvider(id)
await loadProviders()
showToast(t('settings.toastSetDefaultOk'), 'info')
} catch (e) {
console.error('设置默认提供商失败:', e)
console.error(t('settings.toastSetDefaultFail', { msg: errMsg(e) }), e)
showToast(t('settings.toastSetDefaultFail', { msg: errMsg(e) }), 'error')
}
}
@@ -343,14 +540,12 @@ const connForm = reactive({
const CONN_STORAGE_KEY = 'df-connections'
function loadConnections() {
try {
const raw = localStorage.getItem(CONN_STORAGE_KEY)
if (raw) connections.value = JSON.parse(raw)
} catch { /* ignore */ }
const stored = appSettings.get<ConnRecord[] | null>(CONN_STORAGE_KEY, null)
if (stored) connections.value = stored
}
function persistConnections() {
localStorage.setItem(CONN_STORAGE_KEY, JSON.stringify(connections.value))
void appSettings.set(CONN_STORAGE_KEY, connections.value)
}
function typeIcon(type: string) {
@@ -383,7 +578,10 @@ function openConnForm(conn?: ConnRecord) {
}
function saveConn() {
if (!connForm.name || !connForm.host) return
if (!connForm.name || !connForm.host) {
showToast(t('settings.toastConnIncomplete'), 'warning')
return
}
const record: ConnRecord = {
id: connForm.editId || Date.now().toString(36),
name: connForm.name,
@@ -402,9 +600,12 @@ function saveConn() {
connForm.visible = false
}
function removeConn(id: string) {
async function removeConn(id: string) {
const c = connections.value.find(x => x.id === id)
if (!await confirmDialog(t('settings.confirmDeleteConn', { name: c?.name || id }))) return
connections.value = connections.value.filter(c => c.id !== id)
persistConnections()
showToast(t('settings.toastDeleted'), 'info')
}
// ============================================================
@@ -412,11 +613,14 @@ function removeConn(id: string) {
// ============================================================
const settings = reactive({
theme: localStorage.getItem('df-theme') || 'dark',
language: localStorage.getItem('df-language') || 'zh-CN',
aiLanguage: localStorage.getItem('df-ai-language') || 'auto',
theme: appSettings.get<string>('df-theme', 'dark'),
language: appSettings.get<string>('df-language', 'zh-CN'),
aiLanguage: appSettings.get<string>('df-ai-language', 'auto'),
autoExecute: false,
logLevel: 'info',
showTokenUsage: appSettings.get<boolean>('df-show-token-usage', false),
llmGlobalConcurrency: appSettings.get<number>('df-llm-global-concurrency', 3),
llmPerConvConcurrency: appSettings.get<number>('df-llm-per-conv-concurrency', 2),
})
function applyTheme(theme: string) {
@@ -433,29 +637,167 @@ function applyTheme(theme: string) {
watch(() => settings.theme, (val) => {
applyTheme(val)
localStorage.setItem('df-theme', val)
void appSettings.set('df-theme', val)
})
watch(() => settings.aiLanguage, (val) => {
localStorage.setItem('df-ai-language', val)
void appSettings.set('df-ai-language', val)
})
watch(() => settings.showTokenUsage, (val) => {
void appSettings.set('df-show-token-usage', val)
})
watch(() => settings.llmGlobalConcurrency, (v) => {
void appSettings.set('df-llm-global-concurrency', v)
})
watch(() => settings.llmPerConvConcurrency, (v) => {
void appSettings.set('df-llm-per-conv-concurrency', v)
})
// 同步 LLM 并发上限到后端(debounce 300ms,防快速连续调整时频繁 IPC)
// 持久化已由上方 watch 写 appSettings(SQLite) 完成;此处只负责同步内存限流器
let _concurrencyTimer: ReturnType<typeof setTimeout> | null = null
function syncConcurrencyConfig() {
// clamp 到合理区间,防越界输入(缓存被篡改 / 手动输超限)致限流失效;
// 修正后值同步回 v-model(input 显示) + watch 持久化
// 下限 1,无上限;约束单对话并发不超过全局并发
settings.llmGlobalConcurrency = Math.max(1, settings.llmGlobalConcurrency || 3)
settings.llmPerConvConcurrency = Math.max(1, settings.llmPerConvConcurrency || 2)
if (settings.llmPerConvConcurrency > settings.llmGlobalConcurrency) {
settings.llmPerConvConcurrency = settings.llmGlobalConcurrency
}
if (_concurrencyTimer) clearTimeout(_concurrencyTimer)
_concurrencyTimer = setTimeout(async () => {
try {
await aiApi.setConcurrencyConfig(settings.llmGlobalConcurrency, settings.llmPerConvConcurrency)
} catch (e) {
console.error('同步 LLM 并发配置失败:', e)
}
}, 300)
}
watch(() => settings.language, (val) => {
localStorage.setItem('df-language', val)
void appSettings.set('df-language', val)
// 同步 i18n 实例,让界面立即跟随语言切换
i18n.global.locale.value = val as 'zh-CN' | 'en'
})
// ============================================================
// 知识库配置(提取 + 注入) — 走后端 IPC,非 localStorage
// ============================================================
const knowledgeConfig = reactive({
loaded: false,
auto_extract: true,
trigger_mode: 'on_complete' as 'on_complete' | 'on_idle' | 'manual_only',
min_messages: 4,
idle_timeout_ms: 30000,
auto_inject: true,
vector_enabled: false,
embedding_provider_id: null as string | null,
embedding_model: null as string | null,
})
// 可做 embedding 的 provider(仅 openai_compat,Anthropic 无 embed API)
const embeddingProviders = computed(() =>
aiProviders.value.filter(p => p.provider_type !== 'anthropic')
)
async function loadKnowledgeConfig() {
try {
const cfg = await knowledgeApi.getConfig()
Object.assign(knowledgeConfig, cfg)
knowledgeConfig.loaded = true
} catch (e) {
console.error('加载知识库配置失败:', e)
}
}
let _knowledgeSaveTimer: ReturnType<typeof setTimeout> | null = null
function saveKnowledgeConfigDebounced() {
if (_knowledgeSaveTimer) clearTimeout(_knowledgeSaveTimer)
_knowledgeSaveTimer = setTimeout(async () => {
try {
const { loaded, ...cfg } = knowledgeConfig
void loaded
await knowledgeApi.saveConfig(cfg as any)
} catch (e) {
console.error('保存知识库配置失败:', e)
}
}, 500)
}
watch(
() => ({ ...knowledgeConfig }),
() => {
if (knowledgeConfig.loaded) saveKnowledgeConfigDebounced()
},
{ deep: true },
)
onMounted(() => {
applyTheme(settings.theme)
loadProviders()
loadConnections()
loadKnowledgeConfig()
// 启动同步一次 LLM 并发配置(防刷新页面后后端未恢复用户设定值)
syncConcurrencyConfig()
})
// 组件卸载清 debounce timer,防 unmount 后旧 timer 仍 fire 发无效 IPC(timer 泄漏)
onUnmounted(() => {
if (_concurrencyTimer) clearTimeout(_concurrencyTimer)
})
</script>
<style scoped>
.settings { padding: 16px 20px 20px; }
/* ===== 顶部 Toast 提示 ===== */
.toast {
position: fixed;
top: 16px;
left: 50%;
transform: translateX(-50%);
z-index: 1000;
padding: 8px 16px;
border-radius: var(--df-radius-sm);
font-size: 13px;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
}
.toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
.toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
.toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
.toast-enter-active, .toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
.toast-enter-from, .toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
/* ===== 确认弹层 ===== */
.confirm-mask {
position: fixed;
inset: 0;
z-index: 1100;
background: rgba(0,0,0,0.5);
display: flex;
align-items: center;
justify-content: center;
}
.confirm-box {
background: var(--df-bg-card);
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-lg);
padding: 20px;
min-width: 280px;
max-width: 360px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
}
.confirm-msg { font-size: 13px; color: var(--df-text); line-height: 1.5; margin-bottom: 16px; }
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
.btn-danger { background: var(--df-danger); color: #fff; }
.btn-danger:hover { filter: brightness(1.1); }
.confirm-enter-active, .confirm-leave-active { transition: opacity 0.15s; }
.confirm-enter-from, .confirm-leave-to { opacity: 0; }
.page-header {
display: flex;
justify-content: space-between;
@@ -527,6 +869,10 @@ onMounted(() => {
border-radius: var(--df-radius);
padding: 14px 16px;
}
.provider-card--default {
border-color: var(--df-accent);
box-shadow: inset 3px 0 0 var(--df-accent);
}
.provider-top {
display: flex;
justify-content: space-between;

View File

@@ -1,17 +1,17 @@
<template>
<div class="tasks">
<header class="page-header">
<h1>🔀 任务队列</h1>
<h1>{{ $t('tasks.title') }}</h1>
<div class="header-actions">
<button class="btn btn-ghost" @click="refresh">🔄 刷新</button>
<button class="btn btn-primary" @click="openCreateModal">+ 新建任务</button>
<button class="btn btn-ghost" @click="refresh">{{ $t('tasks.refresh') }}</button>
<button class="btn btn-primary" @click="openCreateModal">{{ $t('tasks.create') }}</button>
</div>
</header>
<!-- 筛选栏 -->
<div class="filter-bar">
<div class="filter-group">
<span class="filter-label">项目</span>
<span class="filter-label">{{ $t('tasks.filter.project') }}</span>
<button
v-for="p in projectFilters"
:key="p.key"
@@ -23,7 +23,7 @@
</button>
</div>
<div class="filter-group">
<span class="filter-label">状态</span>
<span class="filter-label">{{ $t('tasks.filter.status') }}</span>
<button
v-for="s in statusFilters"
:key="s.key"
@@ -46,7 +46,7 @@
<div class="group-header">
<span class="group-icon">{{ group.icon }}</span>
<h2 class="group-name">{{ group.projectName }}</h2>
<span class="group-count">{{ group.tasks.length }} 个任务</span>
<span class="group-count">{{ $t('tasks.group.taskCount', { n: group.tasks.length }) }}</span>
</div>
<div class="task-list">
<div class="task-item" v-for="task in group.tasks" :key="task.id">
@@ -60,11 +60,11 @@
<span class="branch-icon"></span>
{{ task.branch_name }}
</span>
<span class="task-date">{{ formatTime(task.created_at) }}</span>
<span class="task-time">{{ formatTime(task.updated_at) }}</span>
<span class="task-date">{{ formatRelativeZh(task.created_at) }}</span>
<span class="task-time">{{ formatRelativeZh(task.updated_at) }}</span>
</div>
</div>
<span class="status-tag" :class="'status-' + task.status">{{ statusLabel(task.status) }}</span>
<span class="status-tag" :class="taskStatusClass(task.status)">{{ $t(statusLabel(task.status)) }}</span>
</div>
</div>
</section>
@@ -73,16 +73,27 @@
<!-- 新建任务模态框 -->
<div class="modal-overlay" v-if="showCreateModal" @click.self="showCreateModal = false">
<div class="modal-box">
<h3>新建任务</h3>
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">项目</label>
<h3>{{ $t('tasks.modal.title') }}</h3>
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('tasks.modal.project') }}</label>
<select v-model="newTaskProjectId" style="width:100%;padding:8px 12px;border: 0.5px solid var(--df-border);border-radius: var(--df-radius-sm);background:var(--df-bg);color:var(--df-text);font-size:13px;margin-bottom:12px">
<option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">标题</label>
<input v-model="newTaskTitle" placeholder="输入任务标题..." @keyup.enter="confirmCreate" />
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('tasks.modal.titleField') }}</label>
<input v-model="newTaskTitle" :placeholder="$t('tasks.modal.titlePlaceholder')" @keyup.enter="confirmCreate" />
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('tasks.modal.desc') }}</label>
<input v-model="newTaskDesc" :placeholder="$t('tasks.modal.descPlaceholder')" style="width:100%;padding:8px 12px;border: 0.5px solid var(--df-border);border-radius: var(--df-radius-sm);background:var(--df-bg);color:var(--df-text);font-size:13px;margin-bottom:12px;box-sizing:border-box" />
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('tasks.modal.branch') }}</label>
<input v-model="newTaskBranch" :placeholder="$t('tasks.modal.branchPlaceholder')" style="width:100%;padding:8px 12px;border: 0.5px solid var(--df-border);border-radius: var(--df-radius-sm);background:var(--df-bg);color:var(--df-text);font-size:13px;margin-bottom:12px;box-sizing:border-box" />
<label style="font-size:12px;color:var(--df-text-secondary);margin-bottom:4px;display:block">{{ $t('tasks.modal.priority') }}</label>
<select v-model="newTaskPriority" style="width:100%;padding:8px 12px;border: 0.5px solid var(--df-border);border-radius: var(--df-radius-sm);background:var(--df-bg);color:var(--df-text);font-size:13px;margin-bottom:12px">
<option :value="0">{{ $t('tasks.modal.priorityCritical') }}</option>
<option :value="1">{{ $t('tasks.modal.priorityHigh') }}</option>
<option :value="2">{{ $t('tasks.modal.priorityMedium') }}</option>
<option :value="3">{{ $t('tasks.modal.priorityLow') }}</option>
</select>
<div class="modal-actions">
<button class="btn-cancel" @click="showCreateModal = false">取消</button>
<button class="btn-confirm" @click="confirmCreate">确认</button>
<button class="btn-cancel" @click="showCreateModal = false">{{ $t('common.cancel') }}</button>
<button class="btn-confirm" @click="confirmCreate">{{ $t('common.confirm') }}</button>
</div>
</div>
</div>
@@ -91,11 +102,14 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useProjectStore } from '../stores/project'
import { formatRelativeZh } from '../utils/time'
import { taskStatusLabel as statusLabel, taskStatusClass, priorityLabel, priorityClass } from '../constants/project'
import type { TaskRecord } from '../api/types'
const store = useProjectStore()
const { t } = useI18n()
const activeProject = ref('all')
const activeStatus = ref('all')
@@ -104,6 +118,9 @@ const activeStatus = ref('all')
const showCreateModal = ref(false)
const newTaskProjectId = ref('')
const newTaskTitle = ref('')
const newTaskDesc = ref('')
const newTaskBranch = ref('')
const newTaskPriority = ref(2) // 默认 medium
interface TaskGroup {
projectName: string
@@ -112,21 +129,21 @@ interface TaskGroup {
}
const projectFilters = computed(() => [
{ key: 'all', label: '全部' },
{ key: 'all', label: t('tasks.filter.all') },
...store.projects.map(p => ({ key: p.id, label: p.name })),
])
const statusFilters: { key: string; label: string; icon: string }[] = [
{ key: 'all', label: '全部', icon: '📋' },
{ key: 'todo', label: '待开始', icon: '📝' },
{ key: 'in_progress', label: '进行中', icon: '🔨' },
{ key: 'review_ready', label: '待审查', icon: '👀' },
{ key: 'merged', label: '已合并', icon: '✅' },
{ key: 'abandoned', label: '已废弃', icon: '🗑️' },
]
const statusFilters = computed<{ key: string; label: string; icon: string }[]>(() => [
{ key: 'all', label: t('tasks.statusFilter.all'), icon: '📋' },
{ key: 'todo', label: t('tasks.statusFilter.todo'), icon: '📝' },
{ key: 'in_progress', label: t('tasks.statusFilter.in_progress'), icon: '🔨' },
{ key: 'review_ready', label: t('tasks.statusFilter.review_ready'), icon: '👀' },
{ key: 'merged', label: t('tasks.statusFilter.merged'), icon: '✅' },
{ key: 'abandoned', label: t('tasks.statusFilter.abandoned'), icon: '🗑️' },
])
function getProjectName(projectId: string): string {
return store.projects.find(p => p.id === projectId)?.name ?? '未知项目'
return store.projects.find(p => p.id === projectId)?.name ?? t('tasks.group.unknownProject')
}
const projectIcons: Record<string, string> = {
@@ -164,30 +181,7 @@ const filteredGroups = computed(() => {
return result
})
function statusLabel(status: string) {
const map: Record<string, string> = {
todo: '📝 待开始',
in_progress: '🔨 进行中',
review_ready: '👀 待审查',
merged: '✅ 已合并',
abandoned: '🗑️ 已废弃',
}
return map[status] ?? status
}
function priorityLabel(priority: number) {
const map: Record<number, string> = { 0: 'P0', 1: 'P1', 2: 'P2', 3: 'P3' }
return map[priority] ?? `P${priority}`
}
function priorityClass(priority: number): string {
const map: Record<number, string> = { 0: 'priority-critical', 1: 'priority-high', 2: 'priority-medium', 3: 'priority-low' }
return map[priority] ?? 'priority-low'
}
function formatTime(timestamp: string | number): string {
return formatRelativeZh(timestamp)
}
// statusLabel / priorityLabel / priorityClass 由 ../constants/project 提供(与 ProjectDetail 文案统一)
async function refresh() {
await store.loadTasks()
@@ -196,15 +190,22 @@ async function refresh() {
function openCreateModal() {
newTaskProjectId.value = store.projects.length > 0 ? store.projects[0].id : ''
newTaskTitle.value = ''
newTaskDesc.value = ''
newTaskBranch.value = ''
newTaskPriority.value = 2
showCreateModal.value = true
}
async function confirmCreate() {
if (!newTaskTitle.value.trim() || !newTaskProjectId.value) return
await store.createTask({
const r = await store.createTask({
project_id: newTaskProjectId.value,
title: newTaskTitle.value.trim(),
description: newTaskDesc.value.trim(),
branch_name: newTaskBranch.value.trim() || undefined,
priority: newTaskPriority.value,
})
if (!r) return // 失败已 toast,保持弹窗不关
showCreateModal.value = false
}
@@ -370,12 +371,11 @@ onMounted(async () => {
border-radius: var(--df-radius-sm);
white-space: nowrap;
}
.status-created { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
.status-in_progress { background: rgba(100,181,246,0.2); color: var(--df-info); }
.status-review_ready { background: rgba(255,217,61,0.2); color: var(--df-warning); }
.status-merged { background: rgba(100,255,218,0.15); color: var(--df-success); }
.status-abandoned { background: rgba(255,107,107,0.2); color: var(--df-danger); }
.status-todo { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
.status-progress { background: rgba(100,181,246,0.2); color: var(--df-info); }
.status-review { background: rgba(255,217,61,0.2); color: var(--df-warning); }
.status-done { background: rgba(100,255,218,0.15); color: var(--df-success); }
.status-abandoned { background: rgba(255,107,107,0.2); color: var(--df-danger); }
/* ===== 模态框 ===== */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 100; }