504 lines
19 KiB
Vue
504 lines
19 KiB
Vue
<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>
|