2026-08-11 00:49:41 +08:00
|
|
|
|
<!--
|
|
|
|
|
|
功能:以响应式信息卡和子表页签展示标准资源详情。
|
2026-08-11 02:01:00 +08:00
|
|
|
|
版本:v1.2.0
|
2026-08-11 00:49:41 +08:00
|
|
|
|
-->
|
|
|
|
|
|
<template>
|
|
|
|
|
|
<div class="detail-stack">
|
|
|
|
|
|
<a-card title="基本信息" :bordered="false" class="detail-card">
|
|
|
|
|
|
<div class="detail-grid">
|
|
|
|
|
|
<div v-for="entry in entries" :key="entry.key" class="detail-item" :class="{ 'detail-item-wide': entry.wide }">
|
|
|
|
|
|
<span class="detail-label">{{ entry.label }}</span>
|
|
|
|
|
|
<pre v-if="entry.objectValue" class="json-value">{{ entry.value }}</pre>
|
|
|
|
|
|
<IdentityText v-else-if="entry.key === 'identity'" :value="String(entry.value)" />
|
2026-08-11 19:15:53 +08:00
|
|
|
|
<div v-else-if="entry.identityValue" class="detail-relation-value">
|
|
|
|
|
|
<span class="detail-value">{{ entry.value }}</span>
|
|
|
|
|
|
<IdentityText :value="entry.identityValue" />
|
|
|
|
|
|
</div>
|
2026-08-11 00:49:41 +08:00
|
|
|
|
<span v-else class="detail-value">{{ entry.value }}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</a-card>
|
|
|
|
|
|
|
|
|
|
|
|
<a-card v-if="collections.length" title="关联记录" :bordered="false" class="detail-card">
|
|
|
|
|
|
<a-tabs>
|
|
|
|
|
|
<a-tab-pane v-for="collection in collections" :key="collection.key" :title="collection.title">
|
|
|
|
|
|
<a-table :data="collection.rows" :pagination="false" size="small" table-layout-fixed>
|
|
|
|
|
|
<template #columns>
|
|
|
|
|
|
<a-table-column
|
|
|
|
|
|
v-for="column in collection.columns"
|
|
|
|
|
|
:key="column"
|
|
|
|
|
|
:title="resourceFieldLabel(definition, column)"
|
|
|
|
|
|
:width="column.includes('identity') ? 220 : 160"
|
|
|
|
|
|
ellipsis
|
|
|
|
|
|
tooltip
|
|
|
|
|
|
>
|
|
|
|
|
|
<template #cell="{ record }">
|
|
|
|
|
|
<IdentityText v-if="column.includes('identity') && record[column]" :value="String(record[column])" />
|
|
|
|
|
|
<template v-else>{{ displayRawValue(column, record[column]) }}</template>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</a-table-column>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
</a-table>
|
|
|
|
|
|
</a-tab-pane>
|
|
|
|
|
|
</a-tabs>
|
|
|
|
|
|
</a-card>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
|
|
<script setup lang="ts">
|
|
|
|
|
|
import { computed } from 'vue';
|
|
|
|
|
|
import IdentityText from '@/components/IdentityText.vue';
|
|
|
|
|
|
import {
|
|
|
|
|
|
displayRawValue,
|
|
|
|
|
|
displayResourceField,
|
|
|
|
|
|
isEmptyDeletedAt,
|
|
|
|
|
|
primaryRecord,
|
|
|
|
|
|
resourceFieldLabel,
|
|
|
|
|
|
} from '@/api/resource-display';
|
|
|
|
|
|
import type { ResourceUiDefinition } from '@/api/resources';
|
|
|
|
|
|
import type { ResourceRow } from '@/api/resource-page-rules';
|
|
|
|
|
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
|
|
definition: ResourceUiDefinition;
|
|
|
|
|
|
detail: ResourceRow;
|
|
|
|
|
|
relationOptions: Record<string, ResourceRow[]>;
|
2026-08-11 02:01:00 +08:00
|
|
|
|
fieldOptions?: Record<
|
|
|
|
|
|
string,
|
|
|
|
|
|
Array<{ label: string; value: string | number }>
|
|
|
|
|
|
>;
|
2026-08-11 00:49:41 +08:00
|
|
|
|
accountSummary: boolean;
|
|
|
|
|
|
}>();
|
|
|
|
|
|
|
|
|
|
|
|
type DetailEntry = {
|
|
|
|
|
|
key: string;
|
|
|
|
|
|
label: string;
|
|
|
|
|
|
value: string;
|
|
|
|
|
|
objectValue: boolean;
|
|
|
|
|
|
wide: boolean;
|
2026-08-11 19:15:53 +08:00
|
|
|
|
identityValue: string;
|
2026-08-11 00:49:41 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const entries = computed<DetailEntry[]>(() => {
|
|
|
|
|
|
const row = primaryRecord(props.detail);
|
|
|
|
|
|
const excluded = new Set(['id', 'password', 'password_hash', 'avatar']);
|
|
|
|
|
|
if (props.accountSummary) {
|
|
|
|
|
|
for (const key of ['username', 'identity', 'created_at']) excluded.add(key);
|
|
|
|
|
|
}
|
|
|
|
|
|
const preferred = [
|
|
|
|
|
|
'identity',
|
|
|
|
|
|
'status',
|
|
|
|
|
|
...props.definition.fields.map((field) => field.key),
|
|
|
|
|
|
'created_at',
|
|
|
|
|
|
'updated_at',
|
|
|
|
|
|
];
|
|
|
|
|
|
const keys = [...new Set([...preferred, ...Object.keys(row)])];
|
|
|
|
|
|
return keys.flatMap((key) => {
|
|
|
|
|
|
const maskedKey = `${key}_masked`;
|
|
|
|
|
|
const actualKey = Object.prototype.hasOwnProperty.call(row, key)
|
|
|
|
|
|
? key
|
|
|
|
|
|
: Object.prototype.hasOwnProperty.call(row, maskedKey)
|
|
|
|
|
|
? maskedKey
|
|
|
|
|
|
: '';
|
|
|
|
|
|
if (!actualKey || excluded.has(key) || key.endsWith('_id')) return [];
|
|
|
|
|
|
const value = row[actualKey];
|
|
|
|
|
|
if (Array.isArray(value)) return [];
|
|
|
|
|
|
if (['deleted_at', 'DeletedAt'].includes(key) && isEmptyDeletedAt(value))
|
|
|
|
|
|
return [];
|
|
|
|
|
|
const field = props.definition.fields.find((item) => item.key === key);
|
|
|
|
|
|
const display = field
|
2026-08-11 02:01:00 +08:00
|
|
|
|
? displayResourceField(
|
|
|
|
|
|
field,
|
|
|
|
|
|
row,
|
|
|
|
|
|
props.relationOptions,
|
|
|
|
|
|
props.fieldOptions,
|
|
|
|
|
|
)
|
2026-08-11 00:49:41 +08:00
|
|
|
|
: displayRawValue(actualKey, value);
|
|
|
|
|
|
const objectValue = typeof value === 'object' && value !== null;
|
|
|
|
|
|
return [
|
|
|
|
|
|
{
|
|
|
|
|
|
key,
|
|
|
|
|
|
label: resourceFieldLabel(props.definition, actualKey),
|
|
|
|
|
|
value: display,
|
|
|
|
|
|
objectValue,
|
2026-08-11 19:15:53 +08:00
|
|
|
|
identityValue:
|
2026-08-12 00:37:58 +08:00
|
|
|
|
(field?.showIdentityCopy || field?.staffRelation?.showIdentityCopy) && value
|
|
|
|
|
|
? String(value)
|
|
|
|
|
|
: '',
|
2026-08-11 00:49:41 +08:00
|
|
|
|
wide:
|
|
|
|
|
|
objectValue ||
|
|
|
|
|
|
/(address|terms|content|body|remark|reason|params|args)$/.test(key),
|
|
|
|
|
|
},
|
|
|
|
|
|
];
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const collections = computed(() =>
|
|
|
|
|
|
Object.entries(props.detail)
|
|
|
|
|
|
.filter(([, value]) => Array.isArray(value) && value.length > 0)
|
|
|
|
|
|
.map(([key, value]) => {
|
|
|
|
|
|
const rows = value as ResourceRow[];
|
|
|
|
|
|
const columns = [
|
|
|
|
|
|
...new Set(
|
|
|
|
|
|
rows.flatMap((row) =>
|
|
|
|
|
|
Object.keys(row).filter(
|
|
|
|
|
|
(column) => column !== 'id' && !column.endsWith('_id'),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
),
|
|
|
|
|
|
].slice(0, 8);
|
|
|
|
|
|
return {
|
|
|
|
|
|
key,
|
|
|
|
|
|
title: resourceFieldLabel(props.definition, key),
|
|
|
|
|
|
rows,
|
|
|
|
|
|
columns,
|
|
|
|
|
|
};
|
|
|
|
|
|
}),
|
|
|
|
|
|
);
|
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
|
|
<style scoped lang="less">
|
|
|
|
|
|
.detail-stack {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.detail-card {
|
|
|
|
|
|
border-radius: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.detail-card :deep(.arco-card-header) {
|
|
|
|
|
|
height: 48px;
|
|
|
|
|
|
padding: 0 24px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.detail-card :deep(.arco-card-body) {
|
|
|
|
|
|
padding: 20px 24px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.detail-grid {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
|
|
|
|
gap: 8px 42px;
|
|
|
|
|
|
padding: 0;
|
|
|
|
|
|
}
|
|
|
|
|
|
.detail-item {
|
|
|
|
|
|
display: grid;
|
2026-08-11 02:01:00 +08:00
|
|
|
|
grid-template-columns: max-content minmax(0, 1fr);
|
|
|
|
|
|
column-gap: 12px;
|
2026-08-11 00:49:41 +08:00
|
|
|
|
align-items: start;
|
|
|
|
|
|
min-height: 38px;
|
|
|
|
|
|
padding: 8px 0;
|
|
|
|
|
|
border-bottom: 1px solid var(--color-border-1);
|
|
|
|
|
|
}
|
|
|
|
|
|
.detail-item-wide {
|
|
|
|
|
|
grid-column: 1 / -1;
|
|
|
|
|
|
}
|
|
|
|
|
|
.detail-label {
|
|
|
|
|
|
color: var(--color-text-3);
|
2026-08-11 02:01:00 +08:00
|
|
|
|
white-space: nowrap;
|
2026-08-11 00:49:41 +08:00
|
|
|
|
}
|
|
|
|
|
|
.detail-value {
|
|
|
|
|
|
color: var(--color-text-1);
|
|
|
|
|
|
white-space: pre-wrap;
|
|
|
|
|
|
word-break: break-word;
|
|
|
|
|
|
}
|
2026-08-11 19:15:53 +08:00
|
|
|
|
.detail-relation-value {
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 4px;
|
|
|
|
|
|
}
|
2026-08-11 00:49:41 +08:00
|
|
|
|
.json-value {
|
|
|
|
|
|
max-height: 360px;
|
|
|
|
|
|
margin: 0;
|
|
|
|
|
|
padding: 12px;
|
|
|
|
|
|
overflow: auto;
|
|
|
|
|
|
color: var(--color-text-1);
|
|
|
|
|
|
background: var(--color-fill-1);
|
|
|
|
|
|
border-radius: 6px;
|
|
|
|
|
|
white-space: pre-wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
@media (max-width: 1280px) {
|
|
|
|
|
|
.detail-grid {
|
|
|
|
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
@media (max-width: 760px) {
|
|
|
|
|
|
.detail-card :deep(.arco-card-header),
|
|
|
|
|
|
.detail-card :deep(.arco-card-body) {
|
|
|
|
|
|
padding-right: 16px;
|
|
|
|
|
|
padding-left: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.detail-grid {
|
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
gap: 4px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.detail-item,
|
|
|
|
|
|
.detail-item-wide {
|
|
|
|
|
|
grid-column: auto;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|