#add jira & message sync

This commit is contained in:
2025-12-02 10:16:32 +08:00
parent 5c4492d8f8
commit 2ec44b5665
49 changed files with 6633 additions and 1209 deletions

View File

@@ -0,0 +1,503 @@
<template>
<div class="p-6">
<!-- 页面标题 -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-900">JIRA 工时查询</h1>
<p class="text-gray-600 mt-2">查询指定时间范围内的工时记录</p>
</div>
<!-- 工时查询区域 -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
<h2 class="text-xl font-semibold text-gray-700 mb-4">工时记录查询</h2>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">用户名</label>
<input
type="text"
v-model="workLogs.username"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="输入 JIRA 用户名"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">开始日期</label>
<input
type="date"
v-model="workLogs.startDate"
:disabled="workLogs.activeQuickSelect"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">结束日期</label>
<input
type="date"
v-model="workLogs.endDate"
:disabled="workLogs.activeQuickSelect"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
>
</div>
<div class="flex items-end">
<button
@click="getWorkLogs"
:disabled="workLogs.loading"
class="w-full px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
<span v-if="workLogs.loading">查询中...</span>
<span v-else>查询工时</span>
</button>
</div>
</div>
<!-- 快速查询选项 -->
<div class="mb-6">
<label class="block text-sm font-medium text-gray-700 mb-3">快速查询</label>
<div class="flex flex-wrap gap-2">
<button
@click="setQuickDateRange('lastWeek')"
:class="[
'px-4 py-2 text-sm font-medium rounded-md transition-colors',
workLogs.activeQuickSelect === 'lastWeek'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
]"
>
查询上周数据
</button>
<button
@click="setQuickDateRange('yesterday')"
:class="[
'px-4 py-2 text-sm font-medium rounded-md transition-colors',
workLogs.activeQuickSelect === 'yesterday'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
]"
>
查询昨天数据
</button>
<button
@click="setQuickDateRange('today')"
:class="[
'px-4 py-2 text-sm font-medium rounded-md transition-colors',
workLogs.activeQuickSelect === 'today'
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
]"
>
查询今天数据
</button>
<button
@click="clearQuickSelect()"
v-if="workLogs.activeQuickSelect"
class="px-4 py-2 text-sm font-medium rounded-md bg-gray-300 text-gray-700 hover:bg-gray-400 transition-colors"
>
清除
</button>
</div>
</div>
<!-- 工时统计 -->
<div v-if="workLogs.data" class="mb-6 grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="bg-blue-50 p-4 rounded-lg">
<h3 class="text-lg font-medium text-blue-800">总工时</h3>
<p class="text-2xl font-bold text-blue-600">{{ workLogs.data.total_hours }} 小时</p>
</div>
<div class="bg-green-50 p-4 rounded-lg">
<h3 class="text-lg font-medium text-green-800">记录数量</h3>
<p class="text-2xl font-bold text-green-600">{{ workLogs.data.total_records }} </p>
</div>
<div class="bg-purple-50 p-4 rounded-lg">
<h3 class="text-lg font-medium text-purple-800">查询范围</h3>
<p class="text-sm text-purple-600">{{ workLogs.data.date_range.start }} {{ workLogs.data.date_range.end }}</p>
</div>
</div>
<!-- 工时记录表格 -->
<div v-if="workLogs.data && workLogs.data.work_logs && workLogs.data.work_logs.length > 0" class="w-full">
<table class="w-full bg-white border border-gray-200 table-fixed">
<thead class="bg-gray-50">
<tr>
<th @click="sortBy('project')" class="w-16 px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100">
项目
<span v-if="sortField === 'project'" class="ml-1">
{{ sortDirection === 'asc' ? '' : '' }}
</span>
</th>
<th @click="sortBy('issue_key')" class="w-1/4 px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100">
任务
<span v-if="sortField === 'issue_key'" class="ml-1">
{{ sortDirection === 'asc' ? '' : '' }}
</span>
</th>
<th @click="sortBy('parent_task')" class="w-1/4 px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100">
父任务
<span v-if="sortField === 'parent_task'" class="ml-1">
{{ sortDirection === 'asc' ? '' : '' }}
</span>
</th>
<th @click="sortBy('date')" class="w-20 px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100">
日期时间
<span v-if="sortField === 'date'" class="ml-1">
{{ sortDirection === 'asc' ? '' : '' }}
</span>
</th>
<th @click="sortBy('hours')" class="w-16 px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100">
工时
<span v-if="sortField === 'hours'" class="ml-1">
{{ sortDirection === 'asc' ? '' : '' }}
</span>
</th>
<th @click="sortBy('comment')" class="w-1/6 px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:bg-gray-100">
备注
<span v-if="sortField === 'comment'" class="ml-1">
{{ sortDirection === 'asc' ? '' : '' }}
</span>
</th>
<th class="w-16 px-3 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">操作</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="log in sortedWorkLogs" :key="log.id">
<td class="px-3 py-4 text-sm text-gray-900 break-words">{{ log.project_key }}</td>
<td class="px-3 py-4 text-sm break-words">
<a :href="log.issue_url" target="_blank" class="text-blue-600 hover:text-blue-800">
{{ log.issue_key }} {{ log.issue_summary }}
</a>
</td>
<td class="px-3 py-4 text-sm break-words">
<a v-if="log.parent_task" :href="getJiraUrl(log.parent_task.key)" target="_blank" class="text-blue-600 hover:text-blue-800">
{{ log.parent_task.key }} {{ log.parent_task.summary }}
</a>
<span v-else class="text-gray-400">-</span>
</td>
<td class="px-3 py-4 text-sm text-gray-900 break-words">{{ log.date }}<br>{{ log.time }}</td>
<td class="px-3 py-4 text-sm text-gray-900">{{ log.hours }}h</td>
<td class="px-3 py-4 text-sm text-gray-900 break-words">{{ log.comment }}</td>
<td class="px-3 py-4 text-sm text-gray-900">
<button
@click="showWorklogDetail(log)"
class="text-blue-600 hover:text-blue-800 text-sm font-medium"
>
详情
</button>
</td>
</tr>
</tbody>
</table>
</div>
<!-- 无数据提示 -->
<div v-else-if="workLogs.data && workLogs.data.work_logs && workLogs.data.work_logs.length === 0" class="text-center py-8">
<p class="text-gray-500">指定日期范围内没有找到工时记录</p>
</div>
<!-- 错误信息 -->
<div v-if="workLogs.error" class="mt-4 p-4 bg-red-50 border border-red-200 rounded-md">
<p class="text-red-700">{{ workLogs.error }}</p>
</div>
</div>
<!-- 工时详情模态框 -->
<div v-if="showDetailModal" class="fixed inset-0 overflow-y-auto h-full w-full z-50">
<div class="relative top-20 mx-auto p-5 border w-11/12 md:w-3/4 lg:w-1/2 shadow-lg rounded-md bg-white">
<div class="mt-3">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-medium text-gray-900">工时详情</h3>
<button @click="closeDetailModal" class="text-gray-400 hover:text-gray-600">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<div v-if="selectedWorklog" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700">工时ID</label>
<p class="mt-1 text-sm text-gray-900">{{ selectedWorklog.id }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">项目</label>
<p class="mt-1 text-sm text-gray-900">{{ selectedWorklog.project }} ({{ selectedWorklog.project_key }})</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">任务</label>
<p class="mt-1 text-sm text-gray-900">
<a :href="selectedWorklog.issue_url" target="_blank" class="text-blue-600 hover:text-blue-800">
{{ selectedWorklog.issue_key }}: {{ selectedWorklog.issue_summary }}
</a>
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">父任务</label>
<p class="mt-1 text-sm text-gray-900">
<a v-if="selectedWorklog.parent_task" :href="getJiraUrl(selectedWorklog.parent_task.key)" target="_blank" class="text-blue-600 hover:text-blue-800">
{{ selectedWorklog.parent_task.key }} {{ selectedWorklog.parent_task.summary }}
</a>
<span v-else class="text-gray-400">无父任务</span>
</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">工时</label>
<p class="mt-1 text-sm text-gray-900">{{ selectedWorklog.hours }}h ({{ selectedWorklog.time_spent }})</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">开始时间</label>
<p class="mt-1 text-sm text-gray-900">{{ selectedWorklog.started }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">记录者</label>
<p class="mt-1 text-sm text-gray-900">{{ selectedWorklog.author.display_name }} ({{ selectedWorklog.author.name }})</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">创建时间</label>
<p class="mt-1 text-sm text-gray-900">{{ selectedWorklog.created }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">更新时间</label>
<p class="mt-1 text-sm text-gray-900">{{ selectedWorklog.updated }}</p>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700">备注</label>
<p class="mt-1 text-sm text-gray-900 bg-gray-50 p-3 rounded-md">
{{ selectedWorklog.comment || '无备注' }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'JiraWorklog',
data() {
return {
workLogs: {
username: '',
startDate: '',
endDate: '',
activeQuickSelect: null, // 'lastWeek', 'yesterday', 'today'
loading: false,
data: null,
error: ''
},
showDetailModal: false,
selectedWorklog: null,
sortField: 'date',
sortDirection: 'desc'
}
},
computed: {
sortedWorkLogs() {
if (!this.workLogs.data || !this.workLogs.data.work_logs) {
return [];
}
const logs = [...this.workLogs.data.work_logs];
return logs.sort((a, b) => {
let aValue = a[this.sortField];
let bValue = b[this.sortField];
// 特殊处理父任务排序
if (this.sortField === 'parent_task') {
aValue = a.parent_task ? a.parent_task.key : '';
bValue = b.parent_task ? b.parent_task.key : '';
}
// 处理空值
if (!aValue && !bValue) return 0;
if (!aValue) return this.sortDirection === 'asc' ? -1 : 1;
if (!bValue) return this.sortDirection === 'asc' ? 1 : -1;
// 数字类型排序
if (this.sortField === 'hours') {
aValue = parseFloat(aValue) || 0;
bValue = parseFloat(bValue) || 0;
}
// 字符串排序
if (typeof aValue === 'string' && typeof bValue === 'string') {
aValue = aValue.toLowerCase();
bValue = bValue.toLowerCase();
}
if (aValue < bValue) {
return this.sortDirection === 'asc' ? -1 : 1;
}
if (aValue > bValue) {
return this.sortDirection === 'asc' ? 1 : -1;
}
return 0;
});
}
},
async mounted() {
// 获取默认用户名
await this.loadDefaultUser();
// 设置默认日期范围:本周一到今天
this.setDefaultDateRange();
},
methods: {
async loadDefaultUser() {
try {
const response = await fetch('/api/jira/config');
const data = await response.json();
if (data.success && data.data.default_user) {
this.workLogs.username = data.data.default_user;
}
} catch (error) {
console.error('获取默认用户失败:', error);
}
},
setDefaultDateRange() {
const today = new Date();
const currentDay = today.getDay(); // 0 = 周日, 1 = 周一, ...
const mondayOffset = currentDay === 0 ? -6 : 1 - currentDay; // 计算到周一的偏移
const monday = new Date(today);
monday.setDate(today.getDate() + mondayOffset);
this.workLogs.startDate = monday.toISOString().split('T')[0];
this.workLogs.endDate = today.toISOString().split('T')[0];
},
setLastWeekDateRange() {
const today = new Date();
const currentDay = today.getDay();
const lastMondayOffset = currentDay === 0 ? -13 : -6 - currentDay; // 上周一的偏移
const lastSundayOffset = currentDay === 0 ? -7 : -currentDay; // 上周日的偏移
const lastMonday = new Date(today);
lastMonday.setDate(today.getDate() + lastMondayOffset);
const lastSunday = new Date(today);
lastSunday.setDate(today.getDate() + lastSundayOffset);
this.workLogs.startDate = lastMonday.toISOString().split('T')[0];
this.workLogs.endDate = lastSunday.toISOString().split('T')[0];
},
setYesterdayDateRange() {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const dateStr = yesterday.toISOString().split('T')[0];
this.workLogs.startDate = dateStr;
this.workLogs.endDate = dateStr;
},
setTodayDateRange() {
const today = new Date();
const dateStr = today.toISOString().split('T')[0];
this.workLogs.startDate = dateStr;
this.workLogs.endDate = dateStr;
},
setQuickDateRange(type) {
this.workLogs.activeQuickSelect = type;
switch (type) {
case 'lastWeek':
this.setLastWeekDateRange();
break;
case 'yesterday':
this.setYesterdayDateRange();
break;
case 'today':
this.setTodayDateRange();
break;
}
},
clearQuickSelect() {
this.workLogs.activeQuickSelect = null;
this.setDefaultDateRange();
},
sortBy(field) {
if (this.sortField === field) {
this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc';
} else {
this.sortField = field;
this.sortDirection = 'asc';
}
},
async getWorkLogs() {
if (!this.workLogs.username.trim()) {
this.workLogs.error = '请输入用户名';
return;
}
if (!this.workLogs.startDate || !this.workLogs.endDate) {
this.workLogs.error = '请选择日期范围';
return;
}
this.workLogs.loading = true;
this.workLogs.error = '';
this.workLogs.data = null;
try {
const response = await fetch('/api/jira/work-logs', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
},
body: JSON.stringify({
username: this.workLogs.username,
start_date: this.workLogs.startDate,
end_date: this.workLogs.endDate
})
});
const data = await response.json();
if (data.success) {
this.workLogs.data = data.data;
} else {
this.workLogs.error = data.message;
}
} catch (error) {
this.workLogs.error = '网络请求失败: ' + error.message;
} finally {
this.workLogs.loading = false;
}
},
showWorklogDetail(worklog) {
this.selectedWorklog = worklog;
this.showDetailModal = true;
},
closeDetailModal() {
this.showDetailModal = false;
this.selectedWorklog = null;
},
getJiraUrl(issueKey) {
// 从现有的issue_url中提取host部分或者使用默认的JIRA host
if (this.workLogs.data && this.workLogs.data.work_logs && this.workLogs.data.work_logs.length > 0) {
const firstLog = this.workLogs.data.work_logs[0];
const baseUrl = firstLog.issue_url.replace(/\/browse\/.*$/, '');
return `${baseUrl}/browse/${issueKey}`;
}
// 如果没有数据,使用默认配置
return `http://jira.eainc.com:8080/browse/${issueKey}`;
}
}
}
</script>