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 键。
199 lines
11 KiB
Vue
199 lines
11 KiB
Vue
<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>
|