Files
toolbox/resources/js/components/admin/SystemSettings.vue
T
2026-08-12 18:00:09 +08:00

697 lines
28 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="space-y-4">
<!-- Header -->
<div class="flex items-center justify-between bg-white p-4 rounded-lg shadow-sm border border-gray-200">
<div>
<h3 class="text-lg font-bold text-gray-800">系统设置</h3>
<p class="text-sm text-gray-500">管理本地偏好与服务端全局配置</p>
</div>
<div v-if="jira.loading || configs.loading || erpRequestReport.loading" class="text-sm text-blue-600 animate-pulse">
数据同步中...
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<!-- Left Column: Local/JIRA Settings -->
<div class="lg:col-span-1 space-y-4">
<!-- JIRA Card -->
<div class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div class="bg-gray-50 px-4 py-3 border-b border-gray-200 flex justify-between items-center">
<h4 class="font-semibold text-gray-700 text-sm">JIRA 偏好</h4>
<span class="text-xs text-gray-400 uppercase tracking-wider">Local</span>
</div>
<div class="p-4 space-y-4">
<!-- Default User Input -->
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">默认查询用户 (本地)</label>
<div class="flex gap-2">
<input
type="text"
v-model="jira.localDefaultQueryUserDraft"
class="flex-1 min-w-0 block w-full px-3 py-2 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
placeholder="如: zhangsan"
/>
<button
@click="save"
:disabled="jira.saving"
class="px-3 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 disabled:opacity-50 transition-colors"
title="保存本地设置"
>
<svg v-if="!jira.saving" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" />
</svg>
<span v-else>...</span>
</button>
<button
@click="clear"
:disabled="jira.saving"
class="px-2 py-2 bg-gray-100 text-gray-600 text-sm font-medium rounded hover:bg-gray-200 disabled:opacity-50 transition-colors"
title="清除并使用默认值"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z" clip-rule="evenodd" />
</svg>
</button>
</div>
<p class="mt-1 text-xs text-gray-400">留空则使用服务端默认值</p>
</div>
<!-- Messages -->
<div v-if="jira.message" class="text-sm text-green-600 bg-green-50 px-3 py-2 rounded border border-green-100 flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
</svg>
{{ jira.message }}
</div>
<div v-if="jira.error" class="text-sm text-red-600 bg-red-50 px-3 py-2 rounded border border-red-100">
{{ jira.error }}
</div>
<!-- Server Info Compact -->
<div class="bg-gray-50 rounded border border-gray-200 p-4 space-y-3">
<div class="flex justify-between items-center text-sm">
<span class="text-gray-500">生效用户:</span>
<span class="font-mono font-medium text-gray-800 bg-white px-2 py-0.5 rounded border border-gray-200">{{ effectiveDefaultQueryUser || '-' }}</span>
</div>
<div class="border-t border-gray-200 my-1"></div>
<div class="flex justify-between items-center text-sm">
<span class="text-gray-500">服务端默认:</span>
<span class="font-mono text-gray-700">{{ jira.serverDefaultUser || '-' }}</span>
</div>
<div class="flex justify-between items-start text-sm gap-2">
<span class="text-gray-500 shrink-0">Host:</span>
<span class="font-mono text-gray-700 text-right break-all">{{ jira.host || '-' }}</span>
</div>
</div>
</div>
</div>
<div v-if="isAdmin" class="bg-white rounded-lg shadow-sm border border-gray-200 overflow-hidden">
<div class="bg-gray-50 px-4 py-3 border-b border-gray-200">
<h4 class="font-semibold text-gray-700 text-sm">ERP 请求日报</h4>
</div>
<div class="p-4 space-y-3">
<div>
<label class="block text-sm font-medium text-gray-600 mb-1">钉钉机器人 Token</label>
<input
v-model="erpRequestReport.dingtalkToken"
type="password"
autocomplete="new-password"
class="w-full px-3 py-2 text-sm font-mono border border-gray-300 rounded focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
placeholder="输入新的 access_token"
/>
<p class="mt-1 text-xs text-gray-400">Token 仅用于保存不会在页面中回显保存后需到定时任务启用 ERP 请求日报</p>
</div>
<div class="flex items-center justify-between gap-3">
<span :class="erpRequestReport.configured ? 'text-green-600' : 'text-yellow-600'" class="text-xs">
{{ erpRequestReport.configured ? '已配置 Token' : '未配置 Token' }}
</span>
<button
@click="saveErpRequestReportConfig"
:disabled="erpRequestReport.saving || !erpRequestReport.dingtalkToken.trim()"
class="px-3 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{{ erpRequestReport.saving ? '保存中...' : '保存 Token' }}
</button>
</div>
<div v-if="erpRequestReport.message" class="text-sm text-green-600 bg-green-50 px-3 py-2 rounded border border-green-100">
{{ erpRequestReport.message }}
</div>
<div v-if="erpRequestReport.error" class="text-sm text-red-600 bg-red-50 px-3 py-2 rounded border border-red-100">
{{ erpRequestReport.error }}
</div>
</div>
</div>
</div>
<!-- Right Column: Database Configs -->
<div class="lg:col-span-2 space-y-4">
<div class="bg-white rounded-lg shadow-sm border border-gray-200 flex flex-col h-full">
<div class="bg-gray-50 px-4 py-3 border-b border-gray-200 flex justify-between items-center">
<h4 class="font-semibold text-gray-700 text-sm">数据库配置 (Configs)</h4>
<div class="flex items-center gap-2">
<span v-if="!isAdmin" class="text-xs text-gray-400 bg-gray-100 px-2 py-0.5 rounded border border-gray-200">Read Only</span>
<button
v-if="isAdmin"
@click="loadConfigs"
:disabled="configs.loading"
class="text-gray-500 hover:text-blue-600 transition-colors p-1.5 rounded hover:bg-gray-200"
title="刷新列表"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4" :class="{'animate-spin': configs.loading}">
<path fill-rule="evenodd" d="M15.312 11.424a5.5 5.5 0 01-9.201 2.466l-.312-.311h2.433a.75.75 0 000-1.5H3.989a.75.75 0 00-.75.75v4.242a.75.75 0 001.5 0v-2.43l.31.31a7 7 0 0011.712-3.138.75.75 0 00-1.449-.39zm1.23-3.723a.75.75 0 00.219-.53V2.929a.75.75 0 00-1.5 0v2.433l-.31-.31a7 7 0 00-11.712 3.138.75.75 0 001.449.39 5.5 5.5 0 019.201-2.466l.312.312h-2.433a.75.75 0 000 1.5h4.185a.75.75 0 00.53-.219z" clip-rule="evenodd" />
</svg>
</button>
</div>
</div>
<!-- Non-Admin Placeholder -->
<div v-if="!isAdmin" class="p-8 text-center text-gray-400 text-sm">
需要管理员权限才能管理数据库配置
</div>
<!-- Admin Content -->
<div v-else class="flex-1 flex flex-col min-h-0">
<!-- Status Messages -->
<div v-if="configs.message" class="mx-4 mt-4 text-sm text-green-600 bg-green-50 px-3 py-2 rounded border border-green-100">
{{ configs.message }}
</div>
<div v-if="configs.error" class="mx-4 mt-4 text-sm text-red-600 bg-red-50 px-3 py-2 rounded border border-red-100">
{{ configs.error }}
</div>
<!-- Add New Config Form -->
<div class="p-4 bg-white border-b border-gray-100">
<div class="flex flex-col md:flex-row gap-3 items-start md:items-center">
<input
v-model="configs.newConfig.key"
type="text"
class="flex-1 w-full md:w-auto px-3 py-2 text-sm font-mono border border-gray-300 rounded focus:ring-1 focus:ring-blue-500"
placeholder="Key (e.g. workspace.repo)"
/>
<input
v-model="configs.newConfig.description"
type="text"
class="flex-1 w-full md:w-auto px-3 py-2 text-sm border border-gray-300 rounded focus:ring-1 focus:ring-blue-500"
placeholder="描述"
/>
<div class="relative flex-grow-[2] w-full md:w-auto">
<input
v-model="configs.newConfig.valueText"
type="text"
class="w-full px-3 py-2 text-sm font-mono border border-gray-300 rounded focus:ring-1 focus:ring-blue-500"
placeholder='Value (JSON)'
/>
</div>
<button
@click="createConfig"
:disabled="configs.saving"
class="w-full md:w-auto px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded hover:bg-blue-700 disabled:opacity-50 flex items-center justify-center gap-1"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path d="M10.75 4.75a.75.75 0 00-1.5 0v4.5h-4.5a.75.75 0 000 1.5h4.5v4.5a.75.75 0 001.5 0v-4.5h4.5a.75.75 0 000-1.5h-4.5v-4.5z" />
</svg>
新增
</button>
</div>
</div>
<!-- Table -->
<div class="overflow-x-auto flex-1">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-1/4">Key</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-1/4">描述</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Value (JSON)</th>
<th scope="col" class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider w-24">操作</th>
</tr>
</thead>
<tbody class="bg-white divide-y divide-gray-200">
<tr v-for="config in configs.items" :key="config.id" class="hover:bg-gray-50 group">
<td class="px-4 py-2 align-top">
<input
v-model="config.key"
type="text"
class="w-full text-sm font-mono bg-transparent border-none p-0 focus:ring-0 text-gray-900 placeholder-gray-400 group-hover:bg-white focus:bg-white rounded px-1"
/>
</td>
<td class="px-4 py-2 align-top">
<input
v-model="config.description"
type="text"
class="w-full text-sm bg-transparent border-none p-0 focus:ring-0 text-gray-600 group-hover:bg-white focus:bg-white rounded px-1"
/>
</td>
<td class="px-4 py-2 align-top">
<textarea
v-model="config.valueText"
rows="1"
class="w-full text-sm font-mono bg-transparent border-transparent focus:border-blue-300 focus:ring-1 focus:ring-blue-200 text-gray-600 rounded px-1 py-0.5 resize-y min-h-[1.75rem]"
@focus="$event.target.rows = 3"
@blur="$event.target.rows = 1"
></textarea>
</td>
<td class="px-4 py-2 align-top text-right whitespace-nowrap">
<div class="flex items-center justify-end gap-1 opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity">
<button
@click="updateConfig(config)"
:disabled="config._saving"
class="p-1.5 text-blue-600 hover:bg-blue-50 rounded"
title="保存"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.857-9.809a.75.75 0 00-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 10-1.06 1.061l2.5 2.5a.75.75 0 001.137-.089l4-5.5z" clip-rule="evenodd" />
</svg>
</button>
<button
@click="deleteConfig(config)"
:disabled="config._deleting"
class="p-1.5 text-red-600 hover:bg-red-50 rounded"
title="删除"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4">
<path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4z" clip-rule="evenodd" />
</svg>
</button>
</div>
</td>
</tr>
<tr v-if="configs.items.length === 0">
<td colspan="4" class="px-4 py-8 text-center text-sm text-gray-400">
暂无配置数据
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import {
clearJiraDefaultQueryUserOverride,
getJiraDefaultQueryUserOverride,
resolveJiraDefaultQueryUser,
setJiraDefaultQueryUserOverride
} from '../../utils/jiraQueryUser';
export default {
name: 'SystemSettings',
props: {
isAdmin: {
type: Boolean,
default: false
}
},
data() {
return {
jira: {
loading: false,
saving: false,
localDefaultQueryUserDraft: '',
localDefaultQueryUserSaved: '',
serverDefaultUser: '',
host: '',
message: '',
error: ''
},
configs: {
loading: false,
saving: false,
items: [],
message: '',
error: '',
loadedOnce: false,
newConfig: {
key: '',
description: '',
valueText: ''
}
},
erpRequestReport: {
loading: false,
saving: false,
loadedOnce: false,
configured: false,
dingtalkToken: '',
message: '',
error: ''
}
};
},
computed: {
effectiveDefaultQueryUser() {
return (this.jira.localDefaultQueryUserSaved || '').trim() || resolveJiraDefaultQueryUser(this.jira.serverDefaultUser);
}
},
async mounted() {
const savedOverride = getJiraDefaultQueryUserOverride();
this.jira.localDefaultQueryUserDraft = savedOverride;
this.jira.localDefaultQueryUserSaved = savedOverride;
await this.loadServerConfig();
if (this.isAdmin) {
await Promise.all([this.loadConfigs(), this.loadErpRequestReportConfig()]);
}
},
watch: {
isAdmin(value) {
if (value) {
if (!this.configs.loadedOnce) {
this.loadConfigs();
}
if (!this.erpRequestReport.loadedOnce) {
this.loadErpRequestReportConfig();
}
}
}
},
methods: {
async parseJsonResponse(response) {
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
const text = await response.text();
throw new Error(text || '响应不是JSON');
}
return response.json();
},
getErrorMessage(data, fallback) {
if (!data) {
return fallback;
}
if (data.message) {
return data.message;
}
if (data.errors && typeof data.errors === 'object') {
const firstKey = Object.keys(data.errors)[0];
const firstError = firstKey ? data.errors[firstKey]?.[0] : '';
if (firstError) {
return firstError;
}
}
return fallback;
},
formatConfigValue(value) {
if (value === null || value === undefined) {
return '';
}
try {
return JSON.stringify(value, null, 2);
} catch (error) {
return String(value);
}
},
async loadServerConfig() {
this.jira.loading = true;
this.jira.error = '';
try {
const response = await fetch('/api/jira/config');
const data = await response.json();
if (!data.success) {
this.jira.error = data.message || '获取配置失败';
return;
}
this.jira.serverDefaultUser = (data.data.default_user || '').trim();
this.jira.host = (data.data.host || '').trim();
} catch (error) {
this.jira.error = error.message;
} finally {
this.jira.loading = false;
}
},
async loadConfigs() {
if (!this.isAdmin) {
return;
}
this.configs.loading = true;
this.configs.error = '';
this.configs.message = '';
try {
const response = await fetch('/api/admin/configs', {
headers: {
Accept: 'application/json'
}
});
const data = await this.parseJsonResponse(response);
if (!response.ok) {
this.configs.error = this.getErrorMessage(data, '加载失败');
return;
}
if (!data.success) {
this.configs.error = data.message || '加载失败';
return;
}
this.configs.items = (data.data.configs || []).map((item) => ({
...item,
description: item.description || '',
valueText: this.formatConfigValue(item.value),
_saving: false,
_deleting: false
}));
this.configs.loadedOnce = true;
} catch (error) {
this.configs.error = error.message;
} finally {
this.configs.loading = false;
}
},
async loadErpRequestReportConfig() {
if (!this.isAdmin) {
return;
}
this.erpRequestReport.loading = true;
this.erpRequestReport.error = '';
try {
const response = await fetch('/api/admin/erp-request-report/config', {
headers: { Accept: 'application/json' }
});
const data = await this.parseJsonResponse(response);
if (!response.ok || !data.success) {
this.erpRequestReport.error = this.getErrorMessage(data, '加载 ERP 请求日报配置失败');
return;
}
this.erpRequestReport.configured = Boolean(data.data.dingtalk_token_configured);
this.erpRequestReport.loadedOnce = true;
} catch (error) {
this.erpRequestReport.error = error.message;
} finally {
this.erpRequestReport.loading = false;
}
},
async saveErpRequestReportConfig() {
if (!this.erpRequestReport.dingtalkToken.trim()) {
return;
}
this.erpRequestReport.saving = true;
this.erpRequestReport.error = '';
this.erpRequestReport.message = '';
try {
const response = await fetch('/api/admin/erp-request-report/config', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
dingtalk_token: this.erpRequestReport.dingtalkToken.trim()
})
});
const data = await this.parseJsonResponse(response);
if (!response.ok || !data.success) {
this.erpRequestReport.error = this.getErrorMessage(data, '保存 ERP 请求日报配置失败');
return;
}
this.erpRequestReport.configured = Boolean(data.data.dingtalk_token_configured);
this.erpRequestReport.dingtalkToken = '';
this.erpRequestReport.message = data.message || 'Token 已保存';
} catch (error) {
this.erpRequestReport.error = error.message;
} finally {
this.erpRequestReport.saving = false;
}
},
async createConfig() {
if (!this.configs.newConfig.key.trim()) {
this.configs.error = 'key 不能为空';
return;
}
this.configs.saving = true;
this.configs.error = '';
this.configs.message = '';
try {
const response = await fetch('/api/admin/configs', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
key: this.configs.newConfig.key.trim(),
description: this.configs.newConfig.description || '',
value: this.configs.newConfig.valueText
})
});
const data = await this.parseJsonResponse(response);
if (!response.ok) {
this.configs.error = this.getErrorMessage(data, '创建失败');
return;
}
if (!data.success) {
this.configs.error = data.message || '创建失败';
return;
}
const created = data.data.config;
this.configs.items.push({
...created,
description: created.description || '',
valueText: this.formatConfigValue(created.value),
_saving: false,
_deleting: false
});
this.configs.message = '已新增配置';
this.configs.newConfig = {
key: '',
description: '',
valueText: ''
};
} catch (error) {
this.configs.error = error.message;
} finally {
this.configs.saving = false;
}
},
async updateConfig(config) {
config._saving = true;
this.configs.error = '';
this.configs.message = '';
try {
const response = await fetch(`/api/admin/configs/${config.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify({
key: config.key,
description: config.description || '',
value: config.valueText
})
});
const data = await this.parseJsonResponse(response);
if (!response.ok) {
this.configs.error = this.getErrorMessage(data, '保存失败');
return;
}
if (!data.success) {
this.configs.error = data.message || '保存失败';
return;
}
const updated = data.data.config;
config.key = updated.key;
config.description = updated.description || '';
config.valueText = this.formatConfigValue(updated.value);
this.configs.message = '已保存配置';
} catch (error) {
this.configs.error = error.message;
} finally {
config._saving = false;
}
},
async deleteConfig(config) {
if (!window.confirm(`确认删除配置 ${config.key} 吗?`)) {
return;
}
config._deleting = true;
this.configs.error = '';
this.configs.message = '';
try {
const response = await fetch(`/api/admin/configs/${config.id}`, {
method: 'DELETE',
headers: {
Accept: 'application/json'
}
});
const data = await this.parseJsonResponse(response);
if (!response.ok) {
this.configs.error = this.getErrorMessage(data, '删除失败');
return;
}
if (!data.success) {
this.configs.error = data.message || '删除失败';
return;
}
this.configs.items = this.configs.items.filter((item) => item.id !== config.id);
this.configs.message = '已删除配置';
} catch (error) {
this.configs.error = error.message;
} finally {
config._deleting = false;
}
},
async save() {
this.jira.saving = true;
this.jira.message = '';
this.jira.error = '';
try {
setJiraDefaultQueryUserOverride(this.jira.localDefaultQueryUserDraft);
const savedOverride = getJiraDefaultQueryUserOverride();
this.jira.localDefaultQueryUserSaved = savedOverride;
this.jira.localDefaultQueryUserDraft = savedOverride;
this.jira.message = '已保存';
// Auto clear message
setTimeout(() => {
this.jira.message = '';
}, 3000);
} catch (error) {
this.jira.error = error.message;
} finally {
this.jira.saving = false;
}
},
async clear() {
this.jira.saving = true;
this.jira.message = '';
this.jira.error = '';
try {
clearJiraDefaultQueryUserOverride();
this.jira.localDefaultQueryUserDraft = '';
this.jira.localDefaultQueryUserSaved = '';
this.jira.message = '已清除默认值';
// Auto clear message
setTimeout(() => {
this.jira.message = '';
}, 3000);
} catch (error) {
this.jira.error = error.message;
} finally {
this.jira.saving = false;
}
}
}
};
</script>