Files
code-utils/frontend/src/views/Tickets.vue
李琦 c430a0f6da 1. 后台图表多样化 API 的 /admin/overview 新增了按 AI 提供商聚合的用量(providerSeries)和云端数据构成(dataDist)。Admin.vue 概览页改用 ECharts:日活折线图、Token 堆叠柱状图(叠加调用次数折线)、AI 提供商用量饼图、云端数据构成饼图。
2. 端口监控与刷新间隔 「存为应用」不再跳转启动台,而是在本页弹出复用的 LaunchAppFormModal 表单,保存成功后弹确认框询问「留在本页 / 前往启动台」。启动台和端口监控页头都加了 RefreshIntervalPicker(仅手动 / 5 / 10 / 30 / 60 秒,默认 30 秒),选择记忆在 localStorage。

3. 团队所有者与用户详情 团队列表的所有者、用户列表的用户名都改为「头像 + 昵称 + ID」的可点击单元格,点击弹出用户详情:头像、昵称、头衔、待办/工单/笔记/团队数量、注册时间、最近活跃、拥有团队数。API 新增 GET /admin/users/:id,团队列表关联查询出 ownerNickname/ownerAvatar。

4. 项目专属排除规则 SQLite exclusion_rules 加了 project_id 列(唯一约束改为 (project_id, pattern),旧库自动重建迁移)。项目详情页「代码」标签下新增专属规则面板,可增删;扫描时全局规则 + 项目规则叠加生效。规则随项目身份上云,其它机器认领项目后自动带回。

5. 一句话 AI 生成待办/工单 待办页和工单页工具栏各嵌入一个 AI 生成输入条:一句话回车后由后端 AIGenerateTasks 调用 AI 拆解成多条草稿,弹预览框逐条勾选、可改标题/类型/优先级/日期,选归属项目后批量保存。

6. 项目云同步与认领

全局挂载了 CloudClaimModal:登录同步后发现云端有本机未落地的项目时自动弹出(同一批只打扰一次),每个项目可「选目录认领」或「忽略」;工作台横幅和个人主页可随时再打开。
统计数据按机器码推送到 stats:<机器码> 文档,只推不拉,不同电脑的扫描历史互不覆盖。
个人主页 · 云同步新增「项目云同步」区块:自动/手动模式切换(手动模式下常规同步跳过项目文档,仅点「立即同步项目」时推拉)、待认领入口、已忽略项目的恢复列表。
途中补齐了约 60 个中英双语 i18n 键,并修掉了 PortMonitor 引用但缺失的 pmSavedToast 键。
2026-08-17 09:04:53 +08:00

