feat(10-01): serve the embedded admin SPA at backend.uri with cookie login
- backend.uri prefix (default /backend) mounts the admin API at {prefix}/api/v1
and the embedded SPA shell at {prefix} with an api/ JSON 404 fallback
- cookie transport: an X-Requested-With login sets the HttpOnly summer_admin
cookie and returns no token; the backend guard reads the cookie after Bearer
- CSRF wrapper refuses cookie-only POST/PUT/DELETE without X-Requested-With
- boardwalk package embeds boardwalk/dist, rewrites index.html once per prefix
and sets cache and security headers
- framework admin OpenAPI pipeline (swag, swagger2openapi, openapi-typescript)
with prefix-relative paths and typed envelopes for the tracer routes
- admin/ Vite SPA: login, plugin rail, section panel and read-only list
through the openapi-fetch client typed by the generated schema
This commit is contained in:
85
admin/src/components/list/DataTable.vue
Normal file
85
admin/src/components/list/DataTable.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<script setup lang="ts">
|
||||
import type { AdminRecord, ListColumn } from '../../api/types'
|
||||
import { t } from '../../app/i18n'
|
||||
|
||||
// Read-only, schema-driven table: columns come from the list schema and each
|
||||
// record is read through its column keys (D-16). Selection, sorting and
|
||||
// row links arrive with the list screens.
|
||||
defineProps<{
|
||||
columns: ListColumn[]
|
||||
rows: AdminRecord[]
|
||||
loading?: boolean
|
||||
emptyText?: string
|
||||
}>()
|
||||
|
||||
function cellText(row: AdminRecord, column: ListColumn): string {
|
||||
return formatValue(row[column.key])
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '—'
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const parts = value.map(formatValue).filter((part) => part !== '—')
|
||||
return parts.length === 0 ? '—' : parts.join(', ')
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? t('backend::lang.list.column_switch_true') : t('backend::lang.list.column_switch_false')
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return '—'
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
|
||||
function rowKey(row: AdminRecord, index: number): string {
|
||||
const id = row.id
|
||||
return typeof id === 'number' || typeof id === 'string' ? String(id) : `row-${index}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full min-w-[640px] border-collapse text-left">
|
||||
<thead>
|
||||
<tr class="h-row-head border-y border-border bg-subtle">
|
||||
<th
|
||||
v-for="column in columns"
|
||||
:key="column.key"
|
||||
scope="col"
|
||||
class="px-3.5 text-[12px] font-semibold text-muted first:pl-5 last:pr-5"
|
||||
>
|
||||
{{ column.label }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody :aria-busy="loading ? 'true' : undefined">
|
||||
<template v-if="loading">
|
||||
<tr v-for="n in 8" :key="`skeleton-${n}`" class="h-row border-b border-border">
|
||||
<td v-for="column in columns" :key="column.key" class="px-3.5 first:pl-5 last:pr-5">
|
||||
<span class="block h-3 w-3/5 rounded-[6px] bg-skel" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
<tr v-else-if="rows.length === 0">
|
||||
<td :colspan="Math.max(columns.length, 1)" class="px-5 py-20 text-center text-muted">
|
||||
{{ emptyText ?? t('backend::lang.list.no_records') }}
|
||||
</td>
|
||||
</tr>
|
||||
<template v-else>
|
||||
<tr v-for="(row, index) in rows" :key="rowKey(row, index)" class="h-row border-b border-border">
|
||||
<td
|
||||
v-for="(column, columnIndex) in columns"
|
||||
:key="column.key"
|
||||
:class="columnIndex === 0 ? 'font-semibold' : 'text-muted'"
|
||||
class="px-3.5 first:pl-5 last:pr-5"
|
||||
>
|
||||
{{ cellText(row, column) }}
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user