hermes-web-ui 0.0.1 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/bin/hermes-web-ui.mjs +114 -19
  2. package/dist/assets/ChatView-DrzZz5ex.css +1 -0
  3. package/dist/assets/ChatView-WnNeYrS7.js +38 -0
  4. package/dist/assets/JobsView-BF9wWdwU.js +831 -0
  5. package/dist/assets/JobsView-BhwwXuLt.css +1 -0
  6. package/dist/assets/Modal-CQVLL_Oc.js +276 -0
  7. package/dist/assets/_plugin-vue_export-helper-D5N3Hfca.js +231 -0
  8. package/dist/assets/chat-640V6r6N.js +1 -0
  9. package/dist/assets/client-BxIiwrqC.js +1 -0
  10. package/dist/assets/index-4iFlrAYJ.js +307 -0
  11. package/dist/assets/index-CMJKLUKk.css +1 -0
  12. package/dist/assets/jobs-Cuol6Mqb.js +1 -0
  13. package/dist/assets/use-message-B8ClBznx.js +117 -0
  14. package/dist/favicon.ico +0 -0
  15. package/dist/index.html +21 -0
  16. package/package.json +3 -11
  17. package/index.html +0 -13
  18. package/src/App.vue +0 -54
  19. package/src/api/chat.ts +0 -87
  20. package/src/api/client.ts +0 -44
  21. package/src/api/jobs.ts +0 -100
  22. package/src/api/system.ts +0 -25
  23. package/src/assets/hero.png +0 -0
  24. package/src/assets/vite.svg +0 -1
  25. package/src/components/chat/ChatInput.vue +0 -123
  26. package/src/components/chat/ChatPanel.vue +0 -289
  27. package/src/components/chat/MarkdownRenderer.vue +0 -187
  28. package/src/components/chat/MessageItem.vue +0 -189
  29. package/src/components/chat/MessageList.vue +0 -94
  30. package/src/components/jobs/JobCard.vue +0 -244
  31. package/src/components/jobs/JobFormModal.vue +0 -188
  32. package/src/components/jobs/JobsPanel.vue +0 -58
  33. package/src/components/layout/AppSidebar.vue +0 -169
  34. package/src/composables/useKeyboard.ts +0 -39
  35. package/src/env.d.ts +0 -7
  36. package/src/main.ts +0 -10
  37. package/src/router/index.ts +0 -24
  38. package/src/stores/app.ts +0 -66
  39. package/src/stores/chat.ts +0 -344
  40. package/src/stores/jobs.ts +0 -72
  41. package/src/styles/global.scss +0 -60
  42. package/src/styles/theme.ts +0 -71
  43. package/src/styles/variables.scss +0 -56
  44. package/src/views/ChatView.vue +0 -25
  45. package/src/views/JobsView.vue +0 -93
  46. package/src/views/SettingsView.vue +0 -257
  47. package/tsconfig.app.json +0 -17
  48. package/tsconfig.json +0 -7
  49. package/tsconfig.node.json +0 -24
  50. package/vite.config.ts +0 -39
  51. /package/{assets/logo.png → dist/assets/logo-BAarh-tH.png} +0 -0
  52. /package/{public → dist}/favicon.svg +0 -0
  53. /package/{public → dist}/icons.svg +0 -0
