tha-cli 0.0.0-stage → 0.1.1
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.
- package/ARCHITECTURE.md +193 -0
- package/LICENSE +21 -0
- package/README.md +406 -2
- package/package.json +42 -4
- package/tha/cli.mjs +116 -0
- package/tha/commands/adopt.mjs +119 -0
- package/tha/commands/check.mjs +226 -0
- package/tha/commands/diff.mjs +70 -0
- package/tha/commands/init.mjs +262 -0
- package/tha/commands/remove.mjs +328 -0
- package/tha/commands/sync.mjs +826 -0
- package/tha/config/field.mjs +178 -0
- package/tha/config/load.mjs +188 -0
- package/tha/config/manifest.mjs +75 -0
- package/tha/config/paths.mjs +124 -0
- package/tha/config/project.mjs +52 -0
- package/tha/config/rules.mjs +85 -0
- package/tha/config/schema.mjs +306 -0
- package/tha/config/template.mjs +78 -0
- package/tha/generate/domain/api.mjs +67 -0
- package/tha/generate/domain/context.mjs +76 -0
- package/tha/generate/domain/detail.mjs +35 -0
- package/tha/generate/domain/entry.mjs +91 -0
- package/tha/generate/domain/executors.mjs +93 -0
- package/tha/generate/domain/form.mjs +34 -0
- package/tha/generate/domain/index.mjs +72 -0
- package/tha/generate/domain/permissions.mjs +35 -0
- package/tha/generate/domain/store.mjs +68 -0
- package/tha/generate/domain/view.mjs +29 -0
- package/tha/generate/global.mjs +111 -0
- package/tha/generate/header.mjs +29 -0
- package/tha/generate/names.mjs +45 -0
- package/tha/generate/plan.mjs +41 -0
- package/tha/kit/ThaCrudPage.vue +344 -0
- package/tha/kit/ThaDetail.vue +146 -0
- package/tha/kit/ThaForm.vue +194 -0
- package/tha/kit/request.ts +30 -0
- package/tha/kit/transport.ts +11 -0
- package/tha/utils/args.mjs +59 -0
- package/tha/utils/compare.mjs +38 -0
- package/tha/utils/files.mjs +175 -0
- package/tha/utils/format.mjs +23 -0
- package/tha/utils/output.mjs +79 -0
- package/tha/utils/style.mjs +147 -0
- package/tha/utils/ts.mjs +73 -0
- package/tha/verify/ast.mjs +143 -0
- package/tha/verify/boundary.mjs +219 -0
- package/tha/verify/contract.mjs +186 -0
- package/tha/verify/openapi.mjs +345 -0
- package/tha/verify/rules.mjs +91 -0
- package/tha/verify/scan.mjs +59 -0
- package/tha.mjs +22 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, reactive, ref } from 'vue'
|
|
3
|
+
|
|
4
|
+
interface ThaField {
|
|
5
|
+
label: string
|
|
6
|
+
type: string
|
|
7
|
+
required?: boolean
|
|
8
|
+
list?: boolean
|
|
9
|
+
form?: boolean
|
|
10
|
+
search?: boolean
|
|
11
|
+
readonly?: boolean
|
|
12
|
+
options?: Array<{ label: string; value: string; color?: string }>
|
|
13
|
+
default?: unknown
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ThaConfig {
|
|
17
|
+
title: string
|
|
18
|
+
fields: Record<string, ThaField>
|
|
19
|
+
list?: { pageSize?: number; orderBy?: string; order?: 'asc' | 'desc' }
|
|
20
|
+
crud?: { create?: boolean; update?: boolean; remove?: boolean }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
interface ThaActions {
|
|
24
|
+
refresh: () => Promise<boolean>
|
|
25
|
+
create?: (input: Record<string, unknown>) => Promise<boolean>
|
|
26
|
+
update?: (input: { id: number; data: Record<string, unknown> }) => Promise<boolean>
|
|
27
|
+
remove?: (id: number) => Promise<boolean>
|
|
28
|
+
can?: (action: 'view' | 'create' | 'update' | 'remove') => boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface ThaStore {
|
|
32
|
+
items: Array<Record<string, unknown> & { id: number }>
|
|
33
|
+
pending: Record<string, boolean>
|
|
34
|
+
error: string | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const props = defineProps<{ config: ThaConfig; actions: ThaActions; store: ThaStore }>()
|
|
38
|
+
|
|
39
|
+
const fields = computed(() =>
|
|
40
|
+
Object.entries(props.config.fields ?? {}).map(([key, field]) => ({ key, ...field }))
|
|
41
|
+
)
|
|
42
|
+
const listFields = computed(() => fields.value.filter((field) => field.list !== false))
|
|
43
|
+
const formFields = computed(() => fields.value.filter((field) => field.form !== false && !field.readonly))
|
|
44
|
+
const searchFields = computed(() => fields.value.filter((field) => field.search))
|
|
45
|
+
|
|
46
|
+
const allowed = (action: 'create' | 'update' | 'remove'): boolean => {
|
|
47
|
+
const crud = props.config.crud ?? {}
|
|
48
|
+
if (crud[action] === false) return false
|
|
49
|
+
return props.actions.can ? props.actions.can(action) : true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const canCreate = computed(() => allowed('create') && Boolean(props.actions.create))
|
|
53
|
+
const canUpdate = computed(() => allowed('update') && Boolean(props.actions.update))
|
|
54
|
+
const canRemove = computed(() => allowed('remove') && Boolean(props.actions.remove))
|
|
55
|
+
|
|
56
|
+
const keyword = ref('')
|
|
57
|
+
const page = ref(1)
|
|
58
|
+
const pageSize = computed(() => props.config.list?.pageSize ?? 20)
|
|
59
|
+
const filtered = computed(() => {
|
|
60
|
+
const text = keyword.value.trim().toLowerCase()
|
|
61
|
+
if (!text) return props.store.items
|
|
62
|
+
return props.store.items.filter((row) =>
|
|
63
|
+
searchFields.value.some((field) => String(row[field.key] ?? '').toLowerCase().includes(text))
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
const sorted = computed(() => {
|
|
67
|
+
const orderBy = props.config.list?.orderBy
|
|
68
|
+
if (!orderBy) return filtered.value
|
|
69
|
+
const direction = props.config.list?.order === 'asc' ? 1 : -1
|
|
70
|
+
return [...filtered.value].sort((left, right) => {
|
|
71
|
+
const a = left[orderBy]
|
|
72
|
+
const b = right[orderBy]
|
|
73
|
+
if (a === b) return 0
|
|
74
|
+
if (a === null || a === undefined) return 1
|
|
75
|
+
if (b === null || b === undefined) return -1
|
|
76
|
+
const order = typeof a === 'number' && typeof b === 'number' ? a - b : String(a).localeCompare(String(b))
|
|
77
|
+
return order * direction
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
const pageCount = computed(() => Math.max(1, Math.ceil(sorted.value.length / pageSize.value)))
|
|
81
|
+
const current = computed(() => Math.min(page.value, pageCount.value))
|
|
82
|
+
const paged = computed(() => {
|
|
83
|
+
const start = (current.value - 1) * pageSize.value
|
|
84
|
+
return sorted.value.slice(start, start + pageSize.value)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const dialogOpen = ref(false)
|
|
88
|
+
const editingId = ref<number | null>(null)
|
|
89
|
+
const draft = reactive<Record<string, unknown>>({})
|
|
90
|
+
const localError = ref('')
|
|
91
|
+
|
|
92
|
+
const isEditing = computed(() => editingId.value !== null)
|
|
93
|
+
const title = computed(() => props.config.title)
|
|
94
|
+
|
|
95
|
+
function resetDraft(): void {
|
|
96
|
+
for (const key of Object.keys(draft)) delete draft[key]
|
|
97
|
+
for (const field of formFields.value) {
|
|
98
|
+
draft[field.key] = field.default ?? (field.type === 'boolean' ? false : '')
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function openCreate(): void {
|
|
103
|
+
editingId.value = null
|
|
104
|
+
resetDraft()
|
|
105
|
+
dialogOpen.value = true
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function openEdit(row: Record<string, unknown> & { id: number }): void {
|
|
109
|
+
editingId.value = row.id
|
|
110
|
+
resetDraft()
|
|
111
|
+
for (const field of formFields.value) draft[field.key] = row[field.key] ?? draft[field.key]
|
|
112
|
+
dialogOpen.value = true
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function submit(): Promise<void> {
|
|
116
|
+
const missing = formFields.value.filter(
|
|
117
|
+
(field) => field.required && (draft[field.key] === '' || draft[field.key] === undefined)
|
|
118
|
+
)
|
|
119
|
+
if (missing.length > 0) {
|
|
120
|
+
localError.value = `请填写:${missing.map((field) => field.label).join('、')}`
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
localError.value = ''
|
|
124
|
+
|
|
125
|
+
const ok = isEditing.value
|
|
126
|
+
? await props.actions.update?.({ id: editingId.value as number, data: { ...draft } })
|
|
127
|
+
: await props.actions.create?.({ ...draft })
|
|
128
|
+
|
|
129
|
+
if (ok) dialogOpen.value = false
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function removeRow(id: number): Promise<void> {
|
|
133
|
+
if (!window.confirm('确认删除这一条?')) return
|
|
134
|
+
await props.actions.remove?.(id)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function search(): void {
|
|
138
|
+
page.value = 1
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function go(target: number): void {
|
|
142
|
+
page.value = Math.max(1, Math.min(pageCount.value, target))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function display(row: Record<string, unknown>, field: (typeof fields.value)[number]): string {
|
|
146
|
+
const value = row[field.key]
|
|
147
|
+
if (field.type === 'enum') {
|
|
148
|
+
const option = field.options?.find((item) => item.value === value)
|
|
149
|
+
return option ? option.label : String(value ?? '')
|
|
150
|
+
}
|
|
151
|
+
if (value === null || value === undefined || value === '') return '—'
|
|
152
|
+
return String(value)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
onMounted(() => {
|
|
156
|
+
void props.actions.refresh()
|
|
157
|
+
})
|
|
158
|
+
</script>
|
|
159
|
+
|
|
160
|
+
<template>
|
|
161
|
+
<section class="tha-crud">
|
|
162
|
+
<header class="tha-crud__head">
|
|
163
|
+
<h2>{{ title }}</h2>
|
|
164
|
+
<div class="tha-crud__tools">
|
|
165
|
+
<input
|
|
166
|
+
v-if="searchFields.length > 0"
|
|
167
|
+
v-model="keyword"
|
|
168
|
+
class="tha-crud__search"
|
|
169
|
+
type="search"
|
|
170
|
+
placeholder="搜索"
|
|
171
|
+
@input="search"
|
|
172
|
+
/>
|
|
173
|
+
<button v-if="canCreate" class="tha-crud__btn tha-crud__btn--primary" @click="openCreate">
|
|
174
|
+
新建
|
|
175
|
+
</button>
|
|
176
|
+
</div>
|
|
177
|
+
</header>
|
|
178
|
+
|
|
179
|
+
<p v-if="localError || store.error" class="tha-crud__error">{{ localError || store.error }}</p>
|
|
180
|
+
|
|
181
|
+
<table class="tha-crud__table">
|
|
182
|
+
<thead>
|
|
183
|
+
<tr>
|
|
184
|
+
<th v-for="field in listFields" :key="field.key">{{ field.label }}</th>
|
|
185
|
+
<th v-if="canUpdate || canRemove" class="tha-crud__op">操作</th>
|
|
186
|
+
</tr>
|
|
187
|
+
</thead>
|
|
188
|
+
<tbody>
|
|
189
|
+
<tr v-for="row in paged" :key="row.id">
|
|
190
|
+
<td v-for="field in listFields" :key="field.key">{{ display(row, field) }}</td>
|
|
191
|
+
<td v-if="canUpdate || canRemove" class="tha-crud__op">
|
|
192
|
+
<button v-if="canUpdate" class="tha-crud__btn" @click="openEdit(row)">编辑</button>
|
|
193
|
+
<button v-if="canRemove" class="tha-crud__btn tha-crud__btn--danger" @click="removeRow(row.id)">
|
|
194
|
+
删除
|
|
195
|
+
</button>
|
|
196
|
+
</td>
|
|
197
|
+
</tr>
|
|
198
|
+
<tr v-if="paged.length === 0">
|
|
199
|
+
<td :colspan="listFields.length + 1" class="tha-crud__empty">暂无数据</td>
|
|
200
|
+
</tr>
|
|
201
|
+
</tbody>
|
|
202
|
+
</table>
|
|
203
|
+
|
|
204
|
+
<footer v-if="pageCount > 1" class="tha-crud__pager">
|
|
205
|
+
<button class="tha-crud__btn" :disabled="current <= 1" @click="go(current - 1)">上一页</button>
|
|
206
|
+
<span>{{ current }} / {{ pageCount }}</span>
|
|
207
|
+
<button class="tha-crud__btn" :disabled="current >= pageCount" @click="go(current + 1)">下一页</button>
|
|
208
|
+
</footer>
|
|
209
|
+
|
|
210
|
+
<div v-if="dialogOpen" class="tha-crud__mask" @click.self="dialogOpen = false">
|
|
211
|
+
<form class="tha-crud__dialog" @submit.prevent="submit">
|
|
212
|
+
<h3>{{ isEditing ? '编辑' : '新建' }} · {{ title }}</h3>
|
|
213
|
+
|
|
214
|
+
<label v-for="field in formFields" :key="field.key" class="tha-crud__field">
|
|
215
|
+
<span>{{ field.label }}<b v-if="field.required"> *</b></span>
|
|
216
|
+
|
|
217
|
+
<select v-if="field.type === 'enum'" v-model="draft[field.key]">
|
|
218
|
+
<option value="">请选择</option>
|
|
219
|
+
<option v-for="option in field.options" :key="option.value" :value="option.value">
|
|
220
|
+
{{ option.label }}
|
|
221
|
+
</option>
|
|
222
|
+
</select>
|
|
223
|
+
|
|
224
|
+
<input v-else-if="field.type === 'boolean'" v-model="draft[field.key]" type="checkbox" />
|
|
225
|
+
|
|
226
|
+
<textarea v-else-if="field.type === 'text'" v-model="draft[field.key]" rows="3" />
|
|
227
|
+
|
|
228
|
+
<input
|
|
229
|
+
v-else
|
|
230
|
+
v-model="draft[field.key]"
|
|
231
|
+
:type="field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : field.type === 'datetime' ? 'datetime-local' : 'text'"
|
|
232
|
+
/>
|
|
233
|
+
</label>
|
|
234
|
+
|
|
235
|
+
<div class="tha-crud__actions">
|
|
236
|
+
<button type="button" class="tha-crud__btn" @click="dialogOpen = false">取消</button>
|
|
237
|
+
<button type="submit" class="tha-crud__btn tha-crud__btn--primary">保存</button>
|
|
238
|
+
</div>
|
|
239
|
+
</form>
|
|
240
|
+
</div>
|
|
241
|
+
</section>
|
|
242
|
+
</template>
|
|
243
|
+
|
|
244
|
+
<style scoped>
|
|
245
|
+
.tha-crud {
|
|
246
|
+
padding: 16px;
|
|
247
|
+
color: #1f2329;
|
|
248
|
+
}
|
|
249
|
+
.tha-crud__head {
|
|
250
|
+
display: flex;
|
|
251
|
+
align-items: center;
|
|
252
|
+
justify-content: space-between;
|
|
253
|
+
gap: 12px;
|
|
254
|
+
margin-bottom: 12px;
|
|
255
|
+
}
|
|
256
|
+
.tha-crud__tools {
|
|
257
|
+
display: flex;
|
|
258
|
+
gap: 8px;
|
|
259
|
+
}
|
|
260
|
+
.tha-crud__search {
|
|
261
|
+
padding: 6px 10px;
|
|
262
|
+
border: 1px solid #dee0e3;
|
|
263
|
+
border-radius: 6px;
|
|
264
|
+
}
|
|
265
|
+
.tha-crud__table {
|
|
266
|
+
width: 100%;
|
|
267
|
+
border-collapse: collapse;
|
|
268
|
+
}
|
|
269
|
+
.tha-crud__table th,
|
|
270
|
+
.tha-crud__table td {
|
|
271
|
+
padding: 10px 12px;
|
|
272
|
+
border-bottom: 1px solid #eff0f1;
|
|
273
|
+
text-align: left;
|
|
274
|
+
}
|
|
275
|
+
.tha-crud__table th {
|
|
276
|
+
color: #646a73;
|
|
277
|
+
font-weight: 500;
|
|
278
|
+
}
|
|
279
|
+
.tha-crud__op {
|
|
280
|
+
white-space: nowrap;
|
|
281
|
+
}
|
|
282
|
+
.tha-crud__empty {
|
|
283
|
+
color: #8f959e;
|
|
284
|
+
text-align: center;
|
|
285
|
+
}
|
|
286
|
+
.tha-crud__btn {
|
|
287
|
+
padding: 5px 12px;
|
|
288
|
+
border: 1px solid #dee0e3;
|
|
289
|
+
border-radius: 6px;
|
|
290
|
+
background: #fff;
|
|
291
|
+
cursor: pointer;
|
|
292
|
+
}
|
|
293
|
+
.tha-crud__btn--primary {
|
|
294
|
+
border-color: #3370ff;
|
|
295
|
+
background: #3370ff;
|
|
296
|
+
color: #fff;
|
|
297
|
+
}
|
|
298
|
+
.tha-crud__btn--danger {
|
|
299
|
+
color: #f54a45;
|
|
300
|
+
}
|
|
301
|
+
.tha-crud__error {
|
|
302
|
+
margin: 0 0 12px;
|
|
303
|
+
color: #f54a45;
|
|
304
|
+
}
|
|
305
|
+
.tha-crud__pager {
|
|
306
|
+
display: flex;
|
|
307
|
+
align-items: center;
|
|
308
|
+
justify-content: flex-end;
|
|
309
|
+
gap: 8px;
|
|
310
|
+
margin-top: 12px;
|
|
311
|
+
}
|
|
312
|
+
.tha-crud__mask {
|
|
313
|
+
position: fixed;
|
|
314
|
+
inset: 0;
|
|
315
|
+
display: flex;
|
|
316
|
+
align-items: center;
|
|
317
|
+
justify-content: center;
|
|
318
|
+
background: rgb(0 0 0 / 40%);
|
|
319
|
+
}
|
|
320
|
+
.tha-crud__dialog {
|
|
321
|
+
width: min(520px, 90vw);
|
|
322
|
+
padding: 20px;
|
|
323
|
+
border-radius: 10px;
|
|
324
|
+
background: #fff;
|
|
325
|
+
}
|
|
326
|
+
.tha-crud__field {
|
|
327
|
+
display: flex;
|
|
328
|
+
flex-direction: column;
|
|
329
|
+
gap: 6px;
|
|
330
|
+
margin-bottom: 12px;
|
|
331
|
+
}
|
|
332
|
+
.tha-crud__field input,
|
|
333
|
+
.tha-crud__field select,
|
|
334
|
+
.tha-crud__field textarea {
|
|
335
|
+
padding: 7px 10px;
|
|
336
|
+
border: 1px solid #dee0e3;
|
|
337
|
+
border-radius: 6px;
|
|
338
|
+
}
|
|
339
|
+
.tha-crud__actions {
|
|
340
|
+
display: flex;
|
|
341
|
+
justify-content: flex-end;
|
|
342
|
+
gap: 8px;
|
|
343
|
+
}
|
|
344
|
+
</style>
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, ref } from 'vue'
|
|
3
|
+
import { useRouter } from 'vue-router'
|
|
4
|
+
|
|
5
|
+
interface ThaField {
|
|
6
|
+
label: string
|
|
7
|
+
type: string
|
|
8
|
+
readonly?: boolean
|
|
9
|
+
options?: Array<{ label: string; value: string; color?: string }>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ThaConfig {
|
|
13
|
+
title: string
|
|
14
|
+
path?: string
|
|
15
|
+
fields: Record<string, ThaField>
|
|
16
|
+
crud?: { create?: boolean; update?: boolean; remove?: boolean }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ThaActions {
|
|
20
|
+
refresh: () => Promise<boolean>
|
|
21
|
+
load?: (id: number) => Promise<unknown>
|
|
22
|
+
can?: (action: 'view' | 'create' | 'update' | 'remove') => boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ThaStore {
|
|
26
|
+
items: Array<Record<string, unknown> & { id: number }>
|
|
27
|
+
error: string | null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const props = defineProps<{ config: ThaConfig; actions: ThaActions; store: ThaStore; id: number }>()
|
|
31
|
+
|
|
32
|
+
const router = useRouter()
|
|
33
|
+
const loading = ref(false)
|
|
34
|
+
|
|
35
|
+
const fields = computed(() =>
|
|
36
|
+
Object.entries(props.config.fields ?? {}).map(([key, field]) => ({ key, ...field }))
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
const row = computed(() => props.store.items.find((item) => item.id === props.id))
|
|
40
|
+
|
|
41
|
+
const canEdit = computed(() => {
|
|
42
|
+
if (props.config.crud?.update === false) return false
|
|
43
|
+
return props.actions.can ? props.actions.can('update') : true
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
onMounted(async () => {
|
|
47
|
+
if (row.value) return
|
|
48
|
+
if (!props.actions.load) return
|
|
49
|
+
loading.value = true
|
|
50
|
+
await props.actions.load(props.id)
|
|
51
|
+
loading.value = false
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
function display(field: (typeof fields.value)[number]): string {
|
|
55
|
+
const value = row.value?.[field.key]
|
|
56
|
+
if (field.type === 'enum') {
|
|
57
|
+
const option = field.options?.find((item) => item.value === value)
|
|
58
|
+
return option ? option.label : String(value ?? '')
|
|
59
|
+
}
|
|
60
|
+
if (value === null || value === undefined || value === '') return '—'
|
|
61
|
+
return String(value)
|
|
62
|
+
}
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
<template>
|
|
66
|
+
<section class="tha-detail">
|
|
67
|
+
<header class="tha-detail__head">
|
|
68
|
+
<h2>{{ config.title }}</h2>
|
|
69
|
+
<div class="tha-detail__tools">
|
|
70
|
+
<button class="tha-detail__btn" @click="router.back()">返回</button>
|
|
71
|
+
<button
|
|
72
|
+
v-if="canEdit && row"
|
|
73
|
+
class="tha-detail__btn tha-detail__btn--primary"
|
|
74
|
+
@click="router.push(`${config.path}/${id}/edit`)"
|
|
75
|
+
>
|
|
76
|
+
编辑
|
|
77
|
+
</button>
|
|
78
|
+
</div>
|
|
79
|
+
</header>
|
|
80
|
+
|
|
81
|
+
<p v-if="loading" class="tha-detail__tip">加载中…</p>
|
|
82
|
+
<p v-else-if="!row" class="tha-detail__tip">找不到这条记录</p>
|
|
83
|
+
<p v-if="store.error" class="tha-detail__tip tha-detail__tip--error">{{ store.error }}</p>
|
|
84
|
+
|
|
85
|
+
<dl v-if="row" class="tha-detail__list">
|
|
86
|
+
<div v-for="field in fields" :key="field.key" class="tha-detail__item">
|
|
87
|
+
<dt>{{ field.label }}</dt>
|
|
88
|
+
<dd>{{ display(field) }}</dd>
|
|
89
|
+
</div>
|
|
90
|
+
</dl>
|
|
91
|
+
</section>
|
|
92
|
+
</template>
|
|
93
|
+
|
|
94
|
+
<style scoped>
|
|
95
|
+
.tha-detail {
|
|
96
|
+
padding: 16px;
|
|
97
|
+
color: #1f2329;
|
|
98
|
+
}
|
|
99
|
+
.tha-detail__head {
|
|
100
|
+
display: flex;
|
|
101
|
+
align-items: center;
|
|
102
|
+
justify-content: space-between;
|
|
103
|
+
margin-bottom: 16px;
|
|
104
|
+
}
|
|
105
|
+
.tha-detail__tools {
|
|
106
|
+
display: flex;
|
|
107
|
+
gap: 8px;
|
|
108
|
+
}
|
|
109
|
+
.tha-detail__btn {
|
|
110
|
+
padding: 5px 12px;
|
|
111
|
+
border: 1px solid #dee0e3;
|
|
112
|
+
border-radius: 6px;
|
|
113
|
+
background: #fff;
|
|
114
|
+
cursor: pointer;
|
|
115
|
+
}
|
|
116
|
+
.tha-detail__btn--primary {
|
|
117
|
+
border-color: #3370ff;
|
|
118
|
+
background: #3370ff;
|
|
119
|
+
color: #fff;
|
|
120
|
+
}
|
|
121
|
+
.tha-detail__tip {
|
|
122
|
+
color: #8f959e;
|
|
123
|
+
}
|
|
124
|
+
.tha-detail__tip--error {
|
|
125
|
+
color: #f54a45;
|
|
126
|
+
}
|
|
127
|
+
.tha-detail__list {
|
|
128
|
+
display: grid;
|
|
129
|
+
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
|
130
|
+
gap: 12px;
|
|
131
|
+
}
|
|
132
|
+
.tha-detail__item {
|
|
133
|
+
padding: 10px 12px;
|
|
134
|
+
border: 1px solid #eff0f1;
|
|
135
|
+
border-radius: 8px;
|
|
136
|
+
}
|
|
137
|
+
.tha-detail__item dt {
|
|
138
|
+
margin-bottom: 4px;
|
|
139
|
+
color: #646a73;
|
|
140
|
+
font-size: 13px;
|
|
141
|
+
}
|
|
142
|
+
.tha-detail__item dd {
|
|
143
|
+
margin: 0;
|
|
144
|
+
word-break: break-all;
|
|
145
|
+
}
|
|
146
|
+
</style>
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, onMounted, reactive, ref } from 'vue'
|
|
3
|
+
import { useRouter } from 'vue-router'
|
|
4
|
+
|
|
5
|
+
interface ThaField {
|
|
6
|
+
label: string
|
|
7
|
+
type: string
|
|
8
|
+
required?: boolean
|
|
9
|
+
form?: boolean
|
|
10
|
+
readonly?: boolean
|
|
11
|
+
options?: Array<{ label: string; value: string; color?: string }>
|
|
12
|
+
default?: unknown
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ThaConfig {
|
|
16
|
+
title: string
|
|
17
|
+
path?: string
|
|
18
|
+
fields: Record<string, ThaField>
|
|
19
|
+
crud?: { create?: boolean; update?: boolean; remove?: boolean }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface ThaActions {
|
|
23
|
+
refresh: () => Promise<boolean>
|
|
24
|
+
load?: (id: number) => Promise<unknown>
|
|
25
|
+
create?: (input: Record<string, unknown>) => Promise<boolean>
|
|
26
|
+
update?: (input: { id: number; data: Record<string, unknown> }) => Promise<boolean>
|
|
27
|
+
can?: (action: 'view' | 'create' | 'update' | 'remove') => boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface ThaStore {
|
|
31
|
+
items: Array<Record<string, unknown> & { id: number }>
|
|
32
|
+
error: string | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const props = defineProps<{ config: ThaConfig; actions: ThaActions; store: ThaStore; id?: number }>()
|
|
36
|
+
|
|
37
|
+
const router = useRouter()
|
|
38
|
+
const draft = reactive<Record<string, unknown>>({})
|
|
39
|
+
const localError = ref('')
|
|
40
|
+
|
|
41
|
+
const formFields = computed(() =>
|
|
42
|
+
Object.entries(props.config.fields ?? {})
|
|
43
|
+
.map(([key, field]) => ({ key, ...field }))
|
|
44
|
+
.filter((field) => field.form !== false && !field.readonly)
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
const isEdit = computed(() => props.id !== undefined)
|
|
48
|
+
const allowed = computed(() => {
|
|
49
|
+
const crud = props.config.crud ?? {}
|
|
50
|
+
const action = isEdit.value ? 'update' : 'create'
|
|
51
|
+
if (crud[action] === false) return false
|
|
52
|
+
if (!props.actions.can) return true
|
|
53
|
+
return props.actions.can(action)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
function reset(): void {
|
|
57
|
+
for (const key of Object.keys(draft)) delete draft[key]
|
|
58
|
+
for (const field of formFields.value) {
|
|
59
|
+
draft[field.key] = field.default ?? (field.type === 'boolean' ? false : '')
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function seed(): Promise<void> {
|
|
64
|
+
reset()
|
|
65
|
+
if (!isEdit.value) return
|
|
66
|
+
|
|
67
|
+
const id = props.id as number
|
|
68
|
+
let row = props.store.items.find((item) => item.id === id)
|
|
69
|
+
if (!row && props.actions.load) {
|
|
70
|
+
await props.actions.load(id)
|
|
71
|
+
row = props.store.items.find((item) => item.id === id)
|
|
72
|
+
}
|
|
73
|
+
if (!row) return
|
|
74
|
+
|
|
75
|
+
for (const field of formFields.value) {
|
|
76
|
+
draft[field.key] = row[field.key] ?? draft[field.key]
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function submit(): Promise<void> {
|
|
81
|
+
if (!allowed.value) {
|
|
82
|
+
localError.value = isEdit.value ? '没有编辑权限' : '没有新增权限'
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const missing = formFields.value.filter(
|
|
87
|
+
(field) => field.required && (draft[field.key] === '' || draft[field.key] === undefined)
|
|
88
|
+
)
|
|
89
|
+
if (missing.length > 0) {
|
|
90
|
+
localError.value = `请填写:${missing.map((field) => field.label).join('、')}`
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
localError.value = ''
|
|
94
|
+
|
|
95
|
+
const payload = { ...draft }
|
|
96
|
+
const ok = isEdit.value
|
|
97
|
+
? await props.actions.update?.({ id: props.id as number, data: payload })
|
|
98
|
+
: await props.actions.create?.(payload)
|
|
99
|
+
|
|
100
|
+
if (ok) router.push(props.config.path ?? '/')
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
onMounted(seed)
|
|
104
|
+
</script>
|
|
105
|
+
|
|
106
|
+
<template>
|
|
107
|
+
<section class="tha-form">
|
|
108
|
+
<header class="tha-form__head">
|
|
109
|
+
<h2>{{ isEdit ? '编辑' : '新建' }} · {{ config.title }}</h2>
|
|
110
|
+
</header>
|
|
111
|
+
|
|
112
|
+
<p v-if="localError || store.error" class="tha-form__error">{{ localError || store.error }}</p>
|
|
113
|
+
|
|
114
|
+
<form class="tha-form__body" @submit.prevent="submit">
|
|
115
|
+
<label v-for="field in formFields" :key="field.key" class="tha-form__field">
|
|
116
|
+
<span>{{ field.label }}<b v-if="field.required"> *</b></span>
|
|
117
|
+
|
|
118
|
+
<select v-if="field.type === 'enum'" v-model="draft[field.key]">
|
|
119
|
+
<option value="">请选择</option>
|
|
120
|
+
<option v-for="option in field.options" :key="option.value" :value="option.value">
|
|
121
|
+
{{ option.label }}
|
|
122
|
+
</option>
|
|
123
|
+
</select>
|
|
124
|
+
|
|
125
|
+
<input v-else-if="field.type === 'boolean'" v-model="draft[field.key]" type="checkbox" />
|
|
126
|
+
|
|
127
|
+
<textarea v-else-if="field.type === 'text'" v-model="draft[field.key]" rows="4" />
|
|
128
|
+
|
|
129
|
+
<input
|
|
130
|
+
v-else
|
|
131
|
+
v-model="draft[field.key]"
|
|
132
|
+
:type="field.type === 'number' ? 'number' : field.type === 'date' ? 'date' : field.type === 'datetime' ? 'datetime-local' : 'text'"
|
|
133
|
+
/>
|
|
134
|
+
</label>
|
|
135
|
+
|
|
136
|
+
<div class="tha-form__actions">
|
|
137
|
+
<button type="button" class="tha-form__btn" @click="router.back()">取消</button>
|
|
138
|
+
<button type="submit" class="tha-form__btn tha-form__btn--primary" :disabled="!allowed">
|
|
139
|
+
保存
|
|
140
|
+
</button>
|
|
141
|
+
</div>
|
|
142
|
+
</form>
|
|
143
|
+
</section>
|
|
144
|
+
</template>
|
|
145
|
+
|
|
146
|
+
<style scoped>
|
|
147
|
+
.tha-form {
|
|
148
|
+
padding: 16px;
|
|
149
|
+
color: #1f2329;
|
|
150
|
+
}
|
|
151
|
+
.tha-form__head {
|
|
152
|
+
margin-bottom: 16px;
|
|
153
|
+
}
|
|
154
|
+
.tha-form__error {
|
|
155
|
+
color: #f54a45;
|
|
156
|
+
}
|
|
157
|
+
.tha-form__body {
|
|
158
|
+
max-width: 560px;
|
|
159
|
+
}
|
|
160
|
+
.tha-form__field {
|
|
161
|
+
display: flex;
|
|
162
|
+
flex-direction: column;
|
|
163
|
+
gap: 6px;
|
|
164
|
+
margin-bottom: 14px;
|
|
165
|
+
}
|
|
166
|
+
.tha-form__field input,
|
|
167
|
+
.tha-form__field select,
|
|
168
|
+
.tha-form__field textarea {
|
|
169
|
+
padding: 7px 10px;
|
|
170
|
+
border: 1px solid #dee0e3;
|
|
171
|
+
border-radius: 6px;
|
|
172
|
+
}
|
|
173
|
+
.tha-form__actions {
|
|
174
|
+
display: flex;
|
|
175
|
+
justify-content: flex-end;
|
|
176
|
+
gap: 8px;
|
|
177
|
+
}
|
|
178
|
+
.tha-form__btn {
|
|
179
|
+
padding: 6px 14px;
|
|
180
|
+
border: 1px solid #dee0e3;
|
|
181
|
+
border-radius: 6px;
|
|
182
|
+
background: #fff;
|
|
183
|
+
cursor: pointer;
|
|
184
|
+
}
|
|
185
|
+
.tha-form__btn--primary {
|
|
186
|
+
border-color: #3370ff;
|
|
187
|
+
background: #3370ff;
|
|
188
|
+
color: #fff;
|
|
189
|
+
}
|
|
190
|
+
.tha-form__btn:disabled {
|
|
191
|
+
cursor: not-allowed;
|
|
192
|
+
opacity: 0.5;
|
|
193
|
+
}
|
|
194
|
+
</style>
|