优化: 前端扫描文案 i18n + parseStack 抽公共 + alert/confirm 换组件

- 扫描/目录状态文案走 $t(Projects/ProjectDetail,zh-CN+en 同步)
- parseStack 抽公共到 src/utils/project.ts(删两份本地实现)
- 8 处原生 alert/confirm 全换(confirm→ConfirmDialog / alert→Arco Message),控制流语义不变

Sprint 20 审查 ⑥⑦⑧。vue-tsc 0 error,验证 safe 0 issue
This commit is contained in:
2026-06-14 14:22:57 +08:00
parent d5a641797c
commit 02ff88fc61
8 changed files with 126 additions and 36 deletions

View File

@@ -48,6 +48,13 @@ export default {
relocateDir: 'Relocate',
bindDir: 'Bind Directory',
dirStatusLabel: 'Directory Status',
dirChecking: 'Checking…',
dirExists: '✓ Directory exists',
dirMissing: '⚠ Directory moved, recommend relocate',
dirConflict: 'This directory is already bound to project "{name}"',
relocateConfirmRelocate: 'Relocate project directory to:\n{dir}\n\nTech stack will be re-detected. Confirm?',
relocateConfirmBind: 'Bind project directory to:\n{dir}\n\nTech stack will be re-detected. Confirm?',
relocateFailed: 'Relocate failed: {msg}',
// Tech stack
techStackLabel: 'Tech Stack',
},

View File

@@ -26,9 +26,16 @@ export default {
confirmPurge: 'Permanently delete project "{name}"? Tasks/branches/releases will be physically deleted and cannot be recovered!',
// New fields
codeDirLabel: 'Code Directory',
codeDirOptionalHint: '(optional, auto-detects tech stack once bound)',
codeDirPlaceholder: 'Click the button on the right to select project directory',
selectDir: 'Select Directory',
clearDir: 'Clear',
// AI scan
aiScanning: 'AI scanning…',
aiScanFill: '🤖 AI scan & autofill',
aiScanNoDesc: 'AI could not generate a description, please fill manually',
aiScanFailed: 'AI scan failed',
dirConflict: 'This directory is already bound to project "{name}"',
// Status labels (PROJECT_STATUS_LABELS values in constants/project.ts use these keys)
status: {
planning: '📐 Planning',

View File

@@ -48,6 +48,13 @@ export default {
relocateDir: '重定位',
bindDir: '绑定目录',
dirStatusLabel: '目录状态',
dirChecking: '检测中…',
dirExists: '✓ 目录存在',
dirMissing: '⚠ 目录已移走,建议重定位',
dirConflict: '该目录已被项目「{name}」绑定',
relocateConfirmRelocate: '将项目目录重定位到:\n{dir}\n\n技术栈将重新探测。确认?',
relocateConfirmBind: '将项目目录绑定到:\n{dir}\n\n技术栈将重新探测。确认?',
relocateFailed: '重定位失败: {msg}',
// 技术栈
techStackLabel: '技术栈',
},

View File

@@ -11,10 +11,17 @@ export default {
descLabel: '项目描述',
descPlaceholder: '简要描述项目目标',
codeDirLabel: '代码目录',
codeDirOptionalHint: '(可选,绑定后自动识别技术栈)',
codeDirPlaceholder: '点击右侧选择项目所在目录',
selectDir: '选择目录',
clearDir: '清除',
confirmCreate: '确认创建',
// AI 扫描
aiScanning: 'AI 扫描中…',
aiScanFill: '🤖 AI 扫描填信息',
aiScanNoDesc: 'AI 未能生成描述,可手动填写',
aiScanFailed: 'AI 扫描失败',
dirConflict: '该目录已被项目「{name}」绑定',
// 回收站模态框
trashTitle: '🗑 回收站',
trashEmpty: '回收站为空',

20
src/utils/project.ts Normal file
View File

@@ -0,0 +1,20 @@
//! 项目相关纯工具函数(无副作用、无依赖,便于跨视图复用)
//!
//! parseStack 此前在 Projects.vue 与 ProjectDetail.vue 各有一份重复实现,
//! 现抽到此处统一维护。
/**
* 解析技术栈 JSON 数组字符串(防崩)。
*
* 后端将技术栈以 `JSON.stringify(["vue","rust"])` 形式存于 project.stack 字段;
* 解析失败或非数组时返回空数组,避免渲染崩溃。
*/
export function parseStack(stack: string | null | undefined): string[] {
if (!stack) return []
try {
const arr = JSON.parse(stack)
return Array.isArray(arr) ? arr : []
} catch {
return []
}
}

View File

@@ -193,6 +193,9 @@
</div>
</div>
</div>
<!-- 确认弹层删除想法替代原生 window.confirm -->
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" />
</div>
</template>
@@ -200,14 +203,31 @@
import { ref, computed, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
import { useProjectStore } from '../stores/project'
import { formatDate } from '../utils/time'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import type { IdeaRecord } from '../api/types'
const { t } = useI18n()
const router = useRouter()
const store = useProjectStore()
// ── 确认弹层(替代原生 window.confirm后者在 Tauri webview 带 localhost 来源信息无法去除) ──
const confirmState = ref({ visible: false, msg: '', resolve: null as null | ((v: boolean) => void) })
function confirmDialog(msg: string): Promise<boolean> {
return new Promise(resolve => {
confirmState.value.msg = msg
confirmState.value.visible = true
confirmState.value.resolve = resolve
})
}
function answerConfirm(ok: boolean) {
confirmState.value.visible = false
confirmState.value.resolve?.(ok)
confirmState.value.resolve = null
}
type FilterKey = 'all' | 'hot' | 'pending' | 'promoted'
const activeFilter = ref<FilterKey>('all')
@@ -375,7 +395,7 @@ async function confirmCapture() {
async function deleteCurrentIdea() {
if (!currentIdea.value) return
if (!confirm(t('ideas.confirmDelete', { title: currentIdea.value.title }))) return
if (!await confirmDialog(t('ideas.confirmDelete', { title: currentIdea.value.title }))) return
await store.deleteIdea(currentIdea.value.id)
selectedId.value = null
}
@@ -388,7 +408,7 @@ async function promoteToProject() {
} catch (e: any) {
const msg = e?.toString() ?? t('ideas.promoteFailed')
console.error(t('ideas.promoteFailed'), e)
alert(msg)
Message.error(msg)
}
}

View File

@@ -90,7 +90,7 @@
<div class="info-item" v-if="currentProject.path">
<span class="label">{{ $t('projectDetail.dirStatusLabel') }}</span>
<span :class="pathExists === false ? 'path-missing' : 'path-ok'">
{{ pathExists === null ? '检测中…' : pathExists ? '✓ 目录存在' : '⚠ 目录已移走,建议重定位' }}
{{ pathExists === null ? $t('projectDetail.dirChecking') : pathExists ? $t('projectDetail.dirExists') : $t('projectDetail.dirMissing') }}
</span>
</div>
@@ -193,6 +193,9 @@
</div>
</div>
</div>
<!-- 确认弹层删除/重定位确认替代原生 window.confirm/alert -->
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" />
</div>
</template>
@@ -200,11 +203,14 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Message } from '@arco-design/web-vue'
import { open } from '@tauri-apps/plugin-dialog'
import { useProjectStore } from '../stores/project'
import { projectApi } from '../api'
import { formatDate } from '../utils/time'
import { parseStack } from '../utils/project'
import { projectStatusLabel, projectStageInfo, taskStatusLabel, taskStatusClass } from '../constants/project'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import type { WorkflowEventPayload } from '../api/types'
const route = useRoute()
@@ -215,6 +221,21 @@ const logListRef = ref<HTMLElement | null>(null)
const showApprovalDialog = ref(false)
let unlisten: (() => void) | null = null
// ── 确认弹层(替代原生 window.confirm后者在 Tauri webview 带 localhost 来源信息无法去除) ──
const confirmState = ref({ visible: false, msg: '', resolve: null as null | ((v: boolean) => void) })
function confirmDialog(msg: string): Promise<boolean> {
return new Promise(resolve => {
confirmState.value.msg = msg
confirmState.value.visible = true
confirmState.value.resolve = resolve
})
}
function answerConfirm(ok: boolean) {
confirmState.value.visible = false
confirmState.value.resolve?.(ok)
confirmState.value.resolve = null
}
// ── 阶段定义(labelKey 对应 i18n projectDetail.stage*) ──
const stages = [
{ key: 'idea', labelKey: 'stageIdea' },
@@ -317,17 +338,6 @@ async function handleSync() {
// ── 代码目录绑定 ──
const pathExists = ref<boolean | null>(null)
// 解析技术栈 JSON 数组字符串(防崩)
function parseStack(stack: string | null): string[] {
if (!stack) return []
try {
const arr = JSON.parse(stack)
return Array.isArray(arr) ? arr : []
} catch {
return []
}
}
// 检查绑定目录是否存在(详情页「目录是否还在」)
async function checkPath() {
const p = currentProject.value
@@ -351,16 +361,19 @@ async function relocateDir() {
try {
const conflict = await projectApi.checkBinding(dir, p.id)
if (conflict) {
alert(`该目录已被项目「${conflict.name}」绑定`)
Message.warning(t('projectDetail.dirConflict', { name: conflict.name }))
return
}
} catch { /* ignore */ }
if (!confirm(`将项目目录${currentProject.value?.path ? '重定位' : '绑定'}到:\n${dir}\n\n技术栈将重新探测。确认?`)) return
const confirmMsg = p.path
? t('projectDetail.relocateConfirmRelocate', { dir })
: t('projectDetail.relocateConfirmBind', { dir })
if (!await confirmDialog(confirmMsg)) return
await store.relocateProjectPath(p.id, dir)
await checkPath()
} catch (e: any) {
console.error('重定位失败:', e)
alert('重定位失败: ' + (e?.toString() ?? '未知错误'))
Message.error(t('projectDetail.relocateFailed', { msg: e?.toString() ?? '未知错误' }))
}
}
@@ -374,7 +387,7 @@ async function handleApproval(decision: string) {
async function handleDelete() {
const p = currentProject.value
if (!p) return
if (!confirm(t('projectDetail.confirmDelete', { name: p.name }))) return
if (!await confirmDialog(t('projectDetail.confirmDelete', { name: p.name }))) return
await store.deleteProject(p.id)
router.push('/projects')
}

View File

@@ -21,7 +21,7 @@
<textarea v-model="newDesc" :placeholder="$t('projects.descPlaceholder')" rows="3"></textarea>
</div>
<div class="modal-field">
<label>{{ $t('projects.codeDirLabel') }}可选绑定后自动识别技术栈</label>
<label>{{ $t('projects.codeDirLabel') }}{{ $t('projects.codeDirOptionalHint') }}</label>
<div class="dir-row">
<input v-model="newPath" :placeholder="$t('projects.codeDirPlaceholder')" readonly />
<button class="btn btn-ghost btn-sm" type="button" @click="pickDir">{{ $t('projects.selectDir') }}</button>
@@ -30,7 +30,7 @@
<div v-if="pathWarning" class="path-warning"> {{ pathWarning }}</div>
<div v-if="newPath" class="scan-row">
<button class="btn btn-ghost btn-sm" type="button" @click="aiScan" :disabled="scanning">
{{ scanning ? 'AI 扫描中…' : '🤖 AI 扫描填信息' }}
{{ scanning ? $t('projects.aiScanning') : $t('projects.aiScanFill') }}
</button>
<span v-if="scanProjectType" class="tech-tag">{{ scanProjectType }}</span>
</div>
@@ -107,6 +107,9 @@
<div v-if="!store.loading && store.projects.length === 0" class="empty-state">
{{ $t('projects.empty') }}
</div>
<!-- 确认弹层删除/彻底删除替代原生 window.confirm -->
<ConfirmDialog :visible="confirmState.visible" :msg="confirmState.msg" @result="answerConfirm" />
</div>
</template>
@@ -118,7 +121,9 @@ import { open } from '@tauri-apps/plugin-dialog'
import { useProjectStore } from '../stores/project'
import { projectApi } from '../api'
import { formatDate } from '../utils/time'
import { parseStack } from '../utils/project'
import { projectStatusLabel as statusLabel, projectBadgeClass as stageClass } from '../constants/project'
import ConfirmDialog from '../components/ConfirmDialog.vue'
import type { ProjectRecord } from '../api/types'
const router = useRouter()
@@ -128,6 +133,21 @@ const { t } = useI18n()
// 状态映射统一走 ../constants/project(与 ProjectDetail/Tasks/Dashboard 一致)
// formatDate 由 ../utils/time 提供(统一毫秒字符串解析,根治 Invalid Date
// ── 确认弹层(替代原生 window.confirm后者在 Tauri webview 带 localhost 来源信息无法去除) ──
const confirmState = ref({ visible: false, msg: '', resolve: null as null | ((v: boolean) => void) })
function confirmDialog(msg: string): Promise<boolean> {
return new Promise(resolve => {
confirmState.value.msg = msg
confirmState.value.visible = true
confirmState.value.resolve = resolve
})
}
function answerConfirm(ok: boolean) {
confirmState.value.visible = false
confirmState.value.resolve?.(ok)
confirmState.value.resolve = null
}
// ── 新建项目 ──
const showCreateModal = ref(false)
const newName = ref('')
@@ -139,17 +159,6 @@ const scanning = ref(false)
const scanError = ref('')
const scanProjectType = ref('')
// 解析技术栈 JSON 数组字符串(防崩)
function parseStack(stack: string | null): string[] {
if (!stack) return []
try {
const arr = JSON.parse(stack)
return Array.isArray(arr) ? arr : []
} catch {
return []
}
}
// 选择目录 → 探测技术栈 + 查重
async function pickDir() {
try {
@@ -163,7 +172,7 @@ async function pickDir() {
// 查重(已被其他项目绑定则警告,禁止创建)
try {
const conflict = await projectApi.checkBinding(selected as string)
if (conflict) pathWarning.value = `该目录已被项目「${conflict.name}」绑定`
if (conflict) pathWarning.value = t('projects.dirConflict', { name: conflict.name })
} catch { /* ignore */ }
} catch (e) {
console.error('选择目录失败:', e)
@@ -190,9 +199,9 @@ async function aiScan() {
detectedStack.value = result.stack
if (result.description) newDesc.value = result.description
scanProjectType.value = result.project_type ?? ''
if (!result.description) scanError.value = 'AI 未能生成描述,可手动填写'
if (!result.description) scanError.value = t('projects.aiScanNoDesc')
} catch (e: any) {
scanError.value = e?.toString() ?? 'AI 扫描失败'
scanError.value = e?.toString() ?? t('projects.aiScanFailed')
} finally {
scanning.value = false
}
@@ -214,7 +223,7 @@ async function submitCreate() {
const showTrashModal = ref(false)
async function handleDelete(project: ProjectRecord) {
if (!confirm(t('projects.confirmDelete', { name: project.name }))) return
if (!await confirmDialog(t('projects.confirmDelete', { name: project.name }))) return
await store.deleteProject(project.id)
}
@@ -228,7 +237,7 @@ async function handleRestore(id: string) {
}
async function handlePurge(project: ProjectRecord) {
if (!confirm(t('projects.confirmPurge', { name: project.name }))) return
if (!await confirmDialog(t('projects.confirmPurge', { name: project.name }))) return
await store.purgeProject(project.id)
}