2026-08-14 07:52:01 +08:00
< 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'
2026-08-17 09:04:53 +08:00
import AITaskGenerator from '../components/AITaskGenerator.vue'
2026-08-14 07:52:01 +08:00
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 >
2026-08-17 09:04:53 +08:00
< div class = "todo-toolbar" >
< AITaskGenerator kind = "ticket" : default -project -id = " projectFilter | | store.projects [ 0 ] ? .id | | 0 " @saved ="reloadAndNotify" / >
< / div >
2026-08-14 07:52:01 +08:00
< 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 >