Files
toolbox/resources/js/components/jira/JiraWorklog.vue

514 lines
20 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.
<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>
import { resolveJiraDefaultQueryUser } from '../../utils/jiraQueryUser';
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 : '';
}
// 特殊处理日期时间排序:结合 date 和 time 字段
if (this.sortField === 'date') {
aValue = `${a.date || ''} ${a.time || '00:00'}`;
bValue = `${b.date || ''} ${b.time || '00:00'}`;
}
// 处理空值
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() {
this.workLogs.username = resolveJiraDefaultQueryUser('');
try {
const response = await fetch('/api/jira/config');
const data = await response.json();
if (data.success) {
this.workLogs.username = resolveJiraDefaultQueryUser(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>