重构: 前端DRY(style全局沉淀 + ts/js公共沉淀)

style-dry:
- 新增 src/styles/components.css: .info-item*/.panel/.panel-header/.tech-tag/.card-tags 全局
- main.ts 注册 import
- 删重复: ProjectDetail/TaskDetail/TaskOutputCard/ProjectCard/Projects(scoped删重复, 全局对子组件生效)
- 未使用清理: Projects .card-progress dead(1处保守核对)

tsjs-dry:
- aiShared.ts: resolveAiLang(合并useAiSend.resolveLang + useAiContext.resolveLanguage)
- stores/knowledge.ts: kindText/kindLabel/kindIcon/knowledgeStatusLabel/knowledgeConfidenceLabel(5公共)
- 删重复: useAiSend/useAiContext(resolveLang) + Knowledge/KnowledgeDetail(4+5 helper) + ActiveProjectsPanel(formatLastActivity→formatRelativeZh复用)

strategy: DRY收口, 公共沉淀(utils/store/shared); relativeTime非纯重复保留; .btn/.empty-hint局部override WATCH未动
This commit is contained in:
2026-06-19 12:11:19 +08:00
parent 64ece1d3a0
commit 7748e3ecbc
14 changed files with 177 additions and 212 deletions

View File

@@ -39,7 +39,7 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useProjectStore } from '@/stores/project'
import { parseTs } from '@/utils/time'
import { formatRelativeZh } from '@/utils/time'
const store = useProjectStore()
const { t } = useI18n()
@@ -59,17 +59,9 @@ function getProjectTaskCount(projectId: string): number {
return store.tasks.filter(t => t.project_id === projectId && t.status === 'in_progress').length
}
function formatLastActivity(iso: string): string {
const ms = parseTs(iso)
if (ms == null) return '—'
const diffMin = Math.floor((Date.now() - ms) / 60000)
if (diffMin < 1) return t('common.justNow')
if (diffMin < 60) return t('common.minutesAgo', { n: diffMin })
const diffH = Math.floor(diffMin / 60)
if (diffH < 24) return t('common.hoursAgo', { n: diffH })
const diffD = Math.floor(diffH / 24)
return t('common.dayAgo', { n: diffD })
}
// 相对时间复用 utils/time.formatRelativeZh(与 Tasks/AuditLog/MessageList 等同源,根治 NaN)
// 原 formatLastActivity 是其逐行复制,提取为单一来源。
const formatLastActivity = formatRelativeZh
const displayProjects = computed(() =>
store.projects.map(p => {

View File

@@ -107,7 +107,13 @@
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { KNOWLEDGE_KINDS, parseTags } from '@/stores/knowledge'
import {
parseTags,
kindLabel,
kindIcon,
knowledgeStatusLabel as statusLabel,
knowledgeConfidenceLabel as confidenceLabel,
} from '@/stores/knowledge'
import { useRendered } from '@/composables/useMarkdown'
import type { KnowledgeDetailPayload, KnowledgeEventRecord } from '@/api/types'
@@ -244,27 +250,7 @@ const timelineNodes = computed(() => {
return nodes
})
// ===== 标签/时间辅助(从 Knowledge.vue 迁移,行为一致)=====
function kindLabel(key: string): string {
const k = KNOWLEDGE_KINDS.find(c => c.key === key)
return k ? `${k.icon} ${kindText(key)}` : key
}
function kindText(key: string): string {
return t(`knowledge.kind.${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
}
// ===== 标签/时间辅助(kind*/status/confidence label 由 @/stores/knowledge 共用,相对时间本组件自留 30 天截止)=====
// 相对时间:复用 common.* 既有 key(justNow/minutesAgo/hoursAgo/dayAgo),30 天以上回退绝对值
function relativeTime(millisStr: string): string {
const ms = Number(millisStr)

View File

@@ -107,20 +107,6 @@ const router = useRouter()
}
.stat-icon { font-size: 13px; }
.card-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.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);
}
.card-top-actions { display: flex; align-items: center; gap: 8px; }
.card-del-btn {
background: transparent; border: none; cursor: pointer;

View File

@@ -87,27 +87,9 @@ const reviewSuggestions = computed<string[]>(() => {
<style scoped>
/* ===== F-AiNodeSelfReview: output_json 区块(产出 + 自审结论卡) ===== */
/* 基础布局 .info-item / .info-block / .label / .value:Vue scoped 不穿透子组件后代 DOM,
父级 TaskDetail.vue 的 .info-item .label 等后代选择器不会作用到本子组件内部元素,
此处复制等价基础布局(与 TaskDetail.vue L559-577 一致),保持视觉零变化。 */
.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; }
/* 基础布局 .info-item / .info-block / .label / .value 已收敛至全局 components.css
(DRY 收口 B-260619)。全局类对子组件内部 DOM 生效(全局非 scoped 无渗透限制),
本子组件无需复制等价基础布局,仅保留本组件特有的 output/review 样式。 */
.output-block .output-body {
display: flex;

View File

@@ -12,11 +12,30 @@
import { aiApi } from '@/api'
import { state } from '@/stores/ai'
import { useAppSettingsStore } from '@/stores/appSettings'
import i18n from '@/i18n'
import type { AiToolCallInfo } from '@/api/types'
const appSettings = useAppSettingsStore()
const t = ((i18n as any).global.t as (k: string, named?: Record<string, unknown>) => string).bind((i18n as any).global)
/**
* 解析 AI 回复语言(useAiSend.sendMessage / useAiContext.compressContext 共用)。
*
* df-ai-language='auto' → 回落到 df-language(应用主语言);否则用 df-ai-language 指定值;
* 兜底 'zh-CN'。
*
* 原分别在 useAiSend.resolveLang(三元)与 useAiContext.resolveLanguage(if/else)各一份,
* 语义相同仅写法不同,提取至此共用。
*/
export function resolveAiLang(): string {
const raw = appSettings.get<string>('df-ai-language', 'auto')
return raw === 'auto'
? appSettings.get<string>('df-language', 'zh-CN')
: raw
}
/** 客户端消息自增 id(全局唯一,多个 composable 共用 nextMsgId) */
let _msgCounter = 0

View File

@@ -24,15 +24,13 @@
//! 耦合:
//! - aiApi.clearContext / aiApi.compressContext(IPC)
//! - state.activeConversationId(读当前对话)
//! - appSettings.get(df-ai-language/df-language)(压缩语言;与 useAiSend.sendMessage 同源)
//! - resolveAiLang(压缩语言;与 useAiSend.sendMessage 同源,实现在 aiShared)
import { ref } from 'vue'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
const appSettings = useAppSettingsStore()
import { resolveAiLang } from './aiShared'
/// 压缩中 loading(防重复点击;AiCompressing→true / AiCompressed|AiError→false)
const isCompressing = ref(false)
@@ -108,20 +106,6 @@ function stopContextListener(): void {
_callbacks = {}
}
/**
* 解析压缩语言(与 useAiSend.sendMessage 同源逻辑):
* - df-ai-language='auto' → df-language(应用主语言)
* - 否则用 df-ai-language 指定值
* - 兜底 'zh-CN'
*/
function resolveLanguage(): string {
const raw = appSettings.get<string>('df-ai-language', 'auto')
if (raw === 'auto') {
return appSettings.get<string>('df-language', 'zh-CN')
}
return raw
}
/**
* 压缩当前对话上下文。
*
@@ -136,7 +120,7 @@ async function compressContext(): Promise<void> {
if (isCompressing.value) return // 防重复点击
isCompressing.value = true
try {
await aiApi.compressContext(convId, resolveLanguage())
await aiApi.compressContext(convId, resolveAiLang())
// 后端 IPC 同步等到 LLM 压缩完成才 resolve(非 spawn 异步),成功即复位 isCompressing;
// case 'AiCompressed' 事件也复位(双保险,防事件丢失致按钮永久禁用)
isCompressing.value = false

View File

@@ -19,31 +19,15 @@ import { listen } from '@tauri-apps/api/event'
import { invoke } from '@tauri-apps/api/core'
import { ref } from 'vue'
import { aiApi } from '@/api'
import { useAppSettingsStore } from '@/stores/appSettings'
import { state } from '@/stores/ai'
import type { ContentPart, AiMessage } from '@/api/types'
import i18n from '@/i18n'
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
import { startListener } from './useAiEvents'
import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers } from './aiShared'
import { nextMsgId, findToolCall, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang } from './aiShared'
const t = ((i18n as any).global.t as (k: string, named?: Record<string, unknown>) => string).bind((i18n as any).global)
const appSettings = useAppSettingsStore()
/**
* B-260617-03 DRY: 解析 AI 回复语言。
*
* df-ai-language='auto' → 回落到 df-language;否则用 df-ai-language 指定值。
* doSend/regenerate/editMessage 三处复用(原各内联同一段表达式)。
*/
function resolveLang(): string {
const raw = appSettings.get<string>('df-ai-language', 'auto')
return raw === 'auto'
? appSettings.get<string>('df-language', 'zh-CN')
: raw
}
/// 待发送队列上限(超过抛错提示用户)
const QUEUE_LIMIT = 10
@@ -114,7 +98,7 @@ async function doSend(text: string, skill?: string, force = false, parts?: Conte
resetStreamWatchdog() // 启动流式看门狗,无数据超时兜底
await startListener()
const lang = resolveLang()
const lang = resolveAiLang()
try {
// force=true 走 force_send IPC(复位残留 generating),否则走正常 send
// F-01 阶段6: 透传 modelOverride(主对话专用,后端兜底校验池内才用)
@@ -170,7 +154,7 @@ async function regenerate() {
resetStreamWatchdog()
await startListener()
const lang = resolveLang()
const lang = resolveAiLang()
const convId = state.activeConversationId
if (!convId) {
// 无活跃对话:回滚占位,报错
@@ -240,7 +224,7 @@ async function editMessage(newMessage: string) {
resetStreamWatchdog()
await startListener()
const lang = resolveLang()
const lang = resolveAiLang()
const convId = state.activeConversationId
if (!convId) {
state.streaming = false

View File

@@ -4,6 +4,7 @@ import router from "./router";
import i18n from "./i18n";
import "./styles/global.css";
import "./styles/ai-md.css";
import "./styles/components.css";
const app = createApp(App);

View File

@@ -58,6 +58,40 @@ export function parseTags(tags: string | null): string[] {
}
}
// ── 知识 label 文案辅助(列表 Knowledge.vue 与详情 KnowledgeDetail.vue 共用) ──
// 原两文件各内联一份重复实现(SW-260618-20 详情迁移时复制),提取至此单一来源。
// 全部依赖 i18n,故用模块级 t(同文件 parseTags 之上的全局 t)。
/** 知识类型文案(无 icon):${icon} ${text} 组合的文本部分,亦供下拉选项 */
export function kindText(key: string): string {
return t(`knowledge.kind.${key}`)
}
/** 知识类型带 icon 文案(列表/详情卡片);未知 key 回退原值 */
export function kindLabel(key: string): string {
const k = KNOWLEDGE_KINDS.find(c => c.key === key)
return k ? `${k.icon} ${kindText(key)}` : key
}
/** 知识类型 icon;未知回退通用图标 */
export function kindIcon(key: string): string {
return KNOWLEDGE_KINDS.find(c => c.key === key)?.icon ?? '📄'
}
/** 知识状态文案;$t(key) 命中失败(无该 status key)回退原值,避免裸露 key */
export function knowledgeStatusLabel(status: string): string {
const key = `knowledge.status.${status}`
const msg = t(key)
return msg === key ? status : msg
}
/** 知识置信度文案;$t(key) 命中失败回退原值 */
export function knowledgeConfidenceLabel(c: string): string {
const key = `knowledge.confidence.${c}`
const msg = t(key)
return msg === key ? c : msg
}
export function useKnowledgeStore() {
// ── 加载列表(library:默认仅 published,F-260616-02 决策 a) ──
// 后端 knowledge_list 默认收窄为 published,无需前端传 status。

79
src/styles/components.css Normal file
View File

@@ -0,0 +1,79 @@
/* ═══════════════════════════════════════════════════════════
DevFlow 公共组件样式(全局,非 scoped)— DRY 收口
═══════════════════════════════════════════════════════════
背景:多个视图/组件 <style scoped> 重复定义相同规则块,且 Vue scoped
不跨组件渗透(子组件内部 DOM 拿不到父级 scoped 后代选择器),被迫逐处复制。
本文件提取高频重复且**完全一致**的样式为全局类,各组件删除 scoped 重复块。
全局类与各页 template class 名一致,零侵入。
提取原则:
- 仅提取 ≥2 处逐字相同的规则(已 grep 比对一致)
- scoped 故意 override 全局值的(如 .empty-hint padding 24px vs 全局 12px)
不提取,保留各组件 scoped 覆盖(零视觉变化)
- global.css 已有的 .btn/.modal-*/.page-header/.empty-hint/.empty-state
不在此重复,仅补充尚未全局化的类
落地批:DRY 收口(B-260619)
- .info-item / .info-item.info-block / .info-item .label / .info-item .value
ProjectDetail.vue / TaskDetail.vue / TaskOutputCard.vue 三处复制
- .panel / .panel-header ProjectDetail.vue / TaskDetail.vue 两处复制
- .tech-tag / .card-tags ProjectCard.vue / Projects.vue 两处复制
*/
/* — Info Item:字段同行布局(label 固定宽 + 值占余),描述字段 info-block 保持块状 —
原 ProjectDetail.vue L439-456 / TaskDetail.vue L560-577 /
TaskOutputCard.vue L93-110 三处逐字一致(后者注释说明 scoped 不渗透故复制)。 */
.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; }
/* — Panel:内嵌面板容器(卡片底 + 头部行)—
原 ProjectDetail.vue L617-630 / TaskDetail.vue L539-551 两处逐字一致。
.task-count 为 ProjectDetail 特有,留组件 scoped。 */
.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; }
/* — Tech Tags:技术栈标签行 + 单标签 —
原 ProjectCard.vue L110-122 / Projects.vue L471-483 两处逐字一致。
ProjectCard 为 scoped 组件,Projects.vue 为创建/导入模态复用;统一全局后
两处均生效(scoped 下全局类对当前组件根/子 DOM 仍生效,仅 scoped 属性选择器
不渗透到子组件内部 — 此处标签是当前组件自身 DOM,无渗透问题)。 */
.card-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.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);
}

View File

@@ -145,7 +145,14 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useKnowledgeStore, KNOWLEDGE_KINDS } from '@/stores/knowledge'
import {
useKnowledgeStore,
KNOWLEDGE_KINDS,
kindText,
kindLabel,
knowledgeStatusLabel as statusLabel,
knowledgeConfidenceLabel as confidenceLabel,
} from '@/stores/knowledge'
import { useRendered } from '@/composables/useMarkdown'
import { stripMd } from '@/utils/markdown'
import KnowledgeDetail from '@/components/knowledge/KnowledgeDetail.vue'
@@ -165,10 +172,6 @@ const categories = [
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('')
let searchTimer: ReturnType<typeof setTimeout> | null = null
@@ -331,20 +334,7 @@ async function submitCreate() {
}
// ===== 列表卡片标签辅助(详情相关 label/时间解析已迁移至 KnowledgeDetail 子组件)=====
function kindLabel(key: string): string {
const k = KNOWLEDGE_KINDS.find(c => c.key === key)
return k ? `${k.icon} ${kindText(key)}` : key
}
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
}
// kindText/kindLabel/statusLabel/confidenceLabel 由 @/stores/knowledge 提供(列表/详情共用,单一来源)
onMounted(() => {
ensureLoaded() // 后台预热 Markdown 渲染器(模块单例,与 AiChat/TaskDetail 共享),不阻塞

View File

@@ -435,26 +435,9 @@ onUnmounted(() => {
gap: var(--df-gap-grid);
}
/* B-260615-31:字段同行布局(label 固定宽 + 值占余),描述字段 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;
}
/* B-260615-31:字段同行布局(label 固定宽 + 值占余),描述字段 info-block 保持块状)
基础 .info-item/.label/.value 已收敛至全局 components.css(DRY 收口 B-260619),
此处仅保留本组件特有覆盖。 */
.info-item a.idea-link {
color: var(--df-accent);
text-decoration: none;
@@ -489,11 +472,8 @@ onUnmounted(() => {
.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);
}
/* .info-tags .tech-tag 与全局 .tech-tag(components.css)逐字一致,移除局部冗余,
标签样式由全局提供。 */
/* ===== 项目描述 Markdown 渲染(B-260615-25,基础样式收敛至全局 ai-md.css) ===== */
.description.ai-md { font-size: 14px; color: var(--df-text); line-height: 1.6; }
@@ -614,19 +594,8 @@ onUnmounted(() => {
.right-column { display: flex; flex-direction: column; 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; }
/* .panel / .panel-header 基础样式已收敛至全局 components.css(DRY 收口 B-260619),
此处仅保留本组件特有 .task-count。 */
.task-count { font-size: 12px; color: var(--df-text-dim); }
/* ===== 任务卡片 ===== */

View File

@@ -446,7 +446,6 @@ async function runImport() {
/* ===== 进度条 ===== */
.card-progress { margin-bottom: 16px; }
.card-progress { margin-bottom: 16px; }
.progress-label-row {
display: flex;
justify-content: space-between;
@@ -467,20 +466,8 @@ async function runImport() {
transition: width 0.3s;
}
/* ===== 技术栈标签(创建/导入模态复用,保留;卡片自身样式随 ProjectCard.vue 迁出) ===== */
.card-tags {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.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);
}
/* ===== 技术栈标签:基础样式已收敛至全局 components.css(DRY 收口 B-260619),
创建/导入模态与本页 ProjectCard 渲染的标签均走全局 .tech-tag/.card-tags。 ===== */
/* ===== 目录选择行 ===== */
.dir-row { display: flex; gap: 8px; align-items: center; }

View File

@@ -536,19 +536,7 @@ onBeforeUnmount(() => {
/* ===== 面板 ===== */
.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; }
/* .panel / .panel-header 基础样式已收敛至全局 components.css(DRY 收口 B-260619)。 */
/* ===== 信息列表 ===== */
.task-info {
@@ -556,25 +544,9 @@ onBeforeUnmount(() => {
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-260615-31:字段同行布局(label 固定宽 + value 占余),描述字段 info-block 保持块状)
基础 .info-item/.label/.value 已收敛至全局 components.css(DRY 收口 B-260619),
此处仅保留本组件特有覆盖。 */
/* 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; }