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.
Files changed (52) hide show
  1. package/ARCHITECTURE.md +193 -0
  2. package/LICENSE +21 -0
  3. package/README.md +406 -2
  4. package/package.json +42 -4
  5. package/tha/cli.mjs +116 -0
  6. package/tha/commands/adopt.mjs +119 -0
  7. package/tha/commands/check.mjs +226 -0
  8. package/tha/commands/diff.mjs +70 -0
  9. package/tha/commands/init.mjs +262 -0
  10. package/tha/commands/remove.mjs +328 -0
  11. package/tha/commands/sync.mjs +826 -0
  12. package/tha/config/field.mjs +178 -0
  13. package/tha/config/load.mjs +188 -0
  14. package/tha/config/manifest.mjs +75 -0
  15. package/tha/config/paths.mjs +124 -0
  16. package/tha/config/project.mjs +52 -0
  17. package/tha/config/rules.mjs +85 -0
  18. package/tha/config/schema.mjs +306 -0
  19. package/tha/config/template.mjs +78 -0
  20. package/tha/generate/domain/api.mjs +67 -0
  21. package/tha/generate/domain/context.mjs +76 -0
  22. package/tha/generate/domain/detail.mjs +35 -0
  23. package/tha/generate/domain/entry.mjs +91 -0
  24. package/tha/generate/domain/executors.mjs +93 -0
  25. package/tha/generate/domain/form.mjs +34 -0
  26. package/tha/generate/domain/index.mjs +72 -0
  27. package/tha/generate/domain/permissions.mjs +35 -0
  28. package/tha/generate/domain/store.mjs +68 -0
  29. package/tha/generate/domain/view.mjs +29 -0
  30. package/tha/generate/global.mjs +111 -0
  31. package/tha/generate/header.mjs +29 -0
  32. package/tha/generate/names.mjs +45 -0
  33. package/tha/generate/plan.mjs +41 -0
  34. package/tha/kit/ThaCrudPage.vue +344 -0
  35. package/tha/kit/ThaDetail.vue +146 -0
  36. package/tha/kit/ThaForm.vue +194 -0
  37. package/tha/kit/request.ts +30 -0
  38. package/tha/kit/transport.ts +11 -0
  39. package/tha/utils/args.mjs +59 -0
  40. package/tha/utils/compare.mjs +38 -0
  41. package/tha/utils/files.mjs +175 -0
  42. package/tha/utils/format.mjs +23 -0
  43. package/tha/utils/output.mjs +79 -0
  44. package/tha/utils/style.mjs +147 -0
  45. package/tha/utils/ts.mjs +73 -0
  46. package/tha/verify/ast.mjs +143 -0
  47. package/tha/verify/boundary.mjs +219 -0
  48. package/tha/verify/contract.mjs +186 -0
  49. package/tha/verify/openapi.mjs +345 -0
  50. package/tha/verify/rules.mjs +91 -0
  51. package/tha/verify/scan.mjs +59 -0
  52. 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>