@@ -1,189 +0,0 @@
1
- <script setup lang="ts">
2
- import { computed } from 'vue'
3
- import type { Message } from '@/stores/chat'
4
- import MarkdownRenderer from './MarkdownRenderer.vue'
5
-
6
- const props = defineProps<{ message: Message }>()
7
-
8
- const isSystem = computed(() => props.message.role === 'system')
9
-
10
- const timeStr = computed(() => {
11
- const d = new Date(props.message.timestamp)
12
- return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
13
- })
14
- </script>
15
-
16
- <template>
17
- <div class="message" :class="[message.role]">
18
- <template v-if="message.role === 'tool'">
19
- <div class="tool-line">
20
- <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="tool-icon"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>
21
- <span class="tool-name">{{ message.toolName }}</span>
22
- <span v-if="message.toolPreview" class="tool-preview">{{ message.toolPreview }}</span>
23
- </div>
24
- </template>
25
- <template v-else>
26
- <div class="msg-body">
27
- <img v-if="message.role === 'assistant'" src="/assets/logo.png" alt="Hermes" class="msg-avatar" />
28
- <div class="msg-content" :class="message.role">
29
- <div class="message-bubble" :class="{ system: isSystem }">
30
- <MarkdownRenderer v-if="message.content" :content="message.content" />
31
- <span v-if="message.isStreaming" class="streaming-cursor"></span>
32
- <div v-if="message.isStreaming && !message.content" class="streaming-dots">
33
- <span></span><span></span><span></span>
34
- </div>
35
- </div>
36
- <div class="message-time">{{ timeStr }}</div>
37
- </div>
38
- </div>
39
- </template>
40
- </div>
41
- </template>
42
-
43
- <style scoped lang="scss">
44
- @use '@/styles/variables' as *;
45
-
46
- .message {
47
- display: flex;
48
- flex-direction: column;
49
-
50
- &.user {
51
- align-items: flex-end;
52
-
53
- .msg-body {
54
- max-width: 75%;
55
- }
56
-
57
- .msg-content.user {
58
- align-items: flex-end;
59
- }
60
-
61
- .message-bubble {
62
- background-color: $msg-user-bg;
63
- border-radius: $radius-md $radius-md 4px $radius-md;
64
- }
65
- }
66
-
67
- &.assistant {
68
- flex-direction: row;
69
- align-items: flex-start;
70
- gap: 8px;
71
-
72
- .msg-body {
73
- max-width: 80%;
74
- }
75
-
76
- .msg-avatar {
77
- width: 28px;
78
- height: 28px;
79
- border-radius: 4px;
80
- flex-shrink: 0;
81
- margin-top: 2px;
82
- }
83
-
84
- .message-bubble {
85
- background-color: $msg-assistant-bg;
86
- border-radius: $radius-md $radius-md $radius-md 4px;
87
- }
88
- }
89
-
90
- &.tool {
91
- align-items: flex-start;
92
- }
93
-
94
- &.system {
95
- align-items: flex-start;
96
-
97
- .message-bubble.system {
98
- border-left: 3px solid $warning;
99
- border-radius: $radius-sm;
100
- max-width: 80%;
101
- background-color: rgba($warning, 0.06);
102
- }
103
- }
104
- }
105
-
106
- .msg-body {
107
- display: flex;
108
- align-items: flex-start;
109
- gap: 8px;
110
- max-width: 85%;
111
- }
112
-
113
- .msg-content {
114
- display: flex;
115
- flex-direction: column;
116
- min-width: 0;
117
- }
118
-
119
- .message-bubble {
120
- padding: 10px 14px;
121
- font-size: 14px;
122
- line-height: 1.65;
123
- word-break: break-word;
124
- }
125
-
126
- .message-time {
127
- font-size: 11px;
128
- color: $text-muted;
129
- margin-top: 4px;
130
- padding: 0 4px;
131
- }
132
-
133
- .tool-line {
134
- display: flex;
135
- align-items: center;
136
- gap: 6px;
137
- font-size: 11px;
138
- color: $text-muted;
139
- padding: 0 4px;
140
-
141
- .tool-name {
142
- font-family: $font-code;
143
- }
144
-
145
- .tool-preview {
146
- overflow: hidden;
147
- text-overflow: ellipsis;
148
- white-space: nowrap;
149
- max-width: 400px;
150
- }
151
- }
152
-
153
- .streaming-cursor {
154
- display: inline-block;
155
- width: 2px;
156
- height: 1em;
157
- background-color: $text-muted;
158
- margin-left: 2px;
159
- vertical-align: text-bottom;
160
- animation: blink 0.8s infinite;
161
- }
162
-
163
- .streaming-dots {
164
- display: flex;
165
- gap: 4px;
166
- padding: 4px 0;
167
-
168
- span {
169
- width: 6px;
170
- height: 6px;
171
- background-color: $text-muted;
172
- border-radius: 50%;
173
- animation: pulse 1.4s infinite ease-in-out;
174
-
175
- &:nth-child(2) { animation-delay: 0.2s; }
176
- &:nth-child(3) { animation-delay: 0.4s; }
177
- }
178
- }
179
-
180
- @keyframes blink {
181
- 0%, 50% { opacity: 1; }
182
- 51%, 100% { opacity: 0; }
183
- }
184
-
185
- @keyframes pulse {
186
- 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
187
- 40% { opacity: 1; transform: scale(1); }
188
- }
189
- </style>
@@ -1,94 +0,0 @@
1
- <script setup lang="ts">
2
- import { ref, watch, nextTick } from 'vue'
3
- import MessageItem from './MessageItem.vue'
4
- import { useChatStore } from '@/stores/chat'
5
-
6
- const chatStore = useChatStore()
7
- const listRef = ref<HTMLElement>()
8
-
9
- function scrollToBottom() {
10
- nextTick(() => {
11
- if (listRef.value) {
12
- listRef.value.scrollTop = listRef.value.scrollHeight
13
- }
14
- })
15
- }
16
-
17
- watch(() => chatStore.messages.length, scrollToBottom)
18
- watch(() => chatStore.messages[chatStore.messages.length - 1]?.content, scrollToBottom)
19
- watch(() => chatStore.isStreaming, (v) => { if (v) scrollToBottom() })
20
- </script>
21
-
22
- <template>
23
- <div ref="listRef" class="message-list">
24
- <div v-if="chatStore.messages.length === 0" class="empty-state">
25
- <img src="/assets/logo.png" alt="Hermes" class="empty-logo" />
26
- <p>Start a conversation with Hermes Agent</p>
27
- </div>
28
- <MessageItem
29
- v-for="msg in chatStore.messages"
30
- :key="msg.id"
31
- :message="msg"
32
- />
33
- <div v-if="chatStore.isStreaming" class="streaming-indicator">
34
- <span></span><span></span><span></span>
35
- </div>
36
- </div>
37
- </template>
38
-
39
- <style scoped lang="scss">
40
- @use '@/styles/variables' as *;
41
-
42
- .message-list {
43
- flex: 1;
44
- overflow-y: auto;
45
- padding: 20px;
46
- display: flex;
47
- flex-direction: column;
48
- gap: 16px;
49
- }
50
-
51
- .empty-state {
52
- flex: 1;
53
- display: flex;
54
- flex-direction: column;
55
- align-items: center;
56
- justify-content: center;
57
- color: $text-muted;
58
- gap: 12px;
59
-
60
- .empty-logo {
61
- width: 48px;
62
- height: 48px;
63
- opacity: 0.25;
64
- }
65
-
66
- p {
67
- font-size: 14px;
68
- }
69
- }
70
-
71
- .streaming-indicator {
72
- display: flex;
73
- align-items: center;
74
- gap: 4px;
75
- padding: 4px;
76
- color: $text-muted;
77
-
78
- span {
79
- width: 5px;
80
- height: 5px;
81
- background-color: $text-muted;
82
- border-radius: 50%;
83
- animation: stream-pulse 1.4s infinite ease-in-out;
84
-
85
- &:nth-child(2) { animation-delay: 0.2s; }
86
- &:nth-child(3) { animation-delay: 0.4s; }
87
- }
88
- }
89
-
90
- @keyframes stream-pulse {
91
- 0%, 80%, 100% { opacity: 0.2; transform: scale(0.8); }
92
- 40% { opacity: 1; transform: scale(1); }
93
- }
94
- </style>
@@ -1,244 +0,0 @@
1
- <script setup lang="ts">
2
- import { computed } from 'vue'
3
- import { NButton, NTooltip, useMessage } from 'naive-ui'
4
- import type { Job } from '@/api/jobs'
5
- import { useJobsStore } from '@/stores/jobs'
6
-
7
- const props = defineProps<{ job: Job }>()
8
- const emit = defineEmits<{
9
- edit: [jobId: string]
10
- }>()
11
-
12
- const jobsStore = useJobsStore()
13
- const message = useMessage()
14
-
15
- const jobId = computed(() => props.job.job_id || props.job.id)
16
-
17
- const statusLabel = computed(() => {
18
- if (props.job.state === 'running') return 'Running'
19
- if (props.job.state === 'paused') return 'Paused'
20
- if (!props.job.enabled) return 'Disabled'
21
- return 'Scheduled'
22
- })
23
-
24
- const statusType = computed(() => {
25
- if (props.job.state === 'running') return 'info' as const
26
- if (props.job.state === 'paused') return 'warning' as const
27
- if (!props.job.enabled) return 'error' as const
28
- return 'success' as const
29
- })
30
-
31
- const scheduleExpr = computed(() => {
32
- const s = props.job.schedule
33
- if (typeof s === 'string') return s
34
- return s?.expr || props.job.schedule_display || '—'
35
- })
36
-
37
- const formatTime = (t?: string | null) => {
38
- if (!t) return '—'
39
- return new Date(t).toLocaleString()
40
- }
41
-
42
- async function handlePause() {
43
- try {
44
- await jobsStore.pauseJob(jobId.value)
45
- message.success('Job paused')
46
- } catch (e: any) {
47
- message.error(e.message)
48
- }
49
- }
50
-
51
- async function handleResume() {
52
- try {
53
- await jobsStore.resumeJob(jobId.value)
54
- message.success('Job resumed')
55
- } catch (e: any) {
56
- message.error(e.message)
57
- }
58
- }
59
-
60
- async function handleRun() {
61
- try {
62
- await jobsStore.runJob(jobId.value)
63
- message.info('Job triggered')
64
- } catch (e: any) {
65
- message.error(e.message)
66
- }
67
- }
68
-
69
- async function handleDelete() {
70
- try {
71
- await jobsStore.deleteJob(jobId.value)
72
- message.success('Job deleted')
73
- } catch (e: any) {
74
- message.error(e.message)
75
- }
76
- }
77
- </script>
78
-
79
- <template>
80
- <div class="job-card">
81
- <div class="card-header">
82
- <h3 class="job-name">{{ job.name }}</h3>
83
- <span class="status-badge" :class="statusType">{{ statusLabel }}</span>
84
- </div>
85
-
86
- <div class="card-body">
87
- <div class="info-row">
88
- <span class="info-label">Schedule</span>
89
- <code class="info-value mono">{{ scheduleExpr }}</code>
90
- </div>
91
- <div class="info-row">
92
- <span class="info-label">Last Run</span>
93
- <span class="info-value">
94
- {{ formatTime(job.last_run_at) }}
95
- <span v-if="job.last_status" class="run-status" :class="{ ok: job.last_status === 'ok', err: job.last_status !== 'ok' }">
96
- {{ job.last_status === 'ok' ? 'OK' : job.last_status }}
97
- </span>
98
- </span>
99
- </div>
100
- <div class="info-row">
101
- <span class="info-label">Next Run</span>
102
- <span class="info-value">{{ formatTime(job.next_run_at) }}</span>
103
- </div>
104
- <div class="info-row">
105
- <span class="info-label">Deliver</span>
106
- <span class="info-value">{{ job.deliver }}<template v-if="job.origin"> ({{ job.origin.platform }})</template></span>
107
- </div>
108
- <div v-if="job.repeat" class="info-row">
109
- <span class="info-label">Repeat</span>
110
- <span class="info-value">
111
- <template v-if="typeof job.repeat === 'string'">{{ job.repeat }}</template>
112
- <template v-else>{{ job.repeat.completed }} / {{ job.repeat.times ?? '∞' }}</template>
113
- </span>
114
- </div>
115
- </div>
116
-
117
- <div class="card-actions">
118
- <NTooltip v-if="job.state !== 'paused' && job.enabled">
119
- <template #trigger>
120
- <NButton size="tiny" quaternary @click="handlePause">Pause</NButton>
121
- </template>
122
- Pause job
123
- </NTooltip>
124
- <NTooltip v-else-if="job.state === 'paused'">
125
- <template #trigger>
126
- <NButton size="tiny" quaternary @click="handleResume">Resume</NButton>
127
- </template>
128
- Resume job
129
- </NTooltip>
130
- <NTooltip>
131
- <template #trigger>
132
- <NButton size="tiny" quaternary @click="handleRun">Run Now</NButton>
133
- </template>
134
- Trigger immediately
135
- </NTooltip>
136
- <NButton size="tiny" quaternary @click="emit('edit', jobId)">Edit</NButton>
137
- <NButton size="tiny" quaternary type="error" @click="handleDelete">Delete</NButton>
138
- </div>
139
- </div>
140
- </template>
141
-
142
- <style scoped lang="scss">
143
- @use '@/styles/variables' as *;
144
-
145
- .job-card {
146
- background-color: $bg-card;
147
- border: 1px solid $border-color;
148
- border-radius: $radius-md;
149
- padding: 16px;
150
- transition: border-color $transition-fast;
151
-
152
- &:hover {
153
- border-color: rgba($accent-primary, 0.3);
154
- }
155
- }
156
-
157
- .card-header {
158
- display: flex;
159
- align-items: center;
160
- justify-content: space-between;
161
- margin-bottom: 12px;
162
- }
163
-
164
- .job-name {
165
- font-size: 15px;
166
- font-weight: 600;
167
- color: $text-primary;
168
- overflow: hidden;
169
- text-overflow: ellipsis;
170
- white-space: nowrap;
171
- max-width: 70%;
172
- }
173
-
174
- .status-badge {
175
- font-size: 11px;
176
- padding: 2px 8px;
177
- border-radius: 10px;
178
- font-weight: 500;
179
-
180
- &.success {
181
- background: rgba($success, 0.12);
182
- color: $success;
183
- }
184
-
185
- &.info {
186
- background: rgba($accent-primary, 0.12);
187
- color: $accent-primary;
188
- }
189
-
190
- &.warning {
191
- background: rgba($warning, 0.12);
192
- color: $warning;
193
- }
194
-
195
- &.error {
196
- background: rgba($error, 0.12);
197
- color: $error;
198
- }
199
- }
200
-
201
- .card-body {
202
- display: flex;
203
- flex-direction: column;
204
- gap: 6px;
205
- margin-bottom: 14px;
206
- }
207
-
208
- .info-row {
209
- display: flex;
210
- justify-content: space-between;
211
- align-items: center;
212
- }
213
-
214
- .info-label {
215
- font-size: 12px;
216
- color: $text-muted;
217
- }
218
-
219
- .info-value {
220
- font-size: 12px;
221
- color: $text-secondary;
222
- }
223
-
224
- .run-status {
225
- margin-left: 6px;
226
- font-size: 11px;
227
- font-weight: 500;
228
-
229
- &.ok { color: $success; }
230
- &.err { color: $error; }
231
- }
232
-
233
- .mono {
234
- font-family: $font-code;
235
- font-size: 12px;
236
- }
237
-
238
- .card-actions {
239
- display: flex;
240
- gap: 4px;
241
- border-top: 1px solid $border-light;
242
- padding-top: 10px;
243
- }
244
- </style>
@@ -1,188 +0,0 @@
1
- <script setup lang="ts">
2
- import { ref, onMounted, computed } from 'vue'
3
- import { NModal, NForm, NFormItem, NInput, NButton, NSelect, NInputNumber, useMessage } from 'naive-ui'
4
- import { useJobsStore } from '@/stores/jobs'
5
-
6
- const props = defineProps<{
7
- jobId: string | null
8
- }>()
9
-
10
- const emit = defineEmits<{
11
- close: []
12
- saved: []
13
- }>()
14
-
15
- const jobsStore = useJobsStore()
16
- const message = useMessage()
17
-
18
- const showModal = ref(true)
19
- const loading = ref(false)
20
-
21
- const formData = ref({
22
- name: '',
23
- schedule: '',
24
- prompt: '',
25
- deliver: 'origin',
26
- repeat_times: null as number | null,
27
- })
28
-
29
- const presetValue = ref<string | null>(null)
30
-
31
- const isEdit = computed(() => !!props.jobId)
32
-
33
- const schedulePresets = [
34
- { label: 'Every minute', value: '* * * * *' },
35
- { label: 'Every 5 minutes', value: '*/5 * * * *' },
36
- { label: 'Every hour', value: '0 * * * *' },
37
- { label: 'Every day at 00:00', value: '0 0 * * *' },
38
- { label: 'Every day at 09:00', value: '0 9 * * *' },
39
- { label: 'Every Monday at 09:00', value: '0 9 * * 1' },
40
- { label: 'Every month 1st at 09:00', value: '0 9 1 * *' },
41
- ]
42
-
43
- const targetOptions = [
44
- { label: 'Origin', value: 'origin' },
45
- { label: 'Local', value: 'local' },
46
- ]
47
-
48
- onMounted(async () => {
49
- if (props.jobId) {
50
- try {
51
- const { getJob } = await import('@/api/jobs')
52
- const job = await getJob(props.jobId)
53
- formData.value = {
54
- name: job.name,
55
- schedule: typeof job.schedule === 'string' ? job.schedule : (job.schedule?.expr || job.schedule_display || ''),
56
- prompt: job.prompt,
57
- deliver: job.deliver || 'origin',
58
- repeat_times: typeof job.repeat === 'number' ? job.repeat : (typeof job.repeat === 'object' ? job.repeat.times : null),
59
- }
60
- } catch (e: any) {
61
- message.error('Failed to load job: ' + e.message)
62
- }
63
- }
64
- })
65
-
66
- async function handleSave() {
67
- if (!formData.value.name.trim()) {
68
- message.warning('Name is required')
69
- return
70
- }
71
- if (!formData.value.schedule.trim()) {
72
- message.warning('Schedule is required')
73
- return
74
- }
75
-
76
- loading.value = true
77
- try {
78
- const payload = {
79
- name: formData.value.name,
80
- schedule: formData.value.schedule,
81
- prompt: formData.value.prompt,
82
- deliver: formData.value.deliver,
83
- repeat: formData.value.repeat_times ?? undefined,
84
- }
85
-
86
- if (isEdit.value) {
87
- await jobsStore.updateJob(props.jobId!, payload)
88
- message.success('Job updated')
89
- } else {
90
- await jobsStore.createJob(payload)
91
- message.success('Job created')
92
- }
93
- emit('saved')
94
- } catch (e: any) {
95
- message.error(e.message)
96
- } finally {
97
- loading.value = false
98
- }
99
- }
100
-
101
- function handleClose() {
102
- showModal.value = false
103
- setTimeout(() => emit('close'), 200)
104
- }
105
- </script>
106
-
107
- <template>
108
- <NModal
109
- v-model:show="showModal"
110
- preset="card"
111
- :title="isEdit ? 'Edit Job' : 'Create Job'"
112
- :style="{ width: '520px' }"
113
- :mask-closable="!loading"
114
- @after-leave="emit('close')"
115
- >
116
- <NForm label-placement="top">
117
- <NFormItem label="Name" required>
118
- <NInput
119
- v-model:value="formData.name"
120
- placeholder="Job name"
121
- maxlength="200"
122
- show-count
123
- />
124
- </NFormItem>
125
-
126
- <NFormItem label="Schedule (Cron Expression)" required>
127
- <NInput
128
- v-model:value="formData.schedule"
129
- placeholder="e.g. 0 9 * * *"
130
- />
131
- </NFormItem>
132
-
133
- <NFormItem label="Quick Presets">
134
- <NSelect
135
- v-model:value="presetValue"
136
- :options="schedulePresets"
137
- placeholder="Select a preset..."
138
- @update:value="v => formData.schedule = v"
139
- />
140
- </NFormItem>
141
-
142
- <NFormItem label="Prompt" required>
143
- <NInput
144
- v-model:value="formData.prompt"
145
- type="textarea"
146
- placeholder="The prompt to execute"
147
- :rows="4"
148
- maxlength="5000"
149
- show-count
150
- />
151
- </NFormItem>
152
-
153
- <NFormItem label="Deliver Target">
154
- <NSelect
155
- v-model:value="formData.deliver"
156
- :options="targetOptions"
157
- />
158
- </NFormItem>
159
-
160
- <NFormItem label="Repeat Count (optional)">
161
- <NInputNumber
162
- v-model:value="formData.repeat_times"
163
- :min="1"
164
- placeholder="Leave empty for infinite"
165
- clearable
166
- style="width: 100%"
167
- />
168
- </NFormItem>
169
- </NForm>
170
-
171
- <template #footer>
172
- <div class="modal-footer">
173
- <NButton @click="handleClose">Cancel</NButton>
174
- <NButton type="primary" :loading="loading" @click="handleSave">
175
- {{ isEdit ? 'Update' : 'Create' }}
176
- </NButton>
177
- </div>
178
- </template>
179
- </NModal>
180
- </template>
181
-
182
- <style scoped lang="scss">
183
- .modal-footer {
184
- display: flex;
185
- justify-content: flex-end;
186
- gap: 8px;
187
- }
188
- </style>