重构: 前端 composable 拆分+详情页 Markdown 渲染+流式 memo+状态同步+bug 修复
- composables/useMarkdown 抽(AiChat 复用)+ai/* 流式核心+useAiSend catch 回滚 user msg(B-21)+useAiStream 超时文案(B-03)+useAiConversations 切换 token(FR-R1) - views/TaskDetail 新建(F-02)+描述 md+mdReady 响应式+字段同行布局(B-24/31) - views/ProjectDetail 多选审批 UI(F-01)+描述 md+mdReady+字段同行(B-25/31) - views/Ideas+Knowledge 描述 md+mdReady(B-25) - views/Tasks 项目切换联动(B-29)+task 详情跳转 - components/AiChat toast 失败提示(B-20)+流式块级 memo(ARC-08)+clean UI(AR-7)+stop 兜底(AR-5) - components/ToolCard 审批卡片可读化(AR-3) - stores useAiStore barrel(ARC-04)+del settings mock(ARC-01)+approve options(R-PD-5) - i18n aiTool/nav 中英对称(AR-3/9)+删 nav.decisions 死链(ARC-02) - router /tasks/:id(F-02)+global.css selection 可见(R-260615-01)+api task detail+App 删 decisions nav
This commit is contained in:
2
package-lock.json
generated
2
package-lock.json
generated
@@ -11,7 +11,7 @@
|
|||||||
"@arco-design/web-vue": "^2.58.0",
|
"@arco-design/web-vue": "^2.58.0",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
"dompurify": "^3.4.9",
|
"dompurify": "^3.4.10",
|
||||||
"marked": "^18.0.5",
|
"marked": "^18.0.5",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-i18n": "9",
|
"vue-i18n": "9",
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"@arco-design/web-vue": "^2.58.0",
|
"@arco-design/web-vue": "^2.58.0",
|
||||||
"@tauri-apps/api": "^2",
|
"@tauri-apps/api": "^2",
|
||||||
"@tauri-apps/plugin-dialog": "^2.7.1",
|
"@tauri-apps/plugin-dialog": "^2.7.1",
|
||||||
"dompurify": "^3.4.9",
|
"dompurify": "^3.4.10",
|
||||||
"marked": "^18.0.5",
|
"marked": "^18.0.5",
|
||||||
"vue": "^3.5.13",
|
"vue": "^3.5.13",
|
||||||
"vue-i18n": "9",
|
"vue-i18n": "9",
|
||||||
|
|||||||
@@ -270,8 +270,6 @@ const primaryNav = [
|
|||||||
const secondaryNav = [
|
const secondaryNav = [
|
||||||
{ path: '/knowledge', label: 'nav.knowledge',
|
{ path: '/knowledge', label: 'nav.knowledge',
|
||||||
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>' },
|
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 016.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 014 19.5v-15A2.5 2.5 0 016.5 2z"/></svg>' },
|
||||||
{ path: '/decisions', label: 'nav.decisions',
|
|
||||||
svg: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>' },
|
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ export const taskApi = {
|
|||||||
return invoke('list_tasks', { projectId: projectId ?? null })
|
return invoke('list_tasks', { projectId: projectId ?? null })
|
||||||
},
|
},
|
||||||
|
|
||||||
|
get(id: string): Promise<TaskRecord> {
|
||||||
|
return invoke('get_task_by_id', { id })
|
||||||
|
},
|
||||||
|
|
||||||
create(input: CreateTaskInput): Promise<TaskRecord> {
|
create(input: CreateTaskInput): Promise<TaskRecord> {
|
||||||
return invoke('create_task', { input })
|
return invoke('create_task', { input })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -180,6 +180,8 @@ export type AiChatEvent = ({
|
|||||||
type: 'AiError'; error: string
|
type: 'AiError'; error: string
|
||||||
} | {
|
} | {
|
||||||
type: 'AiAgentRound'; round: number
|
type: 'AiAgentRound'; round: number
|
||||||
|
} | {
|
||||||
|
type: 'AiHeartbeat'
|
||||||
}) & {
|
}) & {
|
||||||
conversation_id?: string
|
conversation_id?: string
|
||||||
}
|
}
|
||||||
@@ -221,6 +223,9 @@ export interface AiConversationSummary {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 审批选择类型(F-260615-01: single=单选默认,multiple=多选) */
|
||||||
|
export type ApprovalSelectType = 'single' | 'multiple'
|
||||||
|
|
||||||
/** 人工审批请求 */
|
/** 人工审批请求 */
|
||||||
export interface HumanApprovalRequest {
|
export interface HumanApprovalRequest {
|
||||||
execution_id: string
|
execution_id: string
|
||||||
@@ -228,6 +233,8 @@ export interface HumanApprovalRequest {
|
|||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
options: string[]
|
options: string[]
|
||||||
|
/** F-260615-01: 缺省 single(向后兼容),multiple 时允许多选 */
|
||||||
|
select_type?: ApprovalSelectType
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 人工审批响应 */
|
/** 人工审批响应 */
|
||||||
@@ -235,6 +242,8 @@ export interface HumanApprovalResponse {
|
|||||||
execution_id: string
|
execution_id: string
|
||||||
node_id: string
|
node_id: string
|
||||||
decision: string
|
decision: string
|
||||||
|
/** F-260615-01: 多选结果(single 时长度=1,multiple 时长度≥1) */
|
||||||
|
decisions?: string[]
|
||||||
comment?: string
|
comment?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -197,7 +197,7 @@
|
|||||||
class="ai-msg-bubble ai-msg-bubble--ai ai-md"
|
class="ai-msg-bubble ai-msg-bubble--ai ai-md"
|
||||||
:class="{ 'ai-msg-bubble--error': msg.isError }"
|
:class="{ 'ai-msg-bubble--error': msg.isError }"
|
||||||
>
|
>
|
||||||
<div v-html="renderMd(isLastAi(msg) && store.state.currentText ? store.state.currentText : msg.content, isLastAi(msg) && store.state.streaming)"></div>
|
<div v-html="renderContent(msg)"></div>
|
||||||
<span v-if="isLastAi(msg) && isViewingGenerating" class="ai-cursor"></span>
|
<span v-if="isLastAi(msg) && isViewingGenerating" class="ai-cursor"></span>
|
||||||
</div>
|
</div>
|
||||||
<!-- 流式开始但尚无文本时显示光标 -->
|
<!-- 流式开始但尚无文本时显示光标 -->
|
||||||
@@ -308,14 +308,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- 确认弹层(删对话等不可逆操作) -->
|
<!-- 确认弹层(删对话等不可逆操作) -->
|
||||||
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" />
|
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" />
|
||||||
|
<!-- 错误 toast(零依赖,复用 Settings.vue 同款 reactive toast;分离窗口无 App.vue 根 toast,故本组件自管) -->
|
||||||
|
<Transition name="ai-toast">
|
||||||
|
<div v-if="toast.visible" class="ai-toast" :class="'ai-toast-' + toast.type">{{ toast.msg }}</div>
|
||||||
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, computed, nextTick, onMounted, watch } from 'vue'
|
import { ref, reactive, computed, nextTick, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAiStore } from '../stores/ai'
|
import { useAiStore } from '../stores/ai'
|
||||||
import ConfirmDialog from './ConfirmDialog.vue'
|
import ConfirmDialog from './ConfirmDialog.vue'
|
||||||
|
import { useConfirm } from '../composables/useConfirm'
|
||||||
|
import { useMarkdown } from '../composables/useMarkdown'
|
||||||
import ToolCardList from './ToolCardList.vue'
|
import ToolCardList from './ToolCardList.vue'
|
||||||
import { formatRelativeZh } from '../utils/time'
|
import { formatRelativeZh } from '../utils/time'
|
||||||
import type { AiConversationSummary, AiMessage, SkillInfo } from '../api/types'
|
import type { AiConversationSummary, AiMessage, SkillInfo } from '../api/types'
|
||||||
@@ -324,43 +330,112 @@ const props = defineProps<{
|
|||||||
detached?: boolean
|
detached?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// marked + DOMPurify 体积较大,延迟加载以加快首屏;加载完成后 mdReady 翻转触发重渲染
|
// ═══ Markdown 渲染(ARC-260615-08:自研块级 memo,借鉴方案D流式核心) ═══
|
||||||
let _marked: typeof import('marked')['marked'] | null = null
|
// B-23:渲染基础设施(_marked/_purify/mdReady/_mdCache/loadMarkdown/escapeFallback/
|
||||||
let _purify: typeof import('dompurify')['default'] | null = null
|
// renderMd)抽至 composables/useMarkdown(模块级单例),本组件与 TaskDetail 共享。
|
||||||
const mdReady = ref(false)
|
// 流式核心(splitBlocks/parseBlock/renderStreamingMd/scheduleStreamParse)保留在本组件,
|
||||||
// markdown 渲染结果缓存:历史消息文本不变,重渲染(折叠/状态变化触发)命中缓存省去 marked+sanitize 全量解析
|
// 经 getMarked()/getPurify()/mdReady/escapeFallback 复用同一份渲染器实例,行为零变化。
|
||||||
const _mdCache = new Map<string, string>()
|
const {
|
||||||
|
mdReady,
|
||||||
|
loadMarkdown,
|
||||||
|
renderMd,
|
||||||
|
escapeFallback,
|
||||||
|
getMarked,
|
||||||
|
getPurify,
|
||||||
|
} = useMarkdown()
|
||||||
|
// _marked/_purify 走 composable 单例(getMarked/getPurify 在 loadMarkdown 后才非 null);
|
||||||
|
// 流式 parse 经 getter 取最新引用,行为零变化。
|
||||||
|
// 块级 memo:单块文本 → html(流式时已完成块命中跳过,O(全文)→O(末块),借鉴方案D/业界主流机制2)
|
||||||
|
const _blockCache = new Map<string, string>()
|
||||||
|
const BLOCK_CACHE_LIMIT = 300
|
||||||
|
// 流式渲染状态:rAF 节流(多 delta 合并一帧,60fps 封顶防主线程阻塞掉帧)
|
||||||
|
const streamingHtml = ref('')
|
||||||
|
let rafId: number | null = null
|
||||||
|
let lastStreamText = ''
|
||||||
|
|
||||||
async function loadMarkdown() {
|
/// 切块:代码围栏(```...```)整体一块(跨双换行不切),非代码段按双换行切段。
|
||||||
const [{ marked }, dp] = await Promise.all([import('marked'), import('dompurify')])
|
/// 流式期末块可能不完整(未闭合围栏/半截段落),交给 parseBlockNoCache 每次重 parse。
|
||||||
marked.setOptions({ breaks: true, gfm: true })
|
function splitBlocks(text: string): string[] {
|
||||||
_marked = marked
|
const blocks: string[] = []
|
||||||
_purify = dp.default
|
const fenceRe = /```[^\n]*\n[\s\S]*?(?:```|$)/g
|
||||||
_mdCache.clear() // 渲染器就绪,清掉之前 escapeHtml 兜底缓存,触发完整 markdown 重渲染
|
let last = 0
|
||||||
mdReady.value = true
|
let m: RegExpExecArray | null
|
||||||
|
while ((m = fenceRe.exec(text)) !== null) {
|
||||||
|
if (m.index > last) {
|
||||||
|
for (const b of text.slice(last, m.index).split(/\n{2,}/)) if (b.trim()) blocks.push(b)
|
||||||
|
}
|
||||||
|
blocks.push(m[0])
|
||||||
|
last = m.index + m[0].length
|
||||||
|
}
|
||||||
|
if (last < text.length) {
|
||||||
|
for (const b of text.slice(last).split(/\n{2,}/)) if (b.trim()) blocks.push(b)
|
||||||
|
}
|
||||||
|
return blocks.length ? blocks : [text]
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text: string): string {
|
/// 末块不缓存(可能不完整,下次 delta 变),每次重 parse 处理未闭合 token
|
||||||
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
function parseBlockNoCache(block: string): string {
|
||||||
|
// _marked/_purify 在 loadMarkdown 后才就绪;此处仅在 mdReady 后被流式分支调用
|
||||||
|
const marked = getMarked()
|
||||||
|
const purify = getPurify()
|
||||||
|
return purify!.sanitize(marked!.parse(block) as string)
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMd(text: string, isStreaming = false): string {
|
/// 块级 memo parse:已完成块缓存命中,O(末块)
|
||||||
if (!text) return ''
|
function parseBlock(block: string): string {
|
||||||
// 流式态纯文本(AR-1):避免每 delta 全量 marked.parse+sanitize 致主线程阻塞掉帧;
|
const cached = _blockCache.get(block)
|
||||||
// 不缓存流式文本(每 delta 变,缓存无收益且污染),仅 escapeHtml + 换行转 <br>;完成后再走 markdown
|
|
||||||
if (isStreaming || !mdReady.value || !_marked || !_purify) {
|
|
||||||
return escapeHtml(text).replace(/\n/g, '<br>')
|
|
||||||
}
|
|
||||||
const cached = _mdCache.get(text)
|
|
||||||
if (cached !== undefined) return cached
|
if (cached !== undefined) return cached
|
||||||
const html = _purify.sanitize(_marked.parse(text) as string)
|
const html = parseBlockNoCache(block)
|
||||||
if (_mdCache.size > 200) _mdCache.clear()
|
if (_blockCache.size >= BLOCK_CACHE_LIMIT) _blockCache.delete(_blockCache.keys().next().value!)
|
||||||
_mdCache.set(text, html)
|
_blockCache.set(block, html)
|
||||||
return html
|
return html
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 流式渲染:切块 + 块级 memo(前块缓存/末块重 parse)
|
||||||
|
function renderStreamingMd(text: string): string {
|
||||||
|
if (!text) return ''
|
||||||
|
if (!mdReady.value || !getMarked() || !getPurify()) {
|
||||||
|
return escapeFallback(text)
|
||||||
|
}
|
||||||
|
const blocks = splitBlocks(text)
|
||||||
|
const n = blocks.length
|
||||||
|
return blocks.map((b, i) => (i === n - 1 ? parseBlockNoCache(b) : parseBlock(b))).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// rAF 节流:同帧多 delta 仅排队一次,帧内取最新 lastStreamText parse
|
||||||
|
function scheduleStreamParse(text: string) {
|
||||||
|
lastStreamText = text
|
||||||
|
if (rafId !== null) return
|
||||||
|
rafId = requestAnimationFrame(() => {
|
||||||
|
rafId = null
|
||||||
|
streamingHtml.value = renderStreamingMd(lastStreamText)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// template 绑定入口:最后AI消息流式时用 streamingHtml(rAF节流块级memo),否则 renderMd(完成/历史)
|
||||||
|
function renderContent(msg: AiMessage): string {
|
||||||
|
if (isLastAi(msg) && store.state.streaming && store.state.currentText) {
|
||||||
|
return streamingHtml.value
|
||||||
|
}
|
||||||
|
return renderMd(msg.content)
|
||||||
|
}
|
||||||
|
|
||||||
const store = useAiStore()
|
const store = useAiStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// ── 错误 toast(零依赖,复用 Settings.vue 同款 reactive toast) ──
|
||||||
|
// AiChat 可能以分离窗口运行(App.vue 根 toast 不在 DOM 中),故本组件自管一个 toast;
|
||||||
|
// i18n 不在本任务白名单,文案硬编码,后续补 aiChat.toastSendFail key
|
||||||
|
const toast = reactive({ visible: false, msg: '', type: 'error' as 'error' | 'warning' | 'info' })
|
||||||
|
let _toastTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
const inputText = ref('')
|
const inputText = ref('')
|
||||||
const inputEl = ref<HTMLTextAreaElement>()
|
const inputEl = ref<HTMLTextAreaElement>()
|
||||||
const messagesContainer = ref<HTMLDivElement>()
|
const messagesContainer = ref<HTMLDivElement>()
|
||||||
@@ -368,23 +443,9 @@ const messagesContainer = ref<HTMLDivElement>()
|
|||||||
const toolCardListRef = ref<InstanceType<typeof ToolCardList>>()
|
const toolCardListRef = ref<InstanceType<typeof ToolCardList>>()
|
||||||
|
|
||||||
// ── 确认弹层(删对话等不可逆操作,复用 ConfirmDialog 组件) ──
|
// ── 确认弹层(删对话等不可逆操作,复用 ConfirmDialog 组件) ──
|
||||||
const confirmState = reactive({
|
// 状态机抽至 composables/useConfirm(原 Projects/ProjectDetail/Ideas/Settings/AiChat 各重复一份)
|
||||||
visible: false,
|
// 模板 confirmState.visible/msg 在 <script setup> 中自动解包 ref,绑定 ConfirmDialog 不变
|
||||||
msg: '',
|
const { confirmState, confirmDialog, answerConfirm } = useConfirm()
|
||||||
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
|
|
||||||
}
|
|
||||||
async function confirmDeleteConversation(id: string) {
|
async function confirmDeleteConversation(id: string) {
|
||||||
const conv = store.state.conversations.find(c => c.id === id)
|
const conv = store.state.conversations.find(c => c.id === id)
|
||||||
const title = conv?.title || t('aiChat.newConversation')
|
const title = conv?.title || t('aiChat.newConversation')
|
||||||
@@ -609,6 +670,9 @@ async function handleSend() {
|
|||||||
console.error('[AI] 发送失败:', e)
|
console.error('[AI] 发送失败:', e)
|
||||||
inputText.value = text
|
inputText.value = text
|
||||||
pendingSkill.value = skill
|
pendingSkill.value = skill
|
||||||
|
// 用户可见提示(原仅 console.error,用户无感);Tauri IPC 错误是字符串非 Error 对象
|
||||||
|
const msg = e instanceof Error ? e.message : String(e)
|
||||||
|
showToast(`发送失败:${msg}`, 'error')
|
||||||
}
|
}
|
||||||
await nextTick()
|
await nextTick()
|
||||||
scrollToBottom()
|
scrollToBottom()
|
||||||
@@ -651,6 +715,20 @@ function onContentChange() {
|
|||||||
watch(() => store.state.messages.length, onContentChange)
|
watch(() => store.state.messages.length, onContentChange)
|
||||||
watch(() => store.state.currentText, onContentChange)
|
watch(() => store.state.currentText, onContentChange)
|
||||||
|
|
||||||
|
// 流式 Markdown 渲染驱动(ARC-260615-08 块级 memo):
|
||||||
|
// - currentText 变化(每 delta):rAF 节流 scheduleStreamParse,块级 memo 只重 parse 末块
|
||||||
|
// - streaming 翻转:结束清 rAF + 清 streamingHtml(回 renderMd 走 final marked + 缓存)
|
||||||
|
watch(() => store.state.currentText, (text) => {
|
||||||
|
if (store.state.streaming && text) scheduleStreamParse(text)
|
||||||
|
})
|
||||||
|
watch(() => store.state.streaming, (s) => {
|
||||||
|
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null }
|
||||||
|
if (!s) { streamingHtml.value = ''; lastStreamText = '' }
|
||||||
|
})
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (rafId !== null) cancelAnimationFrame(rafId)
|
||||||
|
})
|
||||||
|
|
||||||
// ── 工具卡片自动收起:新内容追加(新消息/toolCall 状态变化/文本增长)时,
|
// ── 工具卡片自动收起:新内容追加(新消息/toolCall 状态变化/文本增长)时,
|
||||||
// 收起旧的已完成/已拒绝卡片,保留 running/pending/write_file 活跃卡片。
|
// 收起旧的已完成/已拒绝卡片,保留 running/pending/write_file 活跃卡片。
|
||||||
// 折叠态由 ToolCardList 自治,父级仅经 expose 调 collapseInactive ──
|
// 折叠态由 ToolCardList 自治,父级仅经 expose 调 collapseInactive ──
|
||||||
@@ -689,7 +767,7 @@ onMounted(async () => {
|
|||||||
const t0 = (window as any).__APP_T0 ?? 0
|
const t0 = (window as any).__APP_T0 ?? 0
|
||||||
// 启动耗时埋点:debug 级(浏览器默认折叠不干扰,空白屏复现时可开 verbose 看)
|
// 启动耗时埋点:debug 级(浏览器默认折叠不干扰,空白屏复现时可开 verbose 看)
|
||||||
console.debug(`[启动] AiChat mounted: ${(performance.now() - t0).toFixed(0)}ms`)
|
console.debug(`[启动] AiChat mounted: ${(performance.now() - t0).toFixed(0)}ms`)
|
||||||
loadMarkdown() // 后台预热 Markdown 渲染器,不阻塞首屏
|
loadMarkdown() // 后台预热 Markdown 渲染器,不阻塞首屏
|
||||||
await store.startListener()
|
await store.startListener()
|
||||||
await store.loadProviders()
|
await store.loadProviders()
|
||||||
await store.loadConversations()
|
await store.loadConversations()
|
||||||
@@ -714,6 +792,7 @@ async function toggleAlwaysOnTop() {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.ai-panel {
|
.ai-panel {
|
||||||
|
position: relative; /* 容纳 .ai-toast(absolute,相对面板定位,嵌入/分离模式均生效) */
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -1356,4 +1435,24 @@ async function toggleAlwaysOnTop() {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══ 错误 toast(复用 Settings.vue .toast 样式,前缀 ai- 防 scoped 冲突) ═══ */
|
||||||
|
.ai-toast {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 8px 14px;
|
||||||
|
border-radius: var(--df-radius);
|
||||||
|
font-size: 12px;
|
||||||
|
z-index: 100;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
|
||||||
|
max-width: 80%;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.ai-toast-error { background: var(--df-danger-bg); color: var(--df-danger); border: 0.5px solid var(--df-danger); }
|
||||||
|
.ai-toast-warning { background: var(--df-warning-bg); color: var(--df-warning); border: 0.5px solid var(--df-warning); }
|
||||||
|
.ai-toast-info { background: var(--df-accent-bg); color: var(--df-accent); border: 0.5px solid var(--df-accent); }
|
||||||
|
.ai-toast-enter-active, .ai-toast-leave-active { transition: opacity 0.2s, transform 0.2s; }
|
||||||
|
.ai-toast-enter-from, .ai-toast-leave-to { opacity: 0; transform: translate(-50%, -8px); }
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -320,13 +320,30 @@ function displayArgValue(arg: { key: string; val: unknown }): string {
|
|||||||
return formatArgValue(arg.val)
|
return formatArgValue(arg.val)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 工具显示名称(含路径摘要);无 path 时硬编码 fallback(path 几乎总有,i18n 兜底属过度防御) */
|
/**
|
||||||
|
* 工具显示名称(含目标摘要);文件类按 path,项目类(delete/restore/purge/update_project)按 id
|
||||||
|
* 解析项目名(复用 projectNameById,无则走 fallback),create_* 用固定文案。
|
||||||
|
*/
|
||||||
function toolDisplayName(tc: AiToolCallInfo): string {
|
function toolDisplayName(tc: AiToolCallInfo): string {
|
||||||
const p = (tc.args as any)?.path as string | undefined
|
const args = tc.args as any
|
||||||
|
const p = args?.path as string | undefined
|
||||||
switch (tc.name) {
|
switch (tc.name) {
|
||||||
case 'read_file': return p ? `${t('aiTool.readPrefix')} ${shortPath(p)}` : t('aiTool.readFallback')
|
case 'read_file': return p ? `${t('aiTool.readPrefix')} ${shortPath(p)}` : t('aiTool.readFallback')
|
||||||
case 'list_directory': return p ? `${t('aiTool.dirPrefix')} ${shortPath(p)}` : t('aiTool.dirFallback')
|
case 'list_directory': return p ? `${t('aiTool.dirPrefix')} ${shortPath(p)}` : t('aiTool.dirFallback')
|
||||||
case 'write_file': return p ? `${t('aiTool.writePrefix')} ${shortPath(p)}` : t('aiTool.writeFallback')
|
case 'write_file': return p ? `${t('aiTool.writePrefix')} ${shortPath(p)}` : t('aiTool.writeFallback')
|
||||||
|
case 'delete_project':
|
||||||
|
case 'restore_project':
|
||||||
|
case 'purge_project':
|
||||||
|
case 'update_project': {
|
||||||
|
const idKey = PROJECT_ID_TOOL_ARG[tc.name]
|
||||||
|
const id = idKey ? (args?.[idKey] as string | undefined) : ''
|
||||||
|
const name = id ? projectNameById.value[id] : ''
|
||||||
|
const prefixKey = `${tc.name.replace('_project', '')}Prefix` as 'deletePrefix' | 'restorePrefix' | 'purgePrefix' | 'updatePrefix'
|
||||||
|
const fallbackKey = `${tc.name.replace('_project', '')}Fallback` as 'deleteFallback' | 'restoreFallback' | 'purgeFallback' | 'updateFallback'
|
||||||
|
return name ? `${t('aiTool.' + prefixKey)}「${name}」` : t('aiTool.' + fallbackKey)
|
||||||
|
}
|
||||||
|
case 'create_task': return t('aiTool.createTask')
|
||||||
|
case 'create_project': return t('aiTool.createProject')
|
||||||
default: return formatToolName(tc.name)
|
default: return formatToolName(tc.name)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,8 @@ export async function switchConversation(id: string) {
|
|||||||
// 此处查回并把对应 toolCard.status 置 pending_approval,使审批卡片重新可见
|
// 此处查回并把对应 toolCard.status 置 pending_approval,使审批卡片重新可见
|
||||||
try {
|
try {
|
||||||
const pending = await aiApi.pendingToolCalls(id)
|
const pending = await aiApi.pendingToolCalls(id)
|
||||||
|
// 第二 await 后二次比对:切换 A→B 期间避免用 A 的 pending 覆写 B 的 pendingApprovals
|
||||||
|
if (mySwitchId !== _latestSwitchId) return
|
||||||
if (pending.length) {
|
if (pending.length) {
|
||||||
const pendingIds = new Set(pending.map(p => p.tool_call_id))
|
const pendingIds = new Set(pending.map(p => p.tool_call_id))
|
||||||
const restored: AiToolCallInfo[] = []
|
const restored: AiToolCallInfo[] = []
|
||||||
|
|||||||
@@ -41,8 +41,9 @@ async function sendMessage(text: string, skill?: string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userMsgId = `user-${nextMsgId()}`
|
||||||
state.messages.push({
|
state.messages.push({
|
||||||
id: `user-${nextMsgId()}`,
|
id: userMsgId,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content: text.trim(),
|
content: text.trim(),
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
@@ -68,10 +69,11 @@ async function sendMessage(text: string, skill?: string) {
|
|||||||
try {
|
try {
|
||||||
await aiApi.sendMessage(text.trim(), lang, skill)
|
await aiApi.sendMessage(text.trim(), lang, skill)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// IPC 失败(spawn 前/provider 配置错等):回滚 streaming 防光标卡死 + 移除空气泡占位;
|
// IPC 失败(spawn 前/provider 配置错等):回滚 streaming 防光标卡死 + 移除 user 消息与空气泡占位;
|
||||||
// 重新抛出由 handleSend 回填输入框,用户可重试
|
// 重新抛出由 handleSend 回填输入框,用户可重试
|
||||||
state.streaming = false
|
state.streaming = false
|
||||||
state.messages = state.messages.filter(m => m.id !== aiMsgId)
|
clearStreamWatchdog() // 清 :61 启动的看门狗,防 130s 后 onStreamTimeout 误推错误气泡
|
||||||
|
state.messages = state.messages.filter(m => m.id !== aiMsgId && m.id !== userMsgId)
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -83,9 +85,16 @@ async function approveToolCall(toolCallId: string, approved: boolean) {
|
|||||||
.flatMap(m => m.toolCalls || [])
|
.flatMap(m => m.toolCalls || [])
|
||||||
.find(t => t.id === toolCallId)
|
.find(t => t.id === toolCallId)
|
||||||
if (tc) tc.status = 'running'
|
if (tc) tc.status = 'running'
|
||||||
|
// 重启看门狗覆盖审批执行→续生成窗口(useAiEvents.ts:162 AiApprovalRequired 已 clear,
|
||||||
|
// 审批态无心跳兜底;approve 后后端要跑工具+续生成,期间任何后端异常不回则按钮永久 running。
|
||||||
|
// 复用通用 watchdog 而非加审批专用超时:复用同一收尾路径(onStreamTimeout 兜底复位 streaming),
|
||||||
|
// 避免新增独立计时器与状态分支)
|
||||||
|
resetStreamWatchdog()
|
||||||
try {
|
try {
|
||||||
await aiApi.approve(toolCallId, approved)
|
await aiApi.approve(toolCallId, approved)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
// IPC 未送达:用户已点过按钮,清看门狗(避免 130s 后误触发假错误)再回滚到失败态
|
||||||
|
clearStreamWatchdog()
|
||||||
// 仅 IPC 真失败(审批已处理/网络断)走到这里:后端工具失败已改走 emit completed,不进此分支
|
// 仅 IPC 真失败(审批已处理/网络断)走到这里:后端工具失败已改走 emit completed,不进此分支
|
||||||
// 不回滚 pending_approval(会卡死按钮),改设 completed + 错误提示,让用户知晓失败
|
// 不回滚 pending_approval(会卡死按钮),改设 completed + 错误提示,让用户知晓失败
|
||||||
console.error('[AI] 审批操作未送达后端:', e)
|
console.error('[AI] 审批操作未送达后端:', e)
|
||||||
|
|||||||
@@ -23,10 +23,26 @@ export function onStreamTimeout() {
|
|||||||
state.generatingConvId = null
|
state.generatingConvId = null
|
||||||
state.currentText = ''
|
state.currentText = ''
|
||||||
clearStreamWatchdog()
|
clearStreamWatchdog()
|
||||||
|
// 文案区分:若已有 completed 工具调用,说明工具执行完后续回复被中断(常见为审批后
|
||||||
|
// 续生成卡住),引导用户继续;否则按纯流式中断处理。
|
||||||
|
// 数据源:state.messages[].toolCalls[].status,反向扫尾部提前退出(O(1) 均,同 findToolCall);
|
||||||
|
// 不复用 useAiEvents.findToolCall 避免反向 import 循环依赖(useAiEvents 已 import 本模块)
|
||||||
|
let hasCompletedTool = false
|
||||||
|
for (let i = state.messages.length - 1; i >= 0; i--) {
|
||||||
|
const toolCalls = state.messages[i].toolCalls
|
||||||
|
if (!toolCalls) continue
|
||||||
|
for (let j = 0; j < toolCalls.length; j++) {
|
||||||
|
if (toolCalls[j].status === 'completed') { hasCompletedTool = true; break }
|
||||||
|
}
|
||||||
|
if (hasCompletedTool) break
|
||||||
|
}
|
||||||
|
const content = hasCompletedTool
|
||||||
|
? '⚠ 工具已执行完成,后续回复中断(长时间无数据流)。可点继续重试。'
|
||||||
|
: '⚠ 响应中断(长时间无数据流)。可能是网络断连或后端异常,请重试。'
|
||||||
state.messages.push({
|
state.messages.push({
|
||||||
id: `timeout-${nextMsgId()}`,
|
id: `timeout-${nextMsgId()}`,
|
||||||
role: 'assistant',
|
role: 'assistant',
|
||||||
content: '⚠ 响应中断(长时间无数据流)。可能是网络断连或后端异常,请重试。',
|
content,
|
||||||
isError: true,
|
isError: true,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
})
|
})
|
||||||
|
|||||||
74
src/composables/useMarkdown.ts
Normal file
74
src/composables/useMarkdown.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
//! Markdown 渲染 composable — 抽自 src/components/AiChat.vue
|
||||||
|
//!
|
||||||
|
//! 背景:TaskDetail 任务描述需 Markdown 渲染(## 标题 / - 列表),原渲染逻辑只在
|
||||||
|
//! AiChat.vue 内联,且与流式核心(splitBlocks/parseBlock/renderStreamingMd)共享
|
||||||
|
//! _marked/_purify/mdReady。抽出为 composable 复用,AiChat 流式核心保持原状不动。
|
||||||
|
//!
|
||||||
|
//! 设计要点:
|
||||||
|
//! - **模块级单例**:_marked/_purify/mdReady/_mdCache 放模块作用域,所有调用方共享
|
||||||
|
//! 同一份渲染器实例与缓存。AiChat 的 loadMarkdown() 与流式 parseBlock 共用此实例,
|
||||||
|
//! 行为与抽前完全一致。
|
||||||
|
//! - **XSS 防护**:renderMd 输出经 DOMPurify.sanitize,可安全 v-html。
|
||||||
|
//! - **块级 memo**:历史/完成态整段缓存(_mdCache),文本不变命中跳过 marked 全量解析。
|
||||||
|
//! - **未就绪兜底**:marked 未加载完成时 escapeHtml + <br> 降级(纯文本安全)。
|
||||||
|
//!
|
||||||
|
//! 行为零变化说明:本文件函数体从 AiChat.vue 原样移出,逻辑未改动。
|
||||||
|
//! AiChat.vue 流式函数(splitBlocks/parseBlock/parseBlockNoCache/renderStreamingMd/
|
||||||
|
//! scheduleStreamParse)继续调用 useMarkdown 暴露的 mdReady/escapeFallback,
|
||||||
|
//! 渲染结果不变。
|
||||||
|
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
// ═══ 模块级单例:AiChat 流式核心与历史 renderMd 共享同一份渲染器/缓存 ═══
|
||||||
|
let _marked: typeof import('marked')['marked'] | null = null
|
||||||
|
let _purify: typeof import('dompurify')['default'] | null = null
|
||||||
|
const mdReady = ref(false)
|
||||||
|
// 历史消息整段缓存(文本不变,重渲染命中省去 marked+sanitize 全量解析)
|
||||||
|
const _mdCache = new Map<string, string>()
|
||||||
|
|
||||||
|
export function useMarkdown() {
|
||||||
|
/** 异步加载 marked + DOMPurify;加载完成后 mdReady 翻转触发重渲染 */
|
||||||
|
async function loadMarkdown() {
|
||||||
|
const [{ marked }, dp] = await Promise.all([import('marked'), import('dompurify')])
|
||||||
|
marked.setOptions({ breaks: true, gfm: true })
|
||||||
|
_marked = marked
|
||||||
|
_purify = dp.default
|
||||||
|
_mdCache.clear() // 渲染器就绪,清兜底缓存触发完整重渲染
|
||||||
|
mdReady.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
mdReady,
|
||||||
|
loadMarkdown,
|
||||||
|
renderMd,
|
||||||
|
// 暴露给 AiChat 流式核心(parseBlock/renderStreamingMd)复用,保持单例共享
|
||||||
|
escapeFallback,
|
||||||
|
getMarked: () => _marked,
|
||||||
|
getPurify: () => _purify,
|
||||||
|
// 仅 AiChat 流式块级 memo 用(独立缓存,不与历史 _mdCache 混)
|
||||||
|
clearHistoryCache: () => _mdCache.clear(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text: string): string {
|
||||||
|
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// marked 未就绪时的兜底渲染:HTML 转义 + 换行转 <br>(纯文本安全降级)
|
||||||
|
function escapeFallback(text: string): string {
|
||||||
|
return escapeHtml(text).replace(/\n/g, '<br>')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 历史/完成态:整段 marked + sanitize + 缓存(函数体原样移自 AiChat.vue)
|
||||||
|
function renderMd(text: string): string {
|
||||||
|
if (!text) return ''
|
||||||
|
if (!mdReady.value || !_marked || !_purify) {
|
||||||
|
return escapeFallback(text)
|
||||||
|
}
|
||||||
|
const cached = _mdCache.get(text)
|
||||||
|
if (cached !== undefined) return cached
|
||||||
|
const html = _purify.sanitize(_marked.parse(text) as string)
|
||||||
|
if (_mdCache.size > 200) _mdCache.clear()
|
||||||
|
_mdCache.set(text, html)
|
||||||
|
return html
|
||||||
|
}
|
||||||
@@ -16,6 +16,17 @@ export default {
|
|||||||
dirFallback: 'List Directory',
|
dirFallback: 'List Directory',
|
||||||
writePrefix: 'Write',
|
writePrefix: 'Write',
|
||||||
writeFallback: 'Write File',
|
writeFallback: 'Write File',
|
||||||
|
// CRUD tool display names (delete/restore/purge/update_project resolve project name by id; fallback when no id or unresolved)
|
||||||
|
deletePrefix: 'Delete Project',
|
||||||
|
deleteFallback: 'Delete Project',
|
||||||
|
restorePrefix: 'Restore Project',
|
||||||
|
restoreFallback: 'Restore Project from Trash',
|
||||||
|
purgePrefix: 'Purge Project',
|
||||||
|
purgeFallback: 'Purge Project',
|
||||||
|
updatePrefix: 'Update Project',
|
||||||
|
updateFallback: 'Update Project',
|
||||||
|
createTask: 'Create Task',
|
||||||
|
createProject: 'Create Project',
|
||||||
|
|
||||||
taskCount: '{n} tasks',
|
taskCount: '{n} tasks',
|
||||||
projectCount: '{n} projects',
|
projectCount: '{n} projects',
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ export default {
|
|||||||
projects: 'Projects',
|
projects: 'Projects',
|
||||||
tasks: 'Tasks',
|
tasks: 'Tasks',
|
||||||
knowledge: 'Knowledge',
|
knowledge: 'Knowledge',
|
||||||
decisions: 'Decisions',
|
|
||||||
settings: 'Settings',
|
settings: 'Settings',
|
||||||
workspace: 'Workspace',
|
workspace: 'Workspace',
|
||||||
traceability: 'Traceability',
|
traceability: 'Traceability',
|
||||||
|
|||||||
@@ -17,6 +17,17 @@ export default {
|
|||||||
dirFallback: '列出目录',
|
dirFallback: '列出目录',
|
||||||
writePrefix: '写入',
|
writePrefix: '写入',
|
||||||
writeFallback: '写入文件',
|
writeFallback: '写入文件',
|
||||||
|
// CRUD 类工具显示名(delete/restore/purge/update_project 按 id 解析项目名;无 id 或解析失败走 fallback)
|
||||||
|
deletePrefix: '删除项目',
|
||||||
|
deleteFallback: '删除项目',
|
||||||
|
restorePrefix: '恢复项目',
|
||||||
|
restoreFallback: '从回收站恢复项目',
|
||||||
|
purgePrefix: '彻底删除项目',
|
||||||
|
purgeFallback: '彻底删除项目',
|
||||||
|
updatePrefix: '更新项目',
|
||||||
|
updateFallback: '更新项目',
|
||||||
|
createTask: '创建任务',
|
||||||
|
createProject: '创建项目',
|
||||||
|
|
||||||
// completed 结果摘要(header 折叠态可见)
|
// completed 结果摘要(header 折叠态可见)
|
||||||
taskCount: '{n} 项任务',
|
taskCount: '{n} 项任务',
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ export default {
|
|||||||
projects: '项目',
|
projects: '项目',
|
||||||
tasks: '任务',
|
tasks: '任务',
|
||||||
knowledge: '知识库',
|
knowledge: '知识库',
|
||||||
decisions: '决策',
|
|
||||||
settings: '设置',
|
settings: '设置',
|
||||||
workspace: '工作区',
|
workspace: '工作区',
|
||||||
traceability: '追溯',
|
traceability: '追溯',
|
||||||
|
|||||||
@@ -41,6 +41,12 @@ const routes = [
|
|||||||
component: () => import('../views/Tasks.vue'),
|
component: () => import('../views/Tasks.vue'),
|
||||||
meta: { title: '任务', icon: 'icon-thunder' },
|
meta: { title: '任务', icon: 'icon-thunder' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/tasks/:id',
|
||||||
|
name: 'TaskDetail',
|
||||||
|
component: () => import('../views/TaskDetail.vue'),
|
||||||
|
meta: { title: '任务详情', icon: 'icon-thunder' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/knowledge',
|
path: '/knowledge',
|
||||||
name: 'Knowledge',
|
name: 'Knowledge',
|
||||||
|
|||||||
@@ -5,9 +5,8 @@
|
|||||||
//! - 写操作立即更新缓存(乐观),并按 key debounce ~300ms 合并连续快速写
|
//! - 写操作立即更新缓存(乐观),并按 key debounce ~300ms 合并连续快速写
|
||||||
//! - 组合式 `useSetting` 让组件像用 ref 一样用 v-model 绑定某个 key
|
//! - 组合式 `useSetting` 让组件像用 ref 一样用 v-model 绑定某个 key
|
||||||
//!
|
//!
|
||||||
//! 注意:此为「KV 偏好持久化」层,与已有的 `stores/settings.ts`(mock 的 AI 提供商/
|
//! 注意:此为「KV 偏好持久化」层,与 AI 提供商/连接配置(store 层)是两件事 ——
|
||||||
//! 连接/通用配置列表)是两件事 —— 那个文件承载静态展示数据,本文件承载运行时偏好,
|
//! 那边承载 provider 业务数据,本文件承载运行时偏好,故单独成文,互不干扰。
|
||||||
//! 故单独成文,互不干扰。
|
|
||||||
|
|
||||||
import { reactive, ref, watch, type Ref } from 'vue'
|
import { reactive, ref, watch, type Ref } from 'vue'
|
||||||
import { settingsApi } from '../api/settings'
|
import { settingsApi } from '../api/settings'
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ export { useProjectStore } from './project'
|
|||||||
export { useKnowledgeStore } from './knowledge'
|
export { useKnowledgeStore } from './knowledge'
|
||||||
|
|
||||||
|
|
||||||
export { useSettingsStore } from './settings'
|
|
||||||
export type { AIProvider, Connection, GeneralSettings } from './settings'
|
|
||||||
|
|
||||||
export { useAppSettingsStore } from './appSettings'
|
export { useAppSettingsStore } from './appSettings'
|
||||||
|
|
||||||
|
export { useAiStore } from './ai'
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ const state = reactive({
|
|||||||
title: string
|
title: string
|
||||||
description: string
|
description: string
|
||||||
options: string[]
|
options: string[]
|
||||||
|
/** F-260615-01: single(缺省)/multiple */
|
||||||
|
select_type?: 'single' | 'multiple'
|
||||||
} | null,
|
} | null,
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null as string | null,
|
error: null as string | null,
|
||||||
@@ -244,16 +246,31 @@ function createStore() {
|
|||||||
state.liveEvents = []
|
state.liveEvents = []
|
||||||
}
|
}
|
||||||
|
|
||||||
async function approveHumanApproval(decision: string, comment?: string) {
|
/**
|
||||||
|
* 发送审批响应。
|
||||||
|
* - 单选(select_type=single 或缺省):传 decision 单值,decisions 留空;
|
||||||
|
* - 多选(select_type=multiple):传 decisions 数组。
|
||||||
|
* F-260615-01: 新增 decisions/select_type 透传,与后端 IPC 签名对齐。
|
||||||
|
*/
|
||||||
|
async function approveHumanApproval(
|
||||||
|
decision: string,
|
||||||
|
comment?: string,
|
||||||
|
decisions?: string[],
|
||||||
|
) {
|
||||||
if (!state.pendingApproval) return
|
if (!state.pendingApproval) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 调用 IPC 命令发送审批响应
|
// 调用 IPC 命令发送审批响应
|
||||||
|
// R-PD-5: 透传 options 闭环后端校验(options 非空时 decision 必须 ∈ options,否则 IPC 报错)。
|
||||||
|
// options 来自已收到的 HumanApprovalRequest 事件 payload(IPC 无法访问节点 config)。
|
||||||
await invoke('approve_human_approval', {
|
await invoke('approve_human_approval', {
|
||||||
execution_id: state.pendingApproval.execution_id,
|
execution_id: state.pendingApproval.execution_id,
|
||||||
node_id: state.pendingApproval.node_id,
|
node_id: state.pendingApproval.node_id,
|
||||||
decision,
|
decision,
|
||||||
comment
|
comment,
|
||||||
|
options: state.pendingApproval.options ?? [],
|
||||||
|
decisions: decisions ?? [],
|
||||||
|
selectType: state.pendingApproval.select_type ?? 'single', // Tauri camelCase→snake_case 自动转 select_type
|
||||||
})
|
})
|
||||||
|
|
||||||
// 清除待审批状态
|
// 清除待审批状态
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import { reactive, computed } from 'vue'
|
|
||||||
|
|
||||||
export interface AIProvider {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
apiKey: string
|
|
||||||
baseUrl: string
|
|
||||||
models: string[]
|
|
||||||
isDefault: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Connection {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
type: 'mysql' | 'ssh' | 'redis' | 'mongo'
|
|
||||||
typeLabel: string
|
|
||||||
icon: string
|
|
||||||
host: string
|
|
||||||
port: number
|
|
||||||
user: string
|
|
||||||
connected: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface GeneralSettings {
|
|
||||||
theme: 'dark' | 'light' | 'system'
|
|
||||||
language: string
|
|
||||||
dataDir: string
|
|
||||||
autoExecute: boolean
|
|
||||||
logLevel: 'error' | 'warn' | 'info' | 'debug'
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = reactive({
|
|
||||||
aiProviders: [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
name: 'Anthropic Claude',
|
|
||||||
apiKey: 'sk-ant-••••••••••••••••7xKm',
|
|
||||||
baseUrl: 'https://api.anthropic.com',
|
|
||||||
models: ['claude-sonnet-4-20250514', 'claude-opus-4-20250514'],
|
|
||||||
isDefault: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: 'Zhipu GLM',
|
|
||||||
apiKey: '••••••••••••••••a3f2',
|
|
||||||
baseUrl: 'https://open.bigmodel.cn/api/paas',
|
|
||||||
models: ['glm-4-plus', 'glm-4-flash'],
|
|
||||||
isDefault: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 3,
|
|
||||||
name: 'DeepSeek',
|
|
||||||
apiKey: '••••••••••••••••9b8c',
|
|
||||||
baseUrl: 'https://api.deepseek.com',
|
|
||||||
models: ['deepseek-chat', 'deepseek-coder'],
|
|
||||||
isDefault: false,
|
|
||||||
},
|
|
||||||
] as AIProvider[],
|
|
||||||
|
|
||||||
connections: [
|
|
||||||
{ id: 1, name: 'flux_dev', type: 'mysql' as const, typeLabel: 'MySQL', icon: '🗄️', host: '39.99.243.191', port: 3306, user: 'root', connected: true },
|
|
||||||
{ id: 2, name: 'flux_dev', type: 'ssh' as const, typeLabel: 'SSH', icon: '🔐', host: '39.99.243.191', port: 22, user: 'root', connected: true },
|
|
||||||
{ id: 3, name: 'flux_redis', type: 'redis' as const, typeLabel: 'Redis', icon: '⚡', host: '127.0.0.1', port: 6379, user: '', connected: true },
|
|
||||||
{ id: 4, name: 'suke_dev', type: 'mongo' as const, typeLabel: 'MongoDB', icon: '🍃', host: '127.0.0.1', port: 27017, user: 'admin', connected: false },
|
|
||||||
{ id: 5, name: 'rds_read', type: 'mysql' as const, typeLabel: 'MySQL', icon: '🗄️', host: 'rm-8vbrd2wil14hclop3vm.mysql.zhangbei.rds.aliyuncs.com', port: 3306, user: 'u_read', connected: true },
|
|
||||||
] as Connection[],
|
|
||||||
|
|
||||||
general: {
|
|
||||||
theme: 'dark' as const,
|
|
||||||
language: 'zh-CN',
|
|
||||||
dataDir: 'E:/wk-lab/devflow/data',
|
|
||||||
autoExecute: false,
|
|
||||||
logLevel: 'info' as const,
|
|
||||||
} as GeneralSettings,
|
|
||||||
})
|
|
||||||
|
|
||||||
export function useSettingsStore() {
|
|
||||||
const aiProviders = computed(() => state.aiProviders)
|
|
||||||
const connections = computed(() => state.connections)
|
|
||||||
const general = computed(() => state.general)
|
|
||||||
|
|
||||||
const defaultProvider = computed(() =>
|
|
||||||
state.aiProviders.find(p => p.isDefault)
|
|
||||||
)
|
|
||||||
|
|
||||||
const connectionStats = computed(() => ({
|
|
||||||
total: state.connections.length,
|
|
||||||
connected: state.connections.filter(c => c.connected).length,
|
|
||||||
}))
|
|
||||||
|
|
||||||
return {
|
|
||||||
aiProviders,
|
|
||||||
connections,
|
|
||||||
general,
|
|
||||||
defaultProvider,
|
|
||||||
connectionStats,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -121,7 +121,9 @@ html, body, #app {
|
|||||||
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.14); }
|
::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.14); }
|
||||||
|
|
||||||
/* — Selection — */
|
/* — Selection — */
|
||||||
::selection { background: var(--df-accent-soft); color: var(--df-accent); }
|
/* 选中态用 accent 实色 + 白字:保证紫底气泡(accent)选中也高对比可读,
|
||||||
|
避免 accent-soft(透明紫)叠 accent 紫底导致紫字紫底不可见(深色主题) */
|
||||||
|
::selection { background: var(--df-accent-hover); color: #fff; }
|
||||||
|
|
||||||
/* — Animations — */
|
/* — Animations — */
|
||||||
@keyframes fadeInUp {
|
@keyframes fadeInUp {
|
||||||
|
|||||||
@@ -59,7 +59,13 @@
|
|||||||
<h2 class="detail-title">{{ currentIdea.title }}</h2>
|
<h2 class="detail-title">{{ currentIdea.title }}</h2>
|
||||||
<span class="status-tag" :class="'status-' + currentIdea.status">{{ $t(statusLabelKey(currentIdea.status)) }}</span>
|
<span class="status-tag" :class="'status-' + currentIdea.status">{{ $t(statusLabelKey(currentIdea.status)) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="detail-desc">{{ currentIdea.description }}</p>
|
<!-- B-260615-25:灵感描述 Markdown 渲染,复用 useMarkdown composable(同 B-24 TaskDetail),空值回退 — -->
|
||||||
|
<p
|
||||||
|
v-if="currentIdea.description"
|
||||||
|
class="detail-desc ai-md"
|
||||||
|
v-html="renderedDesc"
|
||||||
|
></p>
|
||||||
|
<p v-else class="detail-desc">—</p>
|
||||||
|
|
||||||
<!-- 对抗式评估 -->
|
<!-- 对抗式评估 -->
|
||||||
<div class="detail-section">
|
<div class="detail-section">
|
||||||
@@ -208,8 +214,19 @@ import { useProjectStore } from '../stores/project'
|
|||||||
import { formatDate } from '../utils/time'
|
import { formatDate } from '../utils/time'
|
||||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||||
import { useConfirm } from '../composables/useConfirm'
|
import { useConfirm } from '../composables/useConfirm'
|
||||||
|
import { useMarkdown } from '../composables/useMarkdown'
|
||||||
import type { IdeaRecord } from '../api/types'
|
import type { IdeaRecord } from '../api/types'
|
||||||
|
|
||||||
|
// B-260615-25:灵感描述 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例)
|
||||||
|
const { renderMd, loadMarkdown, mdReady } = useMarkdown()
|
||||||
|
|
||||||
|
// 描述渲染依赖 mdReady 响应式:loadMarkdown 完成前 escapeFallback 纯文本,完成后 mdReady 翻转
|
||||||
|
// 触发 computed 重算(B-25 漏响应式致 v-html=renderMd 首次纯文本后不重算,代码块不渲染)
|
||||||
|
const renderedDesc = computed(() => {
|
||||||
|
void mdReady.value
|
||||||
|
return renderMd(currentIdea.value?.description ?? '')
|
||||||
|
})
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useProjectStore()
|
const store = useProjectStore()
|
||||||
@@ -433,6 +450,7 @@ async function onStatusChange(e: Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
loadMarkdown() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞
|
||||||
await store.loadIdeas()
|
await store.loadIdeas()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -797,6 +815,63 @@ onMounted(async () => {
|
|||||||
margin-bottom: var(--df-gap-page);
|
margin-bottom: var(--df-gap-page);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 灵感描述 Markdown 渲染(B-260615-25,样式同 TaskDetail B-24 .ai-md 收敛) ===== */
|
||||||
|
.detail-desc.ai-md { white-space: normal; color: var(--df-text-secondary); }
|
||||||
|
.detail-desc.ai-md :deep(p) { margin: 0 0 6px; }
|
||||||
|
.detail-desc.ai-md :deep(p:last-child) { margin-bottom: 0; }
|
||||||
|
.detail-desc.ai-md :deep(ul), .detail-desc.ai-md :deep(ol) { margin: 4px 0; padding-left: 20px; }
|
||||||
|
.detail-desc.ai-md :deep(li) { margin: 2px 0; line-height: 1.5; }
|
||||||
|
.detail-desc.ai-md :deep(code) {
|
||||||
|
font-family: var(--df-font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border-radius: var(--df-radius-sm);
|
||||||
|
color: var(--df-accent);
|
||||||
|
}
|
||||||
|
.detail-desc.ai-md :deep(pre) {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--df-bg);
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
|
border-radius: var(--df-radius);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.detail-desc.ai-md :deep(pre code) {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
color: var(--df-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.detail-desc.ai-md :deep(blockquote) {
|
||||||
|
margin: 6px 0;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-left: 2px solid var(--df-accent);
|
||||||
|
color: var(--df-text-secondary);
|
||||||
|
}
|
||||||
|
.detail-desc.ai-md :deep(h1), .detail-desc.ai-md :deep(h2), .detail-desc.ai-md :deep(h3) {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--df-text);
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
}
|
||||||
|
.detail-desc.ai-md :deep(h1) { font-size: 16px; }
|
||||||
|
.detail-desc.ai-md :deep(h2) { font-size: 14px; }
|
||||||
|
.detail-desc.ai-md :deep(h3) { font-size: 13px; }
|
||||||
|
.detail-desc.ai-md :deep(a) { color: var(--df-accent); text-decoration: none; }
|
||||||
|
.detail-desc.ai-md :deep(a:hover) { text-decoration: underline; }
|
||||||
|
.detail-desc.ai-md :deep(strong) { font-weight: 500; color: var(--df-text); }
|
||||||
|
.detail-desc.ai-md :deep(hr) { border: none; border-top: 0.5px solid var(--df-border); margin: 8px 0; }
|
||||||
|
.detail-desc.ai-md :deep(table) {
|
||||||
|
width: 100%; border-collapse: collapse; margin: 6px 0;
|
||||||
|
font-size: 12px; overflow-x: auto; display: block;
|
||||||
|
}
|
||||||
|
.detail-desc.ai-md :deep(th), .detail-desc.ai-md :deep(td) {
|
||||||
|
padding: 4px 8px; border: 0.5px solid var(--df-border); text-align: left;
|
||||||
|
}
|
||||||
|
.detail-desc.ai-md :deep(th) { font-weight: 500; background: var(--df-bg); }
|
||||||
|
|
||||||
.detail-section {
|
.detail-section {
|
||||||
margin-bottom: var(--df-gap-page);
|
margin-bottom: var(--df-gap-page);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,7 +110,13 @@
|
|||||||
|
|
||||||
<div class="detail-field">
|
<div class="detail-field">
|
||||||
<label>{{ $t('knowledge.contentLabel') }}</label>
|
<label>{{ $t('knowledge.contentLabel') }}</label>
|
||||||
<div v-if="!editing" class="detail-content">{{ detail.knowledge.content }}</div>
|
<!-- B-260615-25:知识内容 Markdown 渲染(展示态),复用 useMarkdown composable(同 B-24 TaskDetail);编辑态保持 textarea -->
|
||||||
|
<div
|
||||||
|
v-if="!editing && detail.knowledge.content"
|
||||||
|
class="detail-content ai-md"
|
||||||
|
v-html="renderedContent"
|
||||||
|
></div>
|
||||||
|
<div v-else-if="!editing" class="detail-content">—</div>
|
||||||
<textarea v-else v-model="editForm.content" class="edit-input edit-textarea"></textarea>
|
<textarea v-else v-model="editForm.content" class="edit-input edit-textarea"></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -229,11 +235,22 @@
|
|||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useKnowledgeStore, KNOWLEDGE_KINDS, parseTags } from '../stores/knowledge'
|
import { useKnowledgeStore, KNOWLEDGE_KINDS, parseTags } from '../stores/knowledge'
|
||||||
|
import { useMarkdown } from '../composables/useMarkdown'
|
||||||
import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '../api/types'
|
import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '../api/types'
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
const store = useKnowledgeStore()
|
const store = useKnowledgeStore()
|
||||||
|
|
||||||
|
// B-260615-25:知识内容 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例)
|
||||||
|
const { renderMd, loadMarkdown, mdReady } = useMarkdown()
|
||||||
|
|
||||||
|
// 内容渲染依赖 mdReady 响应式:loadMarkdown 完成前 escapeFallback 纯文本,完成后 mdReady 翻转
|
||||||
|
// 触发 computed 重算(B-25 漏响应式致 v-html=renderMd 首次纯文本后不重算,代码块不渲染)
|
||||||
|
const renderedContent = computed(() => {
|
||||||
|
void mdReady.value
|
||||||
|
return renderMd(detail.value?.knowledge.content ?? '')
|
||||||
|
})
|
||||||
|
|
||||||
// 顶层 Tab: library(知识库) | inbox(审核收件箱)
|
// 顶层 Tab: library(知识库) | inbox(审核收件箱)
|
||||||
const topTab = ref<'library' | 'inbox'>('library')
|
const topTab = ref<'library' | 'inbox'>('library')
|
||||||
|
|
||||||
@@ -495,6 +512,7 @@ function relativeTime(millisStr: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
loadMarkdown() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞
|
||||||
store.loadList()
|
store.loadList()
|
||||||
store.loadCandidates()
|
store.loadCandidates()
|
||||||
})
|
})
|
||||||
@@ -592,6 +610,63 @@ onMounted(() => {
|
|||||||
.detail-field { margin-bottom: 12px; }
|
.detail-field { margin-bottom: 12px; }
|
||||||
.detail-field label { display: block; font-size: 12px; color: var(--df-text-secondary); margin-bottom: 4px; }
|
.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-content { font-size: 13px; color: var(--df-text); line-height: 1.6; white-space: pre-wrap; }
|
||||||
|
|
||||||
|
/* ===== 知识内容 Markdown 渲染(B-260615-25,样式同 TaskDetail B-24 .ai-md 收敛) ===== */
|
||||||
|
.detail-content.ai-md { white-space: normal; }
|
||||||
|
.detail-content.ai-md :deep(p) { margin: 0 0 6px; }
|
||||||
|
.detail-content.ai-md :deep(p:last-child) { margin-bottom: 0; }
|
||||||
|
.detail-content.ai-md :deep(ul), .detail-content.ai-md :deep(ol) { margin: 4px 0; padding-left: 20px; }
|
||||||
|
.detail-content.ai-md :deep(li) { margin: 2px 0; line-height: 1.5; }
|
||||||
|
.detail-content.ai-md :deep(code) {
|
||||||
|
font-family: var(--df-font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border-radius: var(--df-radius-sm);
|
||||||
|
color: var(--df-accent);
|
||||||
|
}
|
||||||
|
.detail-content.ai-md :deep(pre) {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--df-bg);
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
|
border-radius: var(--df-radius);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.detail-content.ai-md :deep(pre code) {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
color: var(--df-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.detail-content.ai-md :deep(blockquote) {
|
||||||
|
margin: 6px 0;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-left: 2px solid var(--df-accent);
|
||||||
|
color: var(--df-text-secondary);
|
||||||
|
}
|
||||||
|
.detail-content.ai-md :deep(h1), .detail-content.ai-md :deep(h2), .detail-content.ai-md :deep(h3) {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--df-text);
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
}
|
||||||
|
.detail-content.ai-md :deep(h1) { font-size: 16px; }
|
||||||
|
.detail-content.ai-md :deep(h2) { font-size: 14px; }
|
||||||
|
.detail-content.ai-md :deep(h3) { font-size: 13px; }
|
||||||
|
.detail-content.ai-md :deep(a) { color: var(--df-accent); text-decoration: none; }
|
||||||
|
.detail-content.ai-md :deep(a:hover) { text-decoration: underline; }
|
||||||
|
.detail-content.ai-md :deep(strong) { font-weight: 500; color: var(--df-text); }
|
||||||
|
.detail-content.ai-md :deep(hr) { border: none; border-top: 0.5px solid var(--df-border); margin: 8px 0; }
|
||||||
|
.detail-content.ai-md :deep(table) {
|
||||||
|
width: 100%; border-collapse: collapse; margin: 6px 0;
|
||||||
|
font-size: 12px; overflow-x: auto; display: block;
|
||||||
|
}
|
||||||
|
.detail-content.ai-md :deep(th), .detail-content.ai-md :deep(td) {
|
||||||
|
padding: 4px 8px; border: 0.5px solid var(--df-border); text-align: left;
|
||||||
|
}
|
||||||
|
.detail-content.ai-md :deep(th) { font-weight: 500; background: var(--df-bg); }
|
||||||
.detail-tags { display: flex; gap: 6px; flex-wrap: 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); }
|
.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; }
|
.muted { color: var(--df-text-dim); font-size: 12px; }
|
||||||
|
|||||||
@@ -110,9 +110,15 @@
|
|||||||
<span class="label">{{ $t('projectDetail.updatedAt') }}</span>
|
<span class="label">{{ $t('projectDetail.updatedAt') }}</span>
|
||||||
<span>{{ formatDate(currentProject.updated_at) }}</span>
|
<span>{{ formatDate(currentProject.updated_at) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item info-block">
|
||||||
<span class="label">{{ $t('projectDetail.description') }}</span>
|
<span class="label">{{ $t('projectDetail.description') }}</span>
|
||||||
<span>{{ currentProject.description || '—' }}</span>
|
<!-- B-260615-25:项目描述 Markdown 渲染,复用 useMarkdown composable(同 B-24 TaskDetail) -->
|
||||||
|
<span
|
||||||
|
v-if="currentProject.description"
|
||||||
|
class="value description ai-md"
|
||||||
|
v-html="renderedDesc"
|
||||||
|
></span>
|
||||||
|
<span v-else>—</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<span class="label">{{ $t('projectDetail.projectStatus') }}</span>
|
<span class="label">{{ $t('projectDetail.projectStatus') }}</span>
|
||||||
@@ -153,7 +159,6 @@
|
|||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h2>{{ $t('projectDetail.workflowLogTitle') }}</h2>
|
<h2>{{ $t('projectDetail.workflowLogTitle') }}</h2>
|
||||||
<button class="btn btn-ghost btn-sm" @click="runDemoWorkflow" :disabled="workflowRunning">{{ $t('projectDetail.runDemoWorkflow') }}</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="log-list" ref="logListRef">
|
<div class="log-list" ref="logListRef">
|
||||||
<div class="log-item" v-for="(evt, idx) in formattedEvents" :key="idx" :class="'log-' + evt.level">
|
<div class="log-item" v-for="(evt, idx) in formattedEvents" :key="idx" :class="'log-' + evt.level">
|
||||||
@@ -176,6 +181,27 @@
|
|||||||
<p style="margin: 16px 0; color: var(--df-text-secondary);">{{ store.pendingApproval.description }}</p>
|
<p style="margin: 16px 0; color: var(--df-text-secondary);">{{ store.pendingApproval.description }}</p>
|
||||||
<div style="margin-bottom: 20px;">
|
<div style="margin-bottom: 20px;">
|
||||||
<p style="margin-bottom: 8px; font-size: 14px;">{{ $t('projectDetail.approvalHint') }}</p>
|
<p style="margin-bottom: 8px; font-size: 14px;">{{ $t('projectDetail.approvalHint') }}</p>
|
||||||
|
<!-- F-260615-01: select_type=multiple → checkbox 多选,缺省 single → 按钮单选 -->
|
||||||
|
<template v-if="isMultipleSelect">
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px;">
|
||||||
|
<label
|
||||||
|
v-for="(option, idx) in store.pendingApproval.options"
|
||||||
|
:key="idx"
|
||||||
|
style="display: flex; align-items: center; gap: 8px; cursor: pointer;"
|
||||||
|
>
|
||||||
|
<input type="checkbox" :value="option" v-model="multiDecisions" />
|
||||||
|
<span>{{ option }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary"
|
||||||
|
:disabled="multiDecisions.length === 0"
|
||||||
|
@click="handleApprovalMulti"
|
||||||
|
>
|
||||||
|
确认({{ multiDecisions.length }})
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
||||||
<button
|
<button
|
||||||
v-for="(option, idx) in store.pendingApproval.options"
|
v-for="(option, idx) in store.pendingApproval.options"
|
||||||
@@ -187,6 +213,7 @@
|
|||||||
{{ option }}
|
{{ option }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
@@ -214,6 +241,17 @@ import { parseStack } from '../utils/project'
|
|||||||
import { projectStatusLabel, projectStageInfo, taskStatusLabel, taskStatusClass } from '../constants/project'
|
import { projectStatusLabel, projectStageInfo, taskStatusLabel, taskStatusClass } from '../constants/project'
|
||||||
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
import ConfirmDialog from '../components/ConfirmDialog.vue'
|
||||||
import { useConfirm } from '../composables/useConfirm'
|
import { useConfirm } from '../composables/useConfirm'
|
||||||
|
import { useMarkdown } from '../composables/useMarkdown'
|
||||||
|
|
||||||
|
// B-260615-25:项目描述 Markdown 渲染(复用 AiChat/TaskDetail 同款渲染器,模块级单例)
|
||||||
|
const { renderMd, loadMarkdown, mdReady } = useMarkdown()
|
||||||
|
|
||||||
|
// 描述渲染依赖 mdReady 响应式:loadMarkdown 完成前 escapeFallback 纯文本,完成后 mdReady 翻转
|
||||||
|
// 触发 computed 重算(B-25 漏响应式致 v-html=renderMd 首次纯文本后不重算,代码块不渲染)
|
||||||
|
const renderedDesc = computed(() => {
|
||||||
|
void mdReady.value
|
||||||
|
return renderMd(currentProject.value?.description ?? '')
|
||||||
|
})
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -253,29 +291,10 @@ const projectTasks = computed(() =>
|
|||||||
// taskStatusLabel / taskStatusClass 由 ../constants/project 提供
|
// taskStatusLabel / taskStatusClass 由 ../constants/project 提供
|
||||||
|
|
||||||
// ── 工作流 ──
|
// ── 工作流 ──
|
||||||
const workflowRunning = ref(false)
|
// demoDag + runDemoWorkflow 已下线(R-PD-2):"script" 节点不再注册到 NodeRegistry,
|
||||||
|
// 前端构造含 script 节点的 DagDef 会在后端 build_dag 失败报错。
|
||||||
const demoDag = {
|
// 工作流执行日志面板保留,实时事件展示能力不依赖 demoDag。
|
||||||
nodes: [
|
// 待真实工作流需求落地(独立 BuildNode + 审批链)时重建演示入口。
|
||||||
{ id: 'n1', node_type: 'script', label: '环境检查', config: { command: 'echo "Environment OK"', timeout_secs: 10 } },
|
|
||||||
{ id: 'n2', node_type: 'script', label: '运行测试', config: { command: 'echo "Tests passed"', timeout_secs: 10 } },
|
|
||||||
{ id: 'n3', node_type: 'script', label: '构建产物', config: { command: 'echo "Build success"', timeout_secs: 10 } },
|
|
||||||
],
|
|
||||||
edges: [
|
|
||||||
{ from: 'n1', to: 'n2' },
|
|
||||||
{ from: 'n2', to: 'n3' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runDemoWorkflow() {
|
|
||||||
workflowRunning.value = true
|
|
||||||
store.clearLiveEvents()
|
|
||||||
try {
|
|
||||||
await store.runWorkflow('demo-pipeline', demoDag)
|
|
||||||
} finally {
|
|
||||||
workflowRunning.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── 实时日志格式化 ──
|
// ── 实时日志格式化 ──
|
||||||
interface LogEntry {
|
interface LogEntry {
|
||||||
@@ -397,6 +416,18 @@ async function handleApproval(decision: string) {
|
|||||||
showApprovalDialog.value = false
|
showApprovalDialog.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// F-260615-01: 多选审批
|
||||||
|
const isMultipleSelect = computed(() => store.pendingApproval?.select_type === 'multiple')
|
||||||
|
const multiDecisions = ref<string[]>([])
|
||||||
|
|
||||||
|
async function handleApprovalMulti() {
|
||||||
|
if (multiDecisions.value.length === 0) return
|
||||||
|
// decision 传首项(向后兼容),decisions 透传全量
|
||||||
|
await store.approveHumanApproval(multiDecisions.value[0], undefined, [...multiDecisions.value])
|
||||||
|
multiDecisions.value = []
|
||||||
|
showApprovalDialog.value = false
|
||||||
|
}
|
||||||
|
|
||||||
// ── 取消审批(调 cancel_workflow_node IPC 置节点 Cancelled)──
|
// ── 取消审批(调 cancel_workflow_node IPC 置节点 Cancelled)──
|
||||||
async function handleCancelApproval() {
|
async function handleCancelApproval() {
|
||||||
await store.cancelHumanApproval()
|
await store.cancelHumanApproval()
|
||||||
@@ -420,6 +451,8 @@ watch(formattedEvents, () => {
|
|||||||
// 监听审批请求
|
// 监听审批请求
|
||||||
watch(() => store.pendingApproval, (newApproval) => {
|
watch(() => store.pendingApproval, (newApproval) => {
|
||||||
if (newApproval) {
|
if (newApproval) {
|
||||||
|
// F-260615-01: 每次新审批清空多选状态(避免上轮残留)
|
||||||
|
multiDecisions.value = []
|
||||||
showApprovalDialog.value = true
|
showApprovalDialog.value = true
|
||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
@@ -429,6 +462,7 @@ watch(() => store.pendingApproval, (newApproval) => {
|
|||||||
|
|
||||||
// ── 生命周期 ──
|
// ── 生命周期 ──
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
loadMarkdown() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞
|
||||||
await store.loadProjects()
|
await store.loadProjects()
|
||||||
await store.loadTasks() // 全量加载,详情页用 computed 过滤本项目(避免覆盖全局 tasks 单例)
|
await store.loadTasks() // 全量加载,详情页用 computed 过滤本项目(避免覆盖全局 tasks 单例)
|
||||||
unlisten = await store.startEventListener()
|
unlisten = await store.startEventListener()
|
||||||
@@ -456,10 +490,15 @@ onUnmounted(() => {
|
|||||||
gap: var(--df-gap-grid);
|
gap: var(--df-gap-grid);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* B-260615-31:字段同行布局(label 固定宽 + 值占余),描述字段 info-block 保持块状 */
|
||||||
.info-item {
|
.info-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.info-item.info-block {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-item .label {
|
.info-item .label {
|
||||||
@@ -467,6 +506,8 @@ onUnmounted(() => {
|
|||||||
color: var(--df-text-dim);
|
color: var(--df-text-dim);
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
|
min-width: 88px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-item a.idea-link {
|
.info-item a.idea-link {
|
||||||
@@ -508,6 +549,63 @@ onUnmounted(() => {
|
|||||||
background: rgba(108, 99, 255, 0.08); color: var(--df-text-secondary);
|
background: rgba(108, 99, 255, 0.08); color: var(--df-text-secondary);
|
||||||
border: 0.5px solid var(--df-border);
|
border: 0.5px solid var(--df-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== 项目描述 Markdown 渲染(B-260615-25,样式同 TaskDetail B-24 .ai-md 收敛) ===== */
|
||||||
|
.description.ai-md { font-size: 14px; color: var(--df-text); line-height: 1.6; }
|
||||||
|
.description.ai-md :deep(p) { margin: 0 0 6px; }
|
||||||
|
.description.ai-md :deep(p:last-child) { margin-bottom: 0; }
|
||||||
|
.description.ai-md :deep(ul), .description.ai-md :deep(ol) { margin: 4px 0; padding-left: 20px; }
|
||||||
|
.description.ai-md :deep(li) { margin: 2px 0; line-height: 1.5; }
|
||||||
|
.description.ai-md :deep(code) {
|
||||||
|
font-family: var(--df-font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border-radius: var(--df-radius-sm);
|
||||||
|
color: var(--df-accent);
|
||||||
|
}
|
||||||
|
.description.ai-md :deep(pre) {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--df-bg);
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
|
border-radius: var(--df-radius);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.description.ai-md :deep(pre code) {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
color: var(--df-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.description.ai-md :deep(blockquote) {
|
||||||
|
margin: 6px 0;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-left: 2px solid var(--df-accent);
|
||||||
|
color: var(--df-text-secondary);
|
||||||
|
}
|
||||||
|
.description.ai-md :deep(h1), .description.ai-md :deep(h2), .description.ai-md :deep(h3) {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--df-text);
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
}
|
||||||
|
.description.ai-md :deep(h1) { font-size: 16px; }
|
||||||
|
.description.ai-md :deep(h2) { font-size: 14px; }
|
||||||
|
.description.ai-md :deep(h3) { font-size: 13px; }
|
||||||
|
.description.ai-md :deep(a) { color: var(--df-accent); text-decoration: none; }
|
||||||
|
.description.ai-md :deep(a:hover) { text-decoration: underline; }
|
||||||
|
.description.ai-md :deep(strong) { font-weight: 500; color: var(--df-text); }
|
||||||
|
.description.ai-md :deep(hr) { border: none; border-top: 0.5px solid var(--df-border); margin: 8px 0; }
|
||||||
|
.description.ai-md :deep(table) {
|
||||||
|
width: 100%; border-collapse: collapse; margin: 6px 0;
|
||||||
|
font-size: 12px; overflow-x: auto; display: block;
|
||||||
|
}
|
||||||
|
.description.ai-md :deep(th), .description.ai-md :deep(td) {
|
||||||
|
padding: 4px 8px; border: 0.5px solid var(--df-border); text-align: left;
|
||||||
|
}
|
||||||
|
.description.ai-md :deep(th) { font-weight: 500; background: var(--df-bg); }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
362
src/views/TaskDetail.vue
Normal file
362
src/views/TaskDetail.vue
Normal file
@@ -0,0 +1,362 @@
|
|||||||
|
<template>
|
||||||
|
<div class="task-detail">
|
||||||
|
<!-- 页面头部 -->
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<router-link to="/tasks" class="back-link">← 返回任务列表</router-link>
|
||||||
|
<h1>{{ task?.title ?? '...' }}</h1>
|
||||||
|
<span v-if="task" class="status-tag" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</span>
|
||||||
|
<span v-if="task" class="priority-badge" :class="priorityClass(task.priority)">{{ priorityLabel(task.priority) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<button class="btn btn-ghost" type="button" @click="refresh">刷新</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 加载/错误态 -->
|
||||||
|
<div v-if="loading" class="empty-hint">加载中...</div>
|
||||||
|
<div v-else-if="errorMsg" class="empty-hint error-hint">⚠ {{ errorMsg }}</div>
|
||||||
|
|
||||||
|
<!-- 主体 -->
|
||||||
|
<div v-else-if="task" class="detail-grid">
|
||||||
|
<section class="panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2>任务信息</h2>
|
||||||
|
</div>
|
||||||
|
<div class="task-info">
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">标题</span>
|
||||||
|
<span class="value">{{ task.title }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item info-block">
|
||||||
|
<span class="label">描述</span>
|
||||||
|
<!-- B-24:任务描述 Markdown 渲染(## 标题 / - 列表 等),v-html 经 useMarkdown DOMPurify sanitize -->
|
||||||
|
<span
|
||||||
|
class="value description ai-md"
|
||||||
|
v-if="task.description"
|
||||||
|
v-html="renderedDescription"
|
||||||
|
></span>
|
||||||
|
<span v-else class="value description">—</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">状态</span>
|
||||||
|
<span class="value">
|
||||||
|
<span class="status-tag" :class="taskStatusClass(task.status)">{{ $t(taskStatusLabel(task.status)) }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">优先级</span>
|
||||||
|
<span class="value">
|
||||||
|
<span class="priority-badge" :class="priorityClass(task.priority)">{{ priorityLabel(task.priority) }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">关联项目</span>
|
||||||
|
<span class="value">
|
||||||
|
<router-link v-if="task.project_id" :to="`/projects/${task.project_id}`" class="project-link">
|
||||||
|
{{ projectName }}
|
||||||
|
</router-link>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">分支</span>
|
||||||
|
<span class="value">
|
||||||
|
<span v-if="task.branch_name" class="branch-tag">
|
||||||
|
<span class="branch-icon">⑂</span>{{ task.branch_name }}
|
||||||
|
</span>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">负责人</span>
|
||||||
|
<span class="value">{{ task.assignee ?? '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">基础分支</span>
|
||||||
|
<span class="value">{{ task.base_branch ?? '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">工作流定义</span>
|
||||||
|
<span class="value mono">{{ task.workflow_def_id ?? '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">创建时间</span>
|
||||||
|
<span class="value">{{ formatDate(task.created_at) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="info-item">
|
||||||
|
<span class="label">更新时间</span>
|
||||||
|
<span class="value">{{ formatDate(task.updated_at) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { taskApi, projectApi } from '../api'
|
||||||
|
import { formatDate } from '../utils/time'
|
||||||
|
import { useMarkdown } from '../composables/useMarkdown'
|
||||||
|
import {
|
||||||
|
taskStatusLabel,
|
||||||
|
taskStatusClass,
|
||||||
|
priorityLabel,
|
||||||
|
priorityClass,
|
||||||
|
} from '../constants/project'
|
||||||
|
import type { TaskRecord, ProjectRecord } from '../api/types'
|
||||||
|
|
||||||
|
// B-24:任务描述 Markdown 渲染(复用 AiChat 同款渲染器,模块级单例)
|
||||||
|
const { renderMd, loadMarkdown, mdReady } = useMarkdown()
|
||||||
|
|
||||||
|
// 描述渲染依赖 mdReady 响应式:loadMarkdown 完成前 escapeFallback 纯文本,完成后 mdReady 翻转
|
||||||
|
// 触发 computed 重算(B-24 漏响应式致 v-html=renderMd 首次纯文本后不重算,代码块不渲染)
|
||||||
|
const renderedDescription = computed(() => {
|
||||||
|
void mdReady.value
|
||||||
|
return renderMd(task.value?.description ?? '')
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const task = ref<TaskRecord | null>(null)
|
||||||
|
const projects = ref<ProjectRecord[]>([])
|
||||||
|
|
||||||
|
const taskId = computed(() => route.params.id as string)
|
||||||
|
|
||||||
|
const projectName = computed(() => {
|
||||||
|
const p = projects.value.find(p => p.id === task.value?.project_id)
|
||||||
|
return p?.name ?? task.value?.project_id ?? '—'
|
||||||
|
})
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
errorMsg.value = ''
|
||||||
|
try {
|
||||||
|
const [t, ps] = await Promise.all([
|
||||||
|
taskApi.get(taskId.value),
|
||||||
|
// 项目列表用于解析 project_id → 项目名(独立入口,不依赖全局 store)
|
||||||
|
projectApi.list(),
|
||||||
|
])
|
||||||
|
task.value = t
|
||||||
|
projects.value = ps
|
||||||
|
} catch (e: any) {
|
||||||
|
task.value = null
|
||||||
|
errorMsg.value = e?.toString() ?? '加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 路由参数变化(id 变化)时重新加载
|
||||||
|
watch(taskId, () => { load() })
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadMarkdown() // 后台预热 Markdown 渲染器(模块单例,与 AiChat 共享),不阻塞 load
|
||||||
|
load()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.task-detail { padding: 16px 20px 20px; }
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--df-gap-page);
|
||||||
|
}
|
||||||
|
.header-left { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.back-link { font-size: 13px; color: var(--df-accent); text-decoration: none; white-space: nowrap; }
|
||||||
|
.back-link:hover { text-decoration: underline; }
|
||||||
|
.page-header h1 { font-size: 24px; font-weight: 500; color: var(--df-text); }
|
||||||
|
|
||||||
|
.header-actions { display: flex; gap: 10px; }
|
||||||
|
|
||||||
|
/* ===== 按钮 ===== */
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--df-radius-sm);
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
/* ===== 状态/优先级标签 ===== */
|
||||||
|
.status-tag {
|
||||||
|
font-size: 11px; font-weight: 500;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: var(--df-radius-sm);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.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); }
|
||||||
|
|
||||||
|
.priority-badge {
|
||||||
|
font-size: 10px; font-weight: 500;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--df-radius-xs);
|
||||||
|
}
|
||||||
|
.priority-critical { background: rgba(255,107,107,0.2); color: var(--df-danger); }
|
||||||
|
.priority-high { background: rgba(255,152,0,0.2); color: #ff9800; }
|
||||||
|
.priority-medium { background: rgba(100,181,246,0.2); color: var(--df-info); }
|
||||||
|
.priority-low { background: rgba(90,99,128,0.2); color: var(--df-text-dim); }
|
||||||
|
|
||||||
|
/* ===== 面板 ===== */
|
||||||
|
.detail-grid { display: grid; grid-template-columns: 1fr; gap: var(--df-gap-grid); }
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
background: var(--df-bg-card);
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
|
border-radius: var(--df-radius-lg);
|
||||||
|
padding: var(--df-pad-panel);
|
||||||
|
}
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: var(--df-gap-head);
|
||||||
|
}
|
||||||
|
.panel-header h2 { font-size: 15px; font-weight: 500; }
|
||||||
|
|
||||||
|
/* ===== 信息列表 ===== */
|
||||||
|
.task-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--df-gap-grid);
|
||||||
|
}
|
||||||
|
/* B-260615-31:字段同行布局(label 固定宽 + value 占余),描述字段 info-block 保持块状 */
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.info-item.info-block {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.info-item .label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--df-text-dim);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
min-width: 88px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.info-item .value { font-size: 14px; color: var(--df-text); flex: 1; min-width: 0; }
|
||||||
|
/* B-24 漏 white-space 覆盖致 v-html 后 HTML 标签间 \n 被 pre-wrap 渲染为空行间距——移除 pre-wrap,描述由 .ai-md 接管(line-height 1.5 对齐 AiChat li) */
|
||||||
|
.info-item .value.description { line-height: 1.5; }
|
||||||
|
|
||||||
|
/* ===== 任务描述 Markdown 渲染(B-24,样式同 AiChat .ai-md 收敛) ===== */
|
||||||
|
.description.ai-md :deep(p) { margin: 0 0 6px; }
|
||||||
|
.description.ai-md :deep(p:last-child) { margin-bottom: 0; }
|
||||||
|
.description.ai-md :deep(ul), .description.ai-md :deep(ol) { margin: 4px 0; padding-left: 20px; }
|
||||||
|
.description.ai-md :deep(li) { margin: 2px 0; line-height: 1.5; }
|
||||||
|
.description.ai-md :deep(code) {
|
||||||
|
font-family: var(--df-font-mono);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
border-radius: var(--df-radius-sm);
|
||||||
|
color: var(--df-accent);
|
||||||
|
}
|
||||||
|
.description.ai-md :deep(pre) {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--df-bg);
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
|
border-radius: var(--df-radius);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
.description.ai-md :deep(pre code) {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 0;
|
||||||
|
color: var(--df-text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.description.ai-md :deep(blockquote) {
|
||||||
|
margin: 6px 0;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-left: 2px solid var(--df-accent);
|
||||||
|
color: var(--df-text-secondary);
|
||||||
|
}
|
||||||
|
.description.ai-md :deep(h1), .description.ai-md :deep(h2), .description.ai-md :deep(h3) {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--df-text);
|
||||||
|
margin: 8px 0 4px;
|
||||||
|
}
|
||||||
|
.description.ai-md :deep(h1) { font-size: 16px; }
|
||||||
|
.description.ai-md :deep(h2) { font-size: 14px; }
|
||||||
|
.description.ai-md :deep(h3) { font-size: 13px; }
|
||||||
|
.description.ai-md :deep(a) {
|
||||||
|
color: var(--df-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.description.ai-md :deep(a:hover) { text-decoration: underline; }
|
||||||
|
.description.ai-md :deep(strong) { font-weight: 500; color: var(--df-text); }
|
||||||
|
.description.ai-md :deep(hr) {
|
||||||
|
border: none;
|
||||||
|
border-top: 0.5px solid var(--df-border);
|
||||||
|
margin: 8px 0;
|
||||||
|
}
|
||||||
|
.description.ai-md :deep(table) {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 6px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
overflow-x: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.description.ai-md :deep(th), .description.ai-md :deep(td) {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 0.5px solid var(--df-border);
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.description.ai-md :deep(th) {
|
||||||
|
font-weight: 500;
|
||||||
|
background: var(--df-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-link {
|
||||||
|
color: var(--df-accent);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
.project-link:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.branch-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--df-text-secondary);
|
||||||
|
background: rgba(108, 99, 255, 0.06);
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: var(--df-radius-xs);
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
.branch-icon { color: var(--df-accent); }
|
||||||
|
|
||||||
|
.mono { font-family: 'SF Mono', 'Fira Code', monospace; font-size: 12px; }
|
||||||
|
|
||||||
|
.empty-hint {
|
||||||
|
text-align: center; padding: 24px 12px;
|
||||||
|
font-size: 13px; color: var(--df-text-dim);
|
||||||
|
}
|
||||||
|
.error-hint { color: var(--df-danger); }
|
||||||
|
</style>
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
<span class="group-count">{{ $t('tasks.group.taskCount', { n: group.tasks.length }) }}</span>
|
<span class="group-count">{{ $t('tasks.group.taskCount', { n: group.tasks.length }) }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="task-list">
|
<div class="task-list">
|
||||||
<div class="task-item" v-for="task in group.tasks" :key="task.id">
|
<div class="task-item" v-for="task in group.tasks" :key="task.id" @click="router.push(`/tasks/${task.id}`)">
|
||||||
<div class="task-main">
|
<div class="task-main">
|
||||||
<div class="task-title-row">
|
<div class="task-title-row">
|
||||||
<span class="task-title">{{ task.title }}</span>
|
<span class="task-title">{{ task.title }}</span>
|
||||||
@@ -101,13 +101,15 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useProjectStore } from '../stores/project'
|
import { useProjectStore } from '../stores/project'
|
||||||
import { formatRelativeZh } from '../utils/time'
|
import { formatRelativeZh } from '../utils/time'
|
||||||
import { taskStatusLabel as statusLabel, taskStatusClass, priorityLabel, priorityClass } from '../constants/project'
|
import { taskStatusLabel as statusLabel, taskStatusClass, priorityLabel, priorityClass } from '../constants/project'
|
||||||
import type { TaskRecord } from '../api/types'
|
import type { TaskRecord } from '../api/types'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const store = useProjectStore()
|
const store = useProjectStore()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
@@ -209,6 +211,11 @@ async function confirmCreate() {
|
|||||||
showCreateModal.value = false
|
showCreateModal.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// B-260615-29: 切换项目筛选时按 projectId 重载(避免仅前端 filter,跨项目视图不同步)
|
||||||
|
watch(activeProject, (key) => {
|
||||||
|
store.loadTasks(key === 'all' ? undefined : key)
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([store.loadProjects(), store.loadTasks()])
|
await Promise.all([store.loadProjects(), store.loadTasks()])
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user