feat(设置): 优化信息层级与移动端体验
This commit is contained in:
parent
3f2151a72f
commit
d1f96859f3
@ -1,29 +1,14 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<section class="page-header">
|
||||
<div>
|
||||
<p class="page-kicker">System Configuration</p>
|
||||
<h2 class="page-title">系统设置</h2>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<div class="panel" style="min-width: 80px;">
|
||||
<p class="text-xs text-text-secondary">模块</p>
|
||||
<p class="mt-1 text-lg font-semibold text-text-primary">{{ tabs.length }}</p>
|
||||
</div>
|
||||
<div class="panel" style="min-width: 80px;">
|
||||
<p class="text-xs text-text-secondary">当前</p>
|
||||
<p class="mt-1 text-sm font-semibold text-text-primary">{{ activeTabLabel }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="overflow-x-auto">
|
||||
<section class="card !p-3">
|
||||
<div class="overflow-x-auto scroll-smooth">
|
||||
<div class="segmented-toggle min-w-max" style="padding: 2px;">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
ref="tabButtons"
|
||||
type="button"
|
||||
:data-settings-tab="tab.key"
|
||||
class="segmented-toggle__item"
|
||||
style="min-height: 32px; padding: 0 12px; font-size: 12px;"
|
||||
:class="{ 'is-active': activeTab === tab.key }"
|
||||
@ -53,7 +38,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import SiteSettings from './components/SiteSettings.vue'
|
||||
import UserManagement from './components/UserManagement.vue'
|
||||
@ -99,6 +84,7 @@ const tabs: Array<{ key: TabKey; label: string }> = [
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tabButtons = ref<HTMLButtonElement[]>([])
|
||||
|
||||
const normalizeTab = (value?: string | null): TabKey => {
|
||||
if (value === 'organization') {
|
||||
@ -110,7 +96,13 @@ const normalizeTab = (value?: string | null): TabKey => {
|
||||
|
||||
const initialTab = normalizeTab(route.query.tab as string | undefined)
|
||||
const activeTab = ref<TabKey>(tabs.some((t) => t.key === initialTab) ? initialTab : 'site')
|
||||
const activeTabLabel = computed(() => tabs.find((tab) => tab.key === activeTab.value)?.label || '站点信息')
|
||||
|
||||
const scrollActiveTabIntoView = async (behavior: ScrollBehavior = 'smooth') => {
|
||||
await nextTick()
|
||||
tabButtons.value
|
||||
.find((button) => button.dataset.settingsTab === activeTab.value)
|
||||
?.scrollIntoView({ behavior, block: 'nearest', inline: 'center' })
|
||||
}
|
||||
|
||||
const switchTab = (tab: TabKey) => {
|
||||
activeTab.value = tab
|
||||
@ -120,6 +112,7 @@ watch(
|
||||
() => activeTab.value,
|
||||
(tab) => {
|
||||
router.replace({ query: { ...route.query, tab } })
|
||||
scrollActiveTabIntoView()
|
||||
}
|
||||
)
|
||||
|
||||
@ -130,4 +123,8 @@ watch(
|
||||
activeTab.value = normalizeTab(tab as string)
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
scrollActiveTabIntoView('auto')
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -133,12 +133,23 @@
|
||||
</section>
|
||||
|
||||
<!-- 样本数据 -->
|
||||
<section v-if="previewData" class="card">
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<details v-if="previewData" class="card group">
|
||||
<summary class="flex cursor-pointer list-none items-center justify-between gap-3">
|
||||
<span class="min-w-0">
|
||||
<span class="block text-lg font-semibold text-text-primary">预览样本</span>
|
||||
<span class="mt-1 block text-sm text-text-tertiary">
|
||||
部门 {{ previewData.departments.sample.length }} 条 · 人员 {{ previewData.persons.sample.length }} 条
|
||||
</span>
|
||||
</span>
|
||||
<span class="badge badge-neutral group-open:hidden">按需展开</span>
|
||||
<span class="hidden badge badge-neutral group-open:inline-flex">收起样本</span>
|
||||
</summary>
|
||||
|
||||
<div class="mt-5 grid gap-6 border-t border-border pt-5 lg:grid-cols-2">
|
||||
<div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="section-title">部门样本</h3>
|
||||
<span class="text-sm text-text-tertiary">展示前 {{ previewData.departments.sample.length }} 条</span>
|
||||
<h4 class="text-sm font-semibold text-text-primary">部门样本</h4>
|
||||
<span class="text-xs text-text-tertiary">前 {{ previewData.departments.sample.length }} 条</span>
|
||||
</div>
|
||||
<div class="mt-4 space-y-3">
|
||||
<div
|
||||
@ -159,8 +170,8 @@
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 class="section-title">人员样本</h3>
|
||||
<span class="text-sm text-text-tertiary">展示前 {{ previewData.persons.sample.length }} 条</span>
|
||||
<h4 class="text-sm font-semibold text-text-primary">人员样本</h4>
|
||||
<span class="text-xs text-text-tertiary">前 {{ previewData.persons.sample.length }} 条</span>
|
||||
</div>
|
||||
<div class="mt-4 space-y-3">
|
||||
<div
|
||||
@ -185,28 +196,35 @@
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-text-tertiary">
|
||||
sys00-dept:{{ person.sourceDepartmentName || '-' }};sys00-mainDepts:{{ person.sourceMainDepartmentName || '-' }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-text-tertiary">
|
||||
主部门源ID:{{ person.sourceMainDepartmentId ?? '-' }};部门列表:{{ person.sourceDeptIds || '-' }}
|
||||
</p>
|
||||
|
||||
<details class="mt-3 border-t border-border pt-3">
|
||||
<summary class="cursor-pointer text-xs text-text-tertiary">查看来源字段</summary>
|
||||
<p class="mt-3 text-xs text-text-tertiary">
|
||||
sys00-dept:{{ person.sourceDepartmentName || '-' }};sys00-mainDepts:{{ person.sourceMainDepartmentName || '-' }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-text-tertiary">
|
||||
主部门源ID:{{ person.sourceMainDepartmentId ?? '-' }};部门列表:{{ person.sourceDeptIds || '-' }}
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</details>
|
||||
|
||||
<section class="card">
|
||||
<div class="list-header">
|
||||
<div>
|
||||
<h3 class="section-title">同步历史</h3>
|
||||
<p class="mt-1 text-sm text-text-tertiary">点击左侧任务可查看本次同步的变化明细。</p>
|
||||
<p class="mt-1 text-sm text-text-tertiary">先浏览执行记录,需要时再打开变化明细。</p>
|
||||
</div>
|
||||
<span class="badge badge-neutral">共 {{ historyTotal }} 次</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 grid gap-6 xl:grid-cols-[340px_minmax(0,1fr)]">
|
||||
<div
|
||||
class="mt-5 grid gap-6"
|
||||
:class="showHistoryDetails ? 'xl:grid-cols-[340px_minmax(0,1fr)]' : ''"
|
||||
>
|
||||
<div class="space-y-3">
|
||||
<div
|
||||
v-if="historyLoading && historyList.length === 0"
|
||||
@ -278,12 +296,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div v-if="showHistoryDetails" class="space-y-4">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h4 class="section-title">任务详情</h4>
|
||||
<span v-if="selectedJob" class="text-sm text-text-tertiary">
|
||||
{{ formatDateTime(selectedJob.created_at) }}
|
||||
</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<span v-if="selectedJob" class="text-sm text-text-tertiary">
|
||||
{{ formatDateTime(selectedJob.created_at) }}
|
||||
</span>
|
||||
<button type="button" class="btn-secondary text-xs" @click="handleCloseHistoryDetails">
|
||||
收起详情
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="detailLoading" class="panel text-sm text-text-tertiary">
|
||||
@ -471,6 +494,7 @@ const statusLoading = ref(false)
|
||||
const syncLoading = ref(false)
|
||||
const historyLoading = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const showHistoryDetails = ref(false)
|
||||
const previewData = ref<HrSyncPreview | null>(null)
|
||||
const runResult = ref<HrSyncResult | null>(null)
|
||||
const syncStatus = ref<HrSyncStatus | null>(null)
|
||||
@ -837,6 +861,7 @@ const handleSubmitChangeProcessing = async (change: HrSyncChangeItem) => {
|
||||
}
|
||||
|
||||
const handleSelectJob = async (jobId: number) => {
|
||||
showHistoryDetails.value = true
|
||||
await router.replace({
|
||||
query: {
|
||||
...route.query,
|
||||
@ -846,6 +871,12 @@ const handleSelectJob = async (jobId: number) => {
|
||||
})
|
||||
}
|
||||
|
||||
const handleCloseHistoryDetails = async () => {
|
||||
const query = { ...route.query }
|
||||
delete query.syncJobId
|
||||
await router.replace({ query: { ...query, tab: 'sync' } })
|
||||
}
|
||||
|
||||
const handlePrevHistoryPage = async () => {
|
||||
if (historyPage.value <= 1) return
|
||||
await fetchHistory(historyPage.value - 1)
|
||||
@ -892,10 +923,13 @@ watch(
|
||||
if (!routeJobId) {
|
||||
selectedJobId.value = null
|
||||
selectedJob.value = null
|
||||
showHistoryDetails.value = false
|
||||
resetChangeStates()
|
||||
return
|
||||
}
|
||||
|
||||
showHistoryDetails.value = true
|
||||
|
||||
if (selectedJob.value?.id === routeJobId) {
|
||||
selectedJobId.value = routeJobId
|
||||
return
|
||||
@ -910,12 +944,8 @@ onMounted(async () => {
|
||||
|
||||
const routeJobId = resolveRouteSyncJobId()
|
||||
if (routeJobId) {
|
||||
showHistoryDetails.value = true
|
||||
await fetchJobDetail(routeJobId)
|
||||
return
|
||||
}
|
||||
|
||||
if (historyList.value.length > 0) {
|
||||
await handleSelectJob(historyList.value[0].id)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="form-shell">
|
||||
<section class="form-hero">
|
||||
<div class="mt-5 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="field-stack">
|
||||
<label class="field-label">操作类型</label>
|
||||
<select v-model="filters.operationType" class="select" @change="handleSearch">
|
||||
@ -13,32 +13,9 @@
|
||||
<label class="field-label">操作人</label>
|
||||
<input v-model.trim="filters.username" type="text" class="input" placeholder="输入用户名或姓名" @keyup.enter="handleSearch" />
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">开始日期</label>
|
||||
<input v-model="filters.startDate" type="date" class="input" />
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">结束日期</label>
|
||||
<input v-model="filters.endDate" type="date" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 flex flex-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
||||
<div class="grid flex-1 gap-3 sm:grid-cols-3">
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">资产操作</p>
|
||||
<p class="page-meta__value">{{ logSummary.assetOps }}</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">借还流转</p>
|
||||
<p class="page-meta__value">{{ logSummary.borrowOps }}</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">风险处置</p>
|
||||
<p class="page-meta__value">{{ logSummary.riskOps }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center gap-3">
|
||||
<div class="form-inline-actions">
|
||||
<button class="btn-secondary" :disabled="loading" @click="handleReset">重置</button>
|
||||
<button class="btn-primary" :disabled="loading" @click="handleSearch">
|
||||
@ -46,12 +23,48 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="panel mt-4">
|
||||
<summary class="cursor-pointer text-sm text-text-secondary">
|
||||
日期筛选
|
||||
<span v-if="filters.startDate || filters.endDate" class="ml-2 text-xs text-accent-primary">已设置</span>
|
||||
</summary>
|
||||
<div class="mt-4 grid gap-4 border-t border-border pt-4 sm:grid-cols-2">
|
||||
<div class="field-stack">
|
||||
<label class="field-label">开始日期</label>
|
||||
<input v-model="filters.startDate" type="date" class="input" />
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">结束日期</label>
|
||||
<input v-model="filters.endDate" type="date" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="mt-4 grid gap-3 sm:grid-cols-3">
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">筛选结果总数</p>
|
||||
<p class="page-meta__value">{{ total }}</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">本页加载</p>
|
||||
<p class="page-meta__value">{{ logs.length }}</p>
|
||||
<p class="page-meta__subvalue">第 {{ currentPage }} / {{ totalPages }} 页</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">本页类型参考</p>
|
||||
<p class="mt-2 text-sm font-medium text-text-primary">
|
||||
资产 {{ logSummary.assetOps }} · 借还 {{ logSummary.borrowOps }} · 风险 {{ logSummary.riskOps }}
|
||||
</p>
|
||||
<p class="page-meta__subvalue">按操作名称归类,可能交叉</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h3 class="section-title">操作日志</h3>
|
||||
<span class="badge badge-neutral">已加载 {{ logs.length }} 条</span>
|
||||
<span class="badge badge-neutral">本页 {{ logs.length }} 条 / 共 {{ total }} 条</span>
|
||||
</div>
|
||||
|
||||
<div class="mobile-card-list md:hidden">
|
||||
@ -75,9 +88,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 rounded-2xl border border-border bg-bg-elevated px-4 py-4">
|
||||
<div class="panel mt-4">
|
||||
<p class="mobile-card__label">操作详情</p>
|
||||
<p class="mobile-card__value">{{ log.operation_details || '未记录详情' }}</p>
|
||||
<p class="mt-2 text-sm leading-6 text-text-secondary">
|
||||
{{ formatOperationSummary(log.operation_details) }}
|
||||
</p>
|
||||
<details v-if="log.operation_details" class="mt-3 border-t border-border pt-3">
|
||||
<summary class="cursor-pointer text-xs text-accent-primary">查看完整详情</summary>
|
||||
<pre class="mt-3 max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-xs leading-5 text-text-tertiary">{{ formatOperationDetail(log.operation_details) }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@ -108,7 +127,13 @@
|
||||
</td>
|
||||
<td class="font-mono text-sm text-text-secondary">{{ log.equipment_code || '-' }}</td>
|
||||
<td class="max-w-[360px]">
|
||||
<p class="truncate text-sm text-text-secondary">{{ log.operation_details || '-' }}</p>
|
||||
<p class="line-clamp-2 text-sm leading-6 text-text-secondary">
|
||||
{{ formatOperationSummary(log.operation_details) }}
|
||||
</p>
|
||||
<details v-if="log.operation_details" class="mt-2">
|
||||
<summary class="cursor-pointer text-xs text-accent-primary">查看详情</summary>
|
||||
<pre class="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-xs leading-5 text-text-tertiary">{{ formatOperationDetail(log.operation_details) }}</pre>
|
||||
</details>
|
||||
</td>
|
||||
<td class="font-mono text-sm text-text-tertiary">{{ log.ip_address || '-' }}</td>
|
||||
</tr>
|
||||
@ -132,7 +157,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { getOperationLogList, getOperationTypes } from '@/api/operationLog'
|
||||
|
||||
interface OperationLog {
|
||||
@ -151,7 +176,8 @@ interface OperationLog {
|
||||
const logs = ref<OperationLog[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const mobileLogMedia = window.matchMedia('(max-width: 767px)')
|
||||
const pageSize = ref(mobileLogMedia.matches ? 10 : 20)
|
||||
const loading = ref(false)
|
||||
const operationTypes = ref<string[]>([])
|
||||
|
||||
@ -172,6 +198,82 @@ const logSummary = computed(() => {
|
||||
return { assetOps, borrowOps, riskOps }
|
||||
})
|
||||
|
||||
const operationDetailLabels: Record<string, string> = {
|
||||
equipment_id: '资产 ID',
|
||||
equipment_code: '资产编码',
|
||||
equipment_name: '资产名称',
|
||||
person_id: '人员 ID',
|
||||
person_name: '使用者',
|
||||
person_nickname: '花名',
|
||||
department: '部门',
|
||||
department_name: '部门',
|
||||
from_department: '原部门',
|
||||
to_department: '目标部门',
|
||||
status: '状态',
|
||||
reason: '原因',
|
||||
notes: '备注',
|
||||
count: '数量',
|
||||
success_count: '成功',
|
||||
failed_count: '失败',
|
||||
before: '变更前',
|
||||
after: '变更后'
|
||||
}
|
||||
|
||||
const formatDetailKey = (key: string) => operationDetailLabels[key] || key.replace(/_/g, ' ')
|
||||
const truncateDetailText = (value: string, maxLength = 120) =>
|
||||
value.length > maxLength ? `${value.slice(0, maxLength)}…` : value
|
||||
|
||||
const formatDetailValue = (value: unknown): string => {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
if (Array.isArray(value)) {
|
||||
const summary = value.slice(0, 4).map(formatDetailValue).join('、')
|
||||
return `${summary}${value.length > 4 ? '、…' : ''}`
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
return Object.entries(value as Record<string, unknown>)
|
||||
.slice(0, 4)
|
||||
.map(([key, nestedValue]) => `${formatDetailKey(key)} ${formatDetailValue(nestedValue)}`)
|
||||
.join(',')
|
||||
}
|
||||
if (typeof value === 'boolean') return value ? '是' : '否'
|
||||
return truncateDetailText(String(value))
|
||||
}
|
||||
|
||||
const parseOperationDetail = (value: string | null) => {
|
||||
if (!value) {
|
||||
return { summary: '未记录详情', detail: '' }
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(value) as unknown
|
||||
if (Array.isArray(parsed)) {
|
||||
return {
|
||||
summary: parsed.length > 0 ? `共 ${parsed.length} 项:${parsed.slice(0, 3).map(formatDetailValue).join(';')}` : '空列表',
|
||||
detail: JSON.stringify(parsed, null, 2)
|
||||
}
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === 'object') {
|
||||
const entries = Object.entries(parsed as Record<string, unknown>)
|
||||
const summary = entries
|
||||
.slice(0, 4)
|
||||
.map(([key, detailValue]) => `${formatDetailKey(key)}:${formatDetailValue(detailValue)}`)
|
||||
.join(';')
|
||||
return {
|
||||
summary: `${summary || '已记录结构化详情'}${entries.length > 4 ? ';…' : ''}`,
|
||||
detail: JSON.stringify(parsed, null, 2)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 普通文本直接显示,不把解析失败当作页面错误。
|
||||
}
|
||||
|
||||
return { summary: truncateDetailText(value), detail: value }
|
||||
}
|
||||
|
||||
const formatOperationSummary = (value: string | null) => parseOperationDetail(value).summary
|
||||
const formatOperationDetail = (value: string | null) => parseOperationDetail(value).detail
|
||||
|
||||
const fetchLogs = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
@ -232,6 +334,14 @@ const handleNextPage = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleLogViewportChange = (event: MediaQueryListEvent) => {
|
||||
const nextPageSize = event.matches ? 10 : 20
|
||||
if (pageSize.value === nextPageSize) return
|
||||
pageSize.value = nextPageSize
|
||||
currentPage.value = 1
|
||||
fetchLogs()
|
||||
}
|
||||
|
||||
const formatDateTime = (dateStr: string) => {
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleString('zh-CN', {
|
||||
@ -254,7 +364,12 @@ const getTypeBadgeClass = (type: string) => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
mobileLogMedia.addEventListener('change', handleLogViewportChange)
|
||||
fetchOperationTypes()
|
||||
fetchLogs()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
mobileLogMedia.removeEventListener('change', handleLogViewportChange)
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -14,33 +14,38 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="panel space-y-4">
|
||||
<div class="space-y-1">
|
||||
<p class="text-sm font-semibold text-text-primary">Logo 素材目录</p>
|
||||
<p class="text-sm leading-6 text-text-secondary">
|
||||
默认素材跟随项目一起部署;如果需要自定义,直接把文件放到同一目录即可,系统会自动优先使用自定义文件。
|
||||
</p>
|
||||
</div>
|
||||
<details class="panel group">
|
||||
<summary class="flex cursor-pointer list-none items-center justify-between gap-3 text-sm font-medium text-text-secondary">
|
||||
<span>Logo 文件与部署路径</span>
|
||||
<span class="text-xs text-text-tertiary group-open:hidden">展开</span>
|
||||
<span class="hidden text-xs text-text-tertiary group-open:inline">收起</span>
|
||||
</summary>
|
||||
|
||||
<div class="field-grid">
|
||||
<div class="panel">
|
||||
<p class="text-xs uppercase tracking-wider text-text-tertiary">项目目录</p>
|
||||
<p class="mt-2 font-mono text-sm text-text-primary">frontend/public/branding/</p>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<p class="text-xs uppercase tracking-wider text-text-tertiary">服务器目录</p>
|
||||
<p class="mt-2 font-mono text-sm text-text-primary">/opt/equipment-management/frontend/public/branding/</p>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<p class="text-xs uppercase tracking-wider text-text-tertiary">默认文件</p>
|
||||
<p class="mt-2 text-sm text-text-primary">`logo.svg` / `favicon.svg`</p>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<p class="text-xs uppercase tracking-wider text-text-tertiary">自定义覆盖</p>
|
||||
<p class="mt-2 text-sm text-text-primary">`custom-logo.*` / `custom-favicon.*`</p>
|
||||
<div class="mt-4 space-y-4 border-t border-border pt-4">
|
||||
<p class="text-sm leading-6 text-text-secondary">
|
||||
默认素材随项目部署,自定义文件会优先覆盖默认 Logo 和浏览器图标。
|
||||
</p>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<p class="text-xs text-text-tertiary">项目目录</p>
|
||||
<p class="mt-1 break-all font-mono text-xs text-text-secondary">frontend/public/branding/</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-text-tertiary">服务器目录</p>
|
||||
<p class="mt-1 break-all font-mono text-xs text-text-secondary">/opt/equipment-management/frontend/public/branding/</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-text-tertiary">默认文件</p>
|
||||
<p class="mt-1 font-mono text-xs text-text-secondary">logo.svg / favicon.svg</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-text-tertiary">自定义覆盖</p>
|
||||
<p class="mt-1 font-mono text-xs text-text-secondary">custom-logo.* / custom-favicon.*</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="pt-2">
|
||||
<button
|
||||
|
||||
@ -1,53 +1,17 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 新增表单 -->
|
||||
<section class="card">
|
||||
<h3 class="section-title mb-4">新增用户</h3>
|
||||
<div class="field-grid">
|
||||
<div class="field-stack">
|
||||
<label class="field-label">用户名 *</label>
|
||||
<input v-model.trim="newUser.username" class="input" type="text" placeholder="登录用户名" />
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">姓名 *</label>
|
||||
<input v-model.trim="newUser.name" class="input" type="text" placeholder="显示姓名" />
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">密码 *</label>
|
||||
<input v-model="newUser.password" class="input" type="password" placeholder="初始密码" />
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">角色 *</label>
|
||||
<select v-model="newUser.role" class="select">
|
||||
<option value="user">普通用户</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">邮箱</label>
|
||||
<input v-model.trim="newUser.email" class="input" type="email" placeholder="邮箱(可选)" />
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">手机号</label>
|
||||
<input v-model.trim="newUser.phone" class="input" type="tel" placeholder="手机号(可选)" />
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn-primary text-xs sm:text-sm mt-4"
|
||||
:disabled="createLoading || !canCreate"
|
||||
@click="handleCreateUser"
|
||||
>
|
||||
{{ createLoading ? '创建中...' : '创建用户' }}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<!-- 列表 -->
|
||||
<section class="card">
|
||||
<div class="list-header">
|
||||
<h3 class="section-title">用户列表</h3>
|
||||
<button class="btn-secondary text-xs sm:text-sm" :disabled="listLoading" @click="fetchUsers">
|
||||
刷新
|
||||
</button>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<button class="btn-secondary text-xs sm:text-sm" :disabled="listLoading" @click="fetchUsers">
|
||||
刷新
|
||||
</button>
|
||||
<button ref="createUserTrigger" class="btn-primary text-xs sm:text-sm" @click="openCreate">
|
||||
新增用户
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 移动端卡片列表 -->
|
||||
@ -156,6 +120,107 @@
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<!-- 新增用户弹窗 -->
|
||||
<div v-if="showCreateUser" class="sheet-backdrop" @click.self="closeCreate">
|
||||
<form
|
||||
ref="createDialog"
|
||||
class="sheet-panel max-w-2xl"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="create-user-title"
|
||||
autocomplete="off"
|
||||
@keydown.esc="closeCreate"
|
||||
@keydown.tab="handleCreateTab"
|
||||
@submit.prevent="handleCreateUser"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h3 id="create-user-title" class="text-xl font-semibold text-text-primary">新增用户</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-icon"
|
||||
aria-label="关闭新增用户弹窗"
|
||||
:disabled="createLoading"
|
||||
@click="closeCreate"
|
||||
>
|
||||
<XMarkIcon class="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="mt-5 field-grid">
|
||||
<div class="field-stack">
|
||||
<label class="field-label">用户名 *</label>
|
||||
<input
|
||||
ref="createUsernameInput"
|
||||
v-model.trim="newUser.username"
|
||||
class="input"
|
||||
type="text"
|
||||
name="username"
|
||||
autocomplete="off"
|
||||
placeholder="登录用户名"
|
||||
/>
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">姓名 *</label>
|
||||
<input
|
||||
v-model.trim="newUser.name"
|
||||
class="input"
|
||||
type="text"
|
||||
name="name"
|
||||
autocomplete="off"
|
||||
placeholder="显示姓名"
|
||||
/>
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">密码 *</label>
|
||||
<input
|
||||
v-model="newUser.password"
|
||||
class="input"
|
||||
type="password"
|
||||
name="new-password"
|
||||
autocomplete="new-password"
|
||||
placeholder="初始密码(至少 6 位)"
|
||||
/>
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">角色 *</label>
|
||||
<select v-model="newUser.role" class="select">
|
||||
<option value="user">普通用户</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">邮箱</label>
|
||||
<input
|
||||
v-model.trim="newUser.email"
|
||||
class="input"
|
||||
type="email"
|
||||
name="email"
|
||||
autocomplete="off"
|
||||
placeholder="邮箱(可选)"
|
||||
/>
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">手机号</label>
|
||||
<input
|
||||
v-model.trim="newUser.phone"
|
||||
class="input"
|
||||
type="tel"
|
||||
name="tel"
|
||||
autocomplete="off"
|
||||
placeholder="手机号(可选)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button type="button" class="btn-secondary" :disabled="createLoading" @click="closeCreate">取消</button>
|
||||
<button type="submit" class="btn-primary" :disabled="createLoading || !canCreate">
|
||||
{{ createLoading ? '创建中...' : '创建用户' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 编辑弹窗 -->
|
||||
<div v-if="editingUser" class="sheet-backdrop">
|
||||
<div class="sheet-panel max-w-2xl">
|
||||
@ -202,7 +267,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, reactive, ref } from 'vue'
|
||||
import { XMarkIcon } from '@heroicons/vue/24/outline'
|
||||
import type { User } from '@/types'
|
||||
import {
|
||||
createUser,
|
||||
@ -218,6 +284,10 @@ const users = ref<(User & { status: 'active' | 'inactive'; last_login_at?: strin
|
||||
const listLoading = ref(false)
|
||||
const createLoading = ref(false)
|
||||
const updateLoading = ref(false)
|
||||
const showCreateUser = ref(false)
|
||||
const createUserTrigger = ref<HTMLButtonElement | null>(null)
|
||||
const createDialog = ref<HTMLFormElement | null>(null)
|
||||
const createUsernameInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const newUser = reactive({
|
||||
username: '',
|
||||
@ -265,6 +335,44 @@ const resetNewForm = () => {
|
||||
newUser.phone = ''
|
||||
}
|
||||
|
||||
const openCreate = async () => {
|
||||
resetNewForm()
|
||||
showCreateUser.value = true
|
||||
await nextTick()
|
||||
createUsernameInput.value?.focus()
|
||||
}
|
||||
|
||||
const closeCreate = async () => {
|
||||
if (createLoading.value) return
|
||||
showCreateUser.value = false
|
||||
resetNewForm()
|
||||
await nextTick()
|
||||
createUserTrigger.value?.focus()
|
||||
}
|
||||
|
||||
const handleCreateTab = (event: KeyboardEvent) => {
|
||||
const dialog = createDialog.value
|
||||
if (!dialog) return
|
||||
|
||||
const focusableElements = Array.from(
|
||||
dialog.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||
)
|
||||
).filter((element) => element.offsetParent !== null)
|
||||
|
||||
if (focusableElements.length === 0) return
|
||||
|
||||
const firstElement = focusableElements[0]
|
||||
const lastElement = focusableElements[focusableElements.length - 1]
|
||||
if (event.shiftKey && document.activeElement === firstElement) {
|
||||
event.preventDefault()
|
||||
lastElement.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === lastElement) {
|
||||
event.preventDefault()
|
||||
firstElement.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateUser = async () => {
|
||||
if (!canCreate.value) return
|
||||
try {
|
||||
@ -278,7 +386,10 @@ const handleCreateUser = async () => {
|
||||
phone: newUser.phone || undefined
|
||||
})
|
||||
alert('用户创建成功')
|
||||
showCreateUser.value = false
|
||||
resetNewForm()
|
||||
await nextTick()
|
||||
createUserTrigger.value?.focus()
|
||||
fetchUsers()
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '创建用户失败')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user