199 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { Plus, TicketCheck, Pencil, Trash2, RefreshCw, CalendarDays, Flag, Play, Check, Archive, RotateCcw, X, Bug, Sparkles, Wrench, ListChecks, ImagePlus } from 'lucide-vue-next'
import { call, notifyTasksChanged, onTasksChanged } from '../api'
import { useAppStore } from '../store'
import MarkdownView from '../components/MarkdownView.vue'
import DueQuickPick from '../components/DueQuickPick.vue'
import DatePicker from '../components/DatePicker.vue'
import LifecycleTimeline from '../components/LifecycleTimeline.vue'
import AITaskGenerator from '../components/AITaskGenerator.vue'
import { useMdEditor } from '../mdeditor'
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const store = useAppStore()
const tickets = ref([])
const statusFilter = ref('all')
const projectFilter = ref(0)
const modal = ref(false)
const saving = ref(false)
const error = ref('')
const form = reactive({ id: 0, title: '', description: '', type: 'task', projectId: 0, startAt: '', dueAt: '', priority: 'medium', status: 'open' })
const current = ref(null) // 正在编辑的原始条目(时间线展示已保存的轨迹)
const md = useMdEditor(form, 'description', e => { error.value = errText(e) })
const statuses = ['open', 'in_progress', 'resolved', 'closed']
const typeIcon = { feature: Sparkles, bug: Bug, task: ListChecks, improvement: Wrench }
const filtered = computed(() => tickets.value.filter(x =>
(statusFilter.value === 'all' || x.status === statusFilter.value) &&
(!projectFilter.value || x.projectId === projectFilter.value)
))
const counts = computed(() => Object.fromEntries(['all', ...statuses].map(s => [s, s === 'all' ? tickets.value.length : tickets.value.filter(x => x.status === s).length])))
const overdue = x => x.dueAt && ['open', 'in_progress'].includes(x.status) && new Date(x.dueAt.length === 10 ? x.dueAt + 'T23:59' : x.dueAt) < new Date()
// 状态流转:开始处理 → 解决 → 关闭;已解决/已关闭可重新打开。
const flowActions = x => ({
open: [{ key: 'in_progress', label: t('ticketFlow.start'), icon: Play }],
in_progress: [{ key: 'resolved', label: t('ticketFlow.resolve'), icon: Check }],
resolved: [{ key: 'closed', label: t('ticketFlow.close'), icon: Archive }, { key: 'open', label: t('ticketFlow.reopen'), icon: RotateCcw }],
closed: [{ key: 'open', label: t('ticketFlow.reopen'), icon: RotateCcw }]
}[x.status] || [])
async function load() {
tickets.value = await call('ListTickets', 'all', 0)
}
// 本页写入后广播(顶栏任务中心等同步刷新);也接收其它入口的变更
async function reloadAndNotify() {
await load()
notifyTasksChanged()
}
function openModal(x) {
Object.assign(form, x
? { id: x.id, title: x.title, description: x.description, type: x.type, projectId: x.projectId, startAt: x.startAt, dueAt: x.dueAt, priority: x.priority, status: x.status }
: { id: 0, title: '', description: '', type: 'task', projectId: projectFilter.value || store.projects[0]?.id || 0, startAt: new Date().toISOString().slice(0, 10), dueAt: '', priority: 'medium', status: 'open' })
current.value = x || null
error.value = ''
md.reset()
modal.value = true
}
const errText = raw => {
const code = ['TICKET_TITLE_REQUIRED', 'TICKET_PROJECT_REQUIRED', 'TICKET_SCHEDULE_REQUIRED', 'TICKET_SCHEDULE_INVALID', 'TICKET_STATUS_INVALID'].find(c => String(raw).includes(c))
return code ? t(`errors.${code}`) : String(raw)
}
async function save() {
if (saving.value) return
saving.value = true
error.value = ''
try {
await call('SaveTicket', { ...form, projectId: Number(form.projectId) || 0 })
modal.value = false
await reloadAndNotify()
} catch (e) {
error.value = errText(e)
} finally {
saving.value = false
}
}
async function setStatus(x, status) {
await call('SetTicketStatus', x.id, status)
await reloadAndNotify()
}
async function remove(x) {
if (!confirm(`${t('delete')} ${x.title}?`)) return
await call('DeleteTicket', x.id)
await reloadAndNotify()
}
// 深链 ?edit=id任务中心/日历详情点“编辑”后直接弹出编辑框
function openEditFromQuery() {
const id = Number(route.query.edit)
if (!id) return
router.replace('/tickets')
const x = tickets.value.find(i => i.id === id)
if (x) openModal(x)
}
onMounted(async () => {
await load()
openEditFromQuery()
})
watch(() => route.query.edit, v => { if (v) openEditFromQuery() })
const offTasks = onTasksChanged(load)
onUnmounted(() => offTasks())
</script>
<template>
<div class="page tickets-page">
<header class="page-head sticky-head">
<div><h1>{{ t('tickets') }}</h1><p>{{ t('ticketsSubtitle') }}</p></div>
<div class="actions">
<button class="btn secondary" @click="load"><RefreshCw />{{ t('refresh') }}</button>
<button class="btn primary" @click="openModal()"><Plus />{{ t('addTicket') }}</button>
</div>
</header>
<div class="todo-toolbar">
<div class="tabs compact ticket-tabs">
<button v-for="s in ['all', ...statuses]" :key="s" :class="{ active: statusFilter === s }" @click="statusFilter = s">
{{ s === 'all' ? t('allLogs') : t('ticketStatus.' + s) }}<i class="tab-count">{{ counts[s] }}</i>
</button>
</div>
<select v-model.number="projectFilter" class="log-category">
<option :value="0">{{ t('allProjects') }}</option>
<option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option>
</select>
</div>
<div class="todo-toolbar">
<AITaskGenerator kind="ticket" :default-project-id="projectFilter || store.projects[0]?.id || 0" @saved="reloadAndNotify" />
</div>
<section class="panel ticket-panel">
<article v-for="x in filtered" :key="x.id" class="ticket-row" :class="[x.status, x.priority, { overdue: overdue(x) }]">
<span class="ticket-type" :class="x.type" :title="t('ticketType.' + x.type)"><component :is="typeIcon[x.type] || ListChecks" /></span>
<div class="ticket-main" @click="openModal(x)">
<div class="ticket-title"><b>{{ x.title }}</b><span class="ticket-status" :class="x.status">{{ t('ticketStatus.' + x.status) }}</span></div>
<MarkdownView v-if="x.description && x.description.trim() !== x.title.trim()" class="md-clamp ticket-desc" :source="x.description" />
<div class="todo-meta">
<span class="todo-chip">{{ x.projectName || ('#' + x.projectId) }}</span>
<span class="todo-due" :class="{ overdue: overdue(x) }"><CalendarDays />{{ x.startAt.replace('T', ' ') }} {{ x.dueAt.replace('T', ' ') }}</span>
<span class="todo-priority" :class="x.priority"><Flag />{{ t('priority.' + x.priority) }}</span>
</div>
</div>
<div class="ticket-actions">
<button v-for="act in flowActions(x)" :key="act.key" class="btn secondary flow-btn" @click="setStatus(x, act.key)"><component :is="act.icon" />{{ act.label }}</button>
<div class="icon-actions">
<button :title="t('edit')" @click="openModal(x)"><Pencil /></button>
<button :title="t('delete')" @click="remove(x)"><Trash2 /></button>
</div>
</div>
</article>
<div v-if="!filtered.length" class="empty"><TicketCheck />{{ t('noTickets') }}</div>
</section>
</div>
<Teleport to="body">
<div v-if="modal" class="overlay" @click.self="!saving && (modal = false)">
<form class="modal modal-split" @submit.prevent="save">
<header><h2>{{ form.id ? t('editTicket') : t('addTicket') }}</h2><button type="button" :disabled="saving" @click="modal = false"><X /></button></header>
<div class="split-body">
<div class="split-fields">
<label>{{ t('ticketTitle') }}<input v-model="form.title" :disabled="saving" required /></label>
<label>{{ t('relatedProject') }} *<select v-model.number="form.projectId" :disabled="saving" required><option :value="0" disabled>{{ t('selectProject') }}</option><option v-for="p in store.projects" :key="p.id" :value="p.id">{{ p.name }}</option></select></label>
<label>{{ t('ticketTypeLabel') }}<select v-model="form.type" :disabled="saving"><option v-for="k in ['feature', 'bug', 'task', 'improvement']" :key="k" :value="k">{{ t('ticketType.' + k) }}</option></select></label>
<label>{{ t('startDate') }} *<DatePicker v-model="form.startAt" :disabled="saving" :clearable="false" /></label>
<label>{{ t('dueDate') }} *<DatePicker v-model="form.dueAt" :disabled="saving" :clearable="false" />
<DueQuickPick v-model="form.dueAt" :disabled="saving" />
</label>
<div class="field-pair">
<label>{{ t('priorityLabel') }}<select v-model="form.priority" :disabled="saving"><option value="low">{{ t('priority.low') }}</option><option value="medium">{{ t('priority.medium') }}</option><option value="high">{{ t('priority.high') }}</option></select></label>
<label>{{ t('statusLabel') }}<select v-model="form.status" :disabled="saving"><option v-for="s in statuses" :key="s" :value="s">{{ t('ticketStatus.' + s) }}</option></select></label>
</div>
<LifecycleTimeline v-if="current" :history="current.history" :created-at="current.createdAt" :updated-at="current.updatedAt" :status="current.status" kind="ticket" />
</div>
<div class="split-editor">
<div class="md-toolbar">
<span class="md-field-label">{{ t('ticketDesc') }}</span>
<div class="tabs compact">
<button type="button" :class="{ active: !md.preview.value }" @click="md.preview.value = false">{{ t('mdEdit') }}</button>
<button type="button" :class="{ active: md.preview.value }" @click="md.preview.value = true">{{ t('mdPreviewTab') }}</button>
</div>
<button type="button" class="btn secondary md-img-btn" :disabled="saving || md.uploading.value" @click="md.pickImage"><ImagePlus />{{ md.uploading.value ? t('mdInserting') : t('insertImage') }}</button>
</div>
<div class="md-editor">
<textarea v-show="!md.preview.value" :ref="md.inputEl" v-model="form.description" :disabled="saving" :placeholder="t('mdPlaceholder')" @paste="md.onPaste" />
<MarkdownView v-if="md.preview.value" class="md-preview-box" :source="form.description || t('mdEmpty')" />
</div>
</div>
</div>
<footer>
<p v-if="error" class="form-error">{{ error }}</p>
<button type="button" class="btn secondary" :disabled="saving" @click="modal = false">{{ t('cancel') }}</button>
<button class="btn primary" :disabled="saving">{{ saving ? t('saving') : t('save') }}</button>
</footer>
</form>
</div>
</Teleport>
</template>