From 86b6383251e34e95e43a195a686dcca14686f29c Mon Sep 17 00:00:00 2001 From: Mplan Date: Thu, 1 Oct 2026 10:31:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E9=87=8D=E6=9E=84=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=8E=A7=E5=88=B6=E5=8F=B0=E5=B8=83=E5=B1=80=E5=B9=B6?= =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/admin/admin.css | 523 +++ src/features/admin/api.ts | 15 +- .../admin/components/AdminCloudManager.vue | 545 +++ .../components/AdminCloudTypesManager.vue | 176 + .../admin/components/AdminOverview.vue | 258 ++ .../admin/components/AdminUsersManager.vue | 324 ++ .../admin/composables/useAdminData.ts | 298 ++ .../admin/composables/useAdminViewport.ts | 12 + src/features/admin/models.ts | 50 + src/features/admin/views/AdminView.vue | 2945 ++++------------- tests/admin.test.mjs | 75 + 11 files changed, 3008 insertions(+), 2213 deletions(-) create mode 100644 src/features/admin/admin.css create mode 100644 src/features/admin/components/AdminCloudManager.vue create mode 100644 src/features/admin/components/AdminCloudTypesManager.vue create mode 100644 src/features/admin/components/AdminOverview.vue create mode 100644 src/features/admin/components/AdminUsersManager.vue create mode 100644 src/features/admin/composables/useAdminData.ts create mode 100644 src/features/admin/composables/useAdminViewport.ts create mode 100644 src/features/admin/models.ts create mode 100644 tests/admin.test.mjs diff --git a/src/features/admin/admin.css b/src/features/admin/admin.css new file mode 100644 index 0000000..0c7a597 --- /dev/null +++ b/src/features/admin/admin.css @@ -0,0 +1,523 @@ +.admin-shell { + display: grid; + grid-template-columns: 232px minmax(0, 1fr); + min-height: 100vh; + background: var(--oc-gradient-canvas); + transition: grid-template-columns 160ms ease; +} +.admin-shell--collapsed { + grid-template-columns: 76px minmax(0, 1fr); +} +.admin-sidebar { + position: sticky; + top: 0; + display: flex; + flex-direction: column; + height: 100vh; + overflow-y: auto; + border-right: 1px solid var(--oc-color-border); + background: white; +} +.admin-brand { + display: flex; + align-items: center; + gap: 12px; + min-height: 92px; + padding: 20px; +} +.admin-brand--collapsed { + justify-content: center; + padding: 16px 0; +} +.admin-navigation { + padding: 0 12px; +} +.admin-nav-group { + margin: 20px 12px 8px; + color: var(--oc-color-text-disabled); + font-size: 11px; + letter-spacing: 0.1em; +} +.admin-nav-group:first-child { + margin-top: 0; +} +.admin-nav-item { + position: relative; + display: flex; + align-items: center; + gap: 12px; + min-height: 44px; + padding: 10px 12px; + margin-bottom: 4px; + width: 100%; + border: 1px solid transparent; + color: var(--oc-color-text-muted); + font-size: 13px; + text-align: left; + transition: background 160ms ease; +} +.admin-nav-item:hover { + background: var(--oc-color-surface-muted); + color: var(--oc-color-text); +} +.admin-nav-item--active { + border-color: var(--color-teal-200); + background: var(--color-teal-50); + color: var(--color-teal-800); + font-weight: 600; + box-shadow: var(--oc-shadow-xs); +} +.admin-nav-item--active:hover { + background: var(--color-teal-50); + color: var(--color-teal-800); +} +.admin-nav-badge { + padding: 1px 5px; + border: 1px solid #fde68a; + background: #fffbeb; + color: #92400e; + font-size: 10px; + line-height: 16px; + font-variant-numeric: tabular-nums; +} +.admin-nav-badge--compact { + position: absolute; + right: -4px; + top: -2px; +} +.admin-workspace { + min-width: 0; +} +.admin-topbar { + position: sticky; + top: 0; + z-index: 30; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + height: 68px; + padding: 0 32px; + border-bottom: 1px solid var(--oc-color-border); + background: rgb(255 255 255 / 96%); +} +.admin-mobile-toggle { + display: none; +} +.admin-account { + display: flex; + align-items: center; + gap: 10px; + padding: 4px; +} +.admin-user-avatar { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + flex-shrink: 0; + border: 1px solid var(--color-teal-200); + background: var(--color-teal-50); + color: var(--color-teal-800); + font-size: 13px; + font-weight: 600; +} +.admin-content { + width: 100%; + max-width: 1600px; + margin: 0 auto; + padding: 30px 32px; +} +.admin-page-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + margin-bottom: 28px; +} +.admin-stat-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 20px; +} +.admin-stat { + min-width: 0; + padding: 20px; + border: 1px solid var(--oc-color-border); + background: white; + text-align: left; + transition: transform 160ms ease; +} +.admin-stat:hover { + transform: translate(-1px, -1px); +} +.admin-stat-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 38px; + height: 38px; + flex-shrink: 0; + border: 1px solid var(--oc-color-border); + background: var(--oc-color-surface-muted); + color: var(--oc-color-text-muted); +} +.admin-stat-icon--sky { + background: #f0f9ff; + color: #0369a1; + border-color: #bae6fd; +} +.admin-stat-icon--teal { + background: var(--color-teal-50); + color: var(--color-teal-700); + border-color: var(--color-teal-200); +} +.admin-stat-icon--amber { + background: #fffbeb; + color: #b45309; + border-color: #fde68a; +} +.admin-priority { + display: flex; + align-items: center; + gap: 16px; + padding: 20px; + border: 1px solid var(--oc-color-border); + background: white; +} +.admin-priority--pending { + border-color: #fde68a; + background: #fffdf7; +} +.admin-overview-grid { + display: grid; + grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); + gap: 24px; +} +.admin-panel { + min-width: 0; + border: 1px solid var(--oc-color-border); + background: white; +} +.admin-panel-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + padding: 20px; + border-bottom: 1px solid #f1f5f9; +} +.admin-panel-heading h2 { + color: var(--oc-color-text); + font-size: 15px; + font-weight: 600; +} +.admin-panel-heading p { + margin-top: 5px; + color: var(--oc-color-text-muted); + font-size: 12px; + line-height: 1.7; +} +.admin-chart { + display: flex; + justify-content: space-between; + gap: 14px; + padding: 30px 28px 24px; +} +.admin-chart-column { + display: flex; + flex: 1; + min-width: 0; + flex-direction: column; + align-items: center; + gap: 10px; +} +.admin-chart-track { + position: relative; + width: 100%; + height: 156px; + background: repeating-linear-gradient( + to top, + transparent 0, + transparent 38px, + #f1f5f9 38px, + #f1f5f9 39px + ); +} +.admin-chart-bar { + position: absolute; + bottom: 0; + left: 15%; + width: 70%; + border-top: 2px solid #7dd3fc; + background: #e0f2fe; +} +.admin-chart-bar--today { + border-color: var(--color-teal-400); + background: var(--color-teal-200); +} +.admin-recent-row { + display: flex; + align-items: center; + gap: 16px; + width: 100%; + padding: 14px 20px; + border-bottom: 1px solid #f1f5f9; + text-align: left; +} +.admin-recent-row:last-child { + border-bottom: 0; +} +.admin-recent-row:hover { + background: var(--oc-color-surface-muted); +} +.admin-filters { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 16px; + padding: 20px; +} +.admin-filters > div { + flex: 1 1 144px; + min-width: 0; +} +.admin-filters > .admin-filter-search { + flex: 2 1 280px; +} +.admin-filters > .self-end { + flex: 0 0 auto; +} +.admin-filters .oc-field-control { + width: 100%; + height: 34px; + min-width: 0; + padding: 5px 10px; + font-size: 13px; +} +.admin-filters .oc-field-label { + margin-bottom: 6px; + font-size: 12px; +} +.admin-table-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 12px; + padding: 12px 20px; + border-top: 1px solid #f1f5f9; + border-bottom: 1px solid #f1f5f9; + background: var(--oc-color-surface-muted); +} +.admin-sort { + max-width: 100%; + min-width: 0; + border: 1px solid var(--oc-color-border); + padding: 5px 8px; + color: var(--oc-color-text-secondary); + background: white; + font-size: 12px; +} +.admin-batch-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 12px; + padding: 14px 20px; + background: #f0f9ff; + color: #075985; + border-bottom: 1px solid #bae6fd; +} +.admin-data-table { + min-width: 0; + width: 100%; +} +.admin-data-table .n-data-table-th { + font-size: 12px; + font-weight: 500; +} +.admin-data-table .n-data-table-td { + font-size: 13px; +} +.admin-thumbnail { + display: block; + width: 56px; + height: 40px; + border: 1px solid var(--oc-color-border); + background: #f0f9ff; +} +.admin-thumbnail img { + width: 100%; + height: 100%; + object-fit: cover; +} +.admin-cloud-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 20px; + padding: 20px; +} +.admin-pagination { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 16px; + padding: 16px 20px; + border-top: 1px solid #f1f5f9; +} +.admin-form-modal.n-card { + width: min(540px, calc(100vw - 32px)); + max-height: calc(100dvh - 32px); + overflow-y: auto; + border: 1px solid var(--oc-color-border); + box-shadow: var(--oc-shadow-xl); +} +.admin-form-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + border-top: 1px solid var(--oc-color-border); + padding-top: 20px; +} +.admin-shell button:focus-visible, +.admin-shell a:focus-visible, +.admin-sort:focus-visible { + outline: 2px solid #38bdf8; + outline-offset: 3px; +} +.admin-shell input[type='checkbox'] { + accent-color: var(--oc-color-primary); + width: 15px; + height: 15px; +} +@media (min-width: 1800px) { + .admin-content { + padding-top: 40px; + } +} +@media (max-width: 1199px) { + .admin-content { + padding: 24px; + } + .admin-topbar { + padding: 0 24px; + } + .admin-stat-grid { + gap: 16px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .admin-cloud-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} +@media (max-width: 899px) { + .admin-shell, + .admin-shell--collapsed { + display: block; + } + .admin-sidebar, + .admin-desktop-toggle { + display: none; + } + .admin-mobile-toggle { + display: inline-flex; + } +} +@media (max-width: 639px) { + .admin-topbar { + height: 60px; + gap: 8px; + padding: 0 16px; + } + .admin-content { + padding: 24px 16px; + } + .admin-page-heading { + align-items: flex-start; + flex-direction: column; + gap: 12px; + margin-bottom: 24px; + } + .admin-stat-grid { + gap: 12px; + } + .admin-stat { + padding: 14px; + } + .admin-stat-icon { + width: 30px; + height: 30px; + } + .admin-stat .oc-stat-label { + font-size: 11px; + letter-spacing: 0.04em; + } + .admin-stat .text-3xl { + font-size: 26px; + } + .admin-overview-grid { + grid-template-columns: minmax(0, 1fr); + } + .admin-priority { + align-items: flex-start; + flex-wrap: wrap; + padding: 16px; + } + .admin-priority > .n-button { + margin-left: 46px; + } + .admin-panel-heading { + align-items: flex-start; + flex-wrap: wrap; + padding: 16px; + } + .admin-filters { + gap: 12px; + padding: 16px; + } + .admin-filters > div { + flex-basis: calc(50% - 6px); + } + .admin-filters > .admin-filter-search { + flex-basis: 100%; + } + .admin-filters > .self-end { + flex-basis: 100%; + } + .admin-table-toolbar, + .admin-batch-toolbar { + padding: 12px 16px; + } + .admin-pagination { + padding: 16px; + } + .admin-cloud-grid { + grid-template-columns: minmax(0, 1fr); + padding: 16px; + } + .admin-chart { + gap: 8px; + padding: 24px 16px; + } + .admin-recent-row { + gap: 10px; + padding: 14px 16px; + } +} +@media (max-width: 639px) { + .admin-priority { + display: grid; + grid-template-columns: 30px minmax(0, 1fr); + } + .admin-priority > .n-button { + grid-column: 2; + margin-left: 0; + justify-self: start; + } + .admin-topbar .admin-breadcrumb-separator { + display: none; + } +} diff --git a/src/features/admin/api.ts b/src/features/admin/api.ts index a9e6ca0..6277579 100644 --- a/src/features/admin/api.ts +++ b/src/features/admin/api.ts @@ -10,6 +10,7 @@ import type { CloudTypeCreateResponse, ReviewCloudsBody, SetUserRoleBody, + CreateUserBody, } from '@/types/api-contract' export async function listUsers() { @@ -25,16 +26,28 @@ export function listAdminClouds() { }) } -export async function reviewClouds(ids: string[], reviewStatus: ReviewCloudsBody['review_status']) { +export async function reviewClouds( + ids: string[], + reviewStatus: ReviewCloudsBody['review_status'], + onBatchComplete?: (ids: string[]) => void, +) { for (let offset = 0; offset < ids.length; offset += 100) { const body: ReviewCloudsBody = { cloud_ids: ids.slice(offset, offset + 100), review_status: reviewStatus, } await apiRequest('/admin/clouds/review', { method: 'POST', body }) + onBatchComplete?.(body.cloud_ids) } } +export function createUser(body: CreateUserBody) { + return apiRequest<{ message: string; user_id: string; email_sent: boolean }>('/admin/users', { + method: 'POST', + body, + }) +} + export function deleteAdminCloud(id: string) { return apiRequest(`/admin/clouds/${encodeURIComponent(id)}`, { method: 'DELETE', diff --git a/src/features/admin/components/AdminCloudManager.vue b/src/features/admin/components/AdminCloudManager.vue new file mode 100644 index 0000000..e936a20 --- /dev/null +++ b/src/features/admin/components/AdminCloudManager.vue @@ -0,0 +1,545 @@ + + + diff --git a/src/features/admin/components/AdminCloudTypesManager.vue b/src/features/admin/components/AdminCloudTypesManager.vue new file mode 100644 index 0000000..dcb7111 --- /dev/null +++ b/src/features/admin/components/AdminCloudTypesManager.vue @@ -0,0 +1,176 @@ + + + diff --git a/src/features/admin/components/AdminOverview.vue b/src/features/admin/components/AdminOverview.vue new file mode 100644 index 0000000..10ba907 --- /dev/null +++ b/src/features/admin/components/AdminOverview.vue @@ -0,0 +1,258 @@ + + + diff --git a/src/features/admin/components/AdminUsersManager.vue b/src/features/admin/components/AdminUsersManager.vue new file mode 100644 index 0000000..e7050ee --- /dev/null +++ b/src/features/admin/components/AdminUsersManager.vue @@ -0,0 +1,324 @@ + + + diff --git a/src/features/admin/composables/useAdminData.ts b/src/features/admin/composables/useAdminData.ts new file mode 100644 index 0000000..3368bfb --- /dev/null +++ b/src/features/admin/composables/useAdminData.ts @@ -0,0 +1,298 @@ +import { computed, ref } from 'vue' +import { useDialog, useMessage, type ButtonProps } from 'naive-ui' +import { useAuthStore } from '@/features/auth/stores/auth' +import { useCloudsStore } from '@/features/clouds/stores/clouds' +import { useEncyclopediaStore } from '@/features/encyclopedia/stores/encyclopedia' +import { listCloudTypeRecords } from '@/features/clouds/api' +import { toCloudType } from '@/lib/api-adapters' +import { + listUsers, + listAdminClouds, + reviewClouds, + deleteAdminCloud, + setUserRole, + setUserPassword, + createUser, + createCloudType, + updateCloudType, + deleteCloudTypeRecord, +} from '@/features/admin/api' +import type { CloudDetail, AuthUser } from '@/types/view-models' +import type { CloudTypeResponse, CreateCloudTypeBody, CreateUserBody } from '@/types/api-contract' +import type { ReviewStatus } from '@/features/admin/models' + +export function useAdminData() { + const auth = useAuthStore() + const cloudsStore = useCloudsStore() + const encyclopediaStore = useEncyclopediaStore() + const message = useMessage() + const dialog = useDialog() + const loading = ref(false) + const busy = ref(false) + const error = ref('') + const loaded = ref(false) + const refreshedAt = ref(null) + const users = ref([]) + const clouds = ref([]) + const cloudTypes = ref([]) + const pendingCount = computed( + () => clouds.value.filter(item => item.status === 'pending').length, + ) + const errorText = (reason: unknown) => + reason instanceof Error ? reason.message : '操作失败,请稍后重试' + + async function fetchTypes() { + cloudTypes.value = await listCloudTypeRecords() + const normalized = cloudTypes.value.map(toCloudType) + cloudsStore.setCloudTypes(normalized) + encyclopediaStore.setCloudTypes(normalized) + } + + async function refresh() { + if (loading.value || busy.value) return + loading.value = true + error.value = '' + const results = await Promise.allSettled([ + listUsers().then(rows => { + users.value = rows + }), + listAdminClouds().then(rows => { + clouds.value = rows + }), + fetchTypes(), + ]) + const failed = results.filter(result => result.status === 'rejected') + if (failed.length) { + error.value = failed.map(result => errorText(result.reason)).join(';') + } else { + loaded.value = true + refreshedAt.value = new Date().toISOString() + } + loading.value = false + } + + async function mutate(action: () => Promise) { + if (busy.value || loading.value) return false + busy.value = true + error.value = '' + try { + await action() + return true + } catch (reason) { + error.value = errorText(reason) + message.error(error.value) + return false + } finally { + busy.value = false + } + } + + function confirm( + title: string, + content: string, + action: () => Promise, + danger = false, + ) { + if (busy.value || loading.value) return + const positiveButtonProps: ButtonProps & { class: string } = { + type: 'default', + class: `oc-panel-button oc-panel-button--${danger ? 'danger' : 'teal'}`, + } + const negativeButtonProps: ButtonProps & { class: string } = { + type: 'default', + class: 'oc-panel-button oc-panel-button--neutral', + } + const instance = dialog.warning({ + title, + content, + positiveText: '确认', + negativeText: '取消', + maskClosable: false, + closeOnEsc: false, + positiveButtonProps, + negativeButtonProps, + zIndex: 2000, + onPositiveClick: async () => { + instance.loading = true + try { + return await action() + } finally { + instance.loading = false + } + }, + onNegativeClick: () => !busy.value, + onClose: () => !busy.value, + }) + } + + function review(ids: string[], status: Exclude) { + const targets = [...new Set(ids)].filter(id => + clouds.value.some(item => item.id === id && item.status !== status), + ) + if (!targets.length) return + confirm( + status === 'approved' ? '通过内容审核' : '拒绝内容审核', + `确认将 ${targets.length} 张云图设为${status === 'approved' ? '已通过' : '已拒绝'}?${status === 'approved' ? '通过后,未隐藏的云图将公开展示。' : '拒绝后,云图将不再公开展示。'}`, + () => + mutate(async () => { + // Apply each completed batch locally so partial failures remain visible. + await reviewClouds(targets, status, batch => { + const completed = new Set(batch) + clouds.value = clouds.value.map(item => + completed.has(item.id) ? { ...item, status } : item, + ) + }) + message.success( + `已${status === 'approved' ? '通过' : '拒绝'} ${targets.length} 张云图`, + ) + }), + status === 'rejected', + ) + } + + function removeClouds(items: CloudDetail[]) { + if (!items.length) return + confirm( + '删除云图', + `确认永久删除 ${items.length} 张云图及其图片文件?此操作无法撤销。`, + () => + mutate(async () => { + let completed = 0 + for (const item of items) { + try { + await deleteAdminCloud(item.id) + clouds.value = clouds.value.filter(row => row.id !== item.id) + users.value = users.value.map(user => + user.id === item.user_id + ? { + ...user, + cloud_count: Math.max(0, user.cloud_count - 1), + received_like_count: Math.max( + 0, + user.received_like_count - item.received_like_count, + ), + } + : user, + ) + completed++ + } catch (reason) { + throw new Error( + `已删除 ${completed} 张,剩余 ${items.length - completed} 张未删除:${errorText(reason)}`, + { cause: reason }, + ) + } + } + message.success(`已删除 ${completed} 张云图`) + }), + true, + ) + } + + function changeRole(user: AuthUser) { + if (user.id === auth.user?.id) return + const role = user.role === 'admin' ? 'user' : 'admin' + confirm( + '调整用户角色', + `确认将「${user.username}」设为${role === 'admin' ? '管理员' : '普通用户'}?该用户的现有登录状态将失效。`, + () => + mutate(async () => { + await setUserRole(user.id, role) + users.value = users.value.map(row => + row.id === user.id ? { ...row, role } : row, + ) + message.success('用户角色已更新') + }), + ) + } + + function resetPassword(id: string, password: string) { + return mutate(async () => { + await setUserPassword(id, password) + message.success('密码已重置,该用户需要重新登录') + if (id === auth.user?.id) { + auth.user = null + auth.profile = null + } + }) + } + + function addUser(body: CreateUserBody) { + return mutate(async () => { + const result = await createUser(body) + if (result.email_sent) message.success('用户已创建,验证邮件已发送') + else message.warning('用户已创建,验证邮件发送失败,可在登录页重发') + // Creation succeeded: a refresh failure must not invite duplicate submission. + try { + users.value = await listUsers() + } catch { + error.value = '用户已创建,列表刷新失败,请点击刷新数据' + } + }) + } + + function saveType(id: number | null, body: CreateCloudTypeBody) { + return mutate(async () => { + if (id === null) await createCloudType(body) + else await updateCloudType(id, body) + message.success(id === null ? '云类型已创建' : '云类型已更新') + try { + await fetchTypes() + clouds.value = clouds.value.map(item => { + const type = cloudTypes.value.find(type => type.id === item.cloud_type_id) + return type + ? { + ...item, + cloud_type_name: type.name, + cloud_type_rarity: type.rarity, + cloud_type: { id: type.id, name: type.name, rarity: type.rarity }, + } + : item + }) + } catch { + error.value = '云类型已保存,列表刷新失败,请点击刷新数据' + } + }) + } + + function removeType(type: CloudTypeResponse) { + const count = clouds.value.filter(item => item.cloud_type_id === type.id).length + if (count) { + message.warning(`该类型仍被 ${count} 张云图引用,无法删除`) + return + } + confirm( + '删除云类型', + `确认删除「${type.name}」?此操作无法撤销。`, + () => + mutate(async () => { + await deleteCloudTypeRecord(type.id) + cloudTypes.value = cloudTypes.value.filter(row => row.id !== type.id) + const normalized = cloudTypes.value.map(toCloudType) + cloudsStore.setCloudTypes(normalized) + encyclopediaStore.setCloudTypes(normalized) + message.success('云类型已删除') + }), + true, + ) + } + + return { + loading, + busy, + loaded, + error, + refreshedAt, + users, + clouds, + cloudTypes, + pendingCount, + refresh, + review, + removeClouds, + changeRole, + resetPassword, + addUser, + saveType, + removeType, + } +} diff --git a/src/features/admin/composables/useAdminViewport.ts b/src/features/admin/composables/useAdminViewport.ts new file mode 100644 index 0000000..dd418c3 --- /dev/null +++ b/src/features/admin/composables/useAdminViewport.ts @@ -0,0 +1,12 @@ +import { onUnmounted, ref } from 'vue' + +export function useAdminViewport() { + const query = window.matchMedia('(max-width: 639px)') + const compact = ref(query.matches) + const update = (event: MediaQueryListEvent) => { + compact.value = event.matches + } + query.addEventListener('change', update) + onUnmounted(() => query.removeEventListener('change', update)) + return compact +} diff --git a/src/features/admin/models.ts b/src/features/admin/models.ts new file mode 100644 index 0000000..ae9f41f --- /dev/null +++ b/src/features/admin/models.ts @@ -0,0 +1,50 @@ +import type { CloudDetail } from '@/types/view-models' + +export type AdminSection = 'dashboard' | 'review' | 'images' | 'users' | 'cloud-types' +export type ReviewStatus = CloudDetail['status'] + +export const statusMeta = { + pending: { label: '待审核', type: 'warning' }, + approved: { label: '已通过', type: 'success' }, + rejected: { label: '已拒绝', type: 'error' }, +} as const + +export function formatDateTime(value: string | null) { + if (!value) return '未记录' + const date = new Date(value) + return Number.isNaN(date.getTime()) + ? '未记录' + : date.toLocaleString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) +} + +export function visibilityLabel(cloud: Pick) { + if (cloud.is_hidden) return '用户隐藏' + return cloud.status === 'approved' ? '公开可见' : '尚未公开' +} + +export function serializeCsv(headers: string[], rows: unknown[][]) { + const escape = (value: unknown) => { + let text = String(value ?? '') + // Spreadsheet programs interpret these prefixes as formulas, even inside quoted cells. + if (/^[\s]*[=+@-]/.test(text) || /^[\t\r\n]/.test(text)) text = `'${text}` + return `"${text.replaceAll('"', '""')}"` + } + return '\uFEFF' + [headers, ...rows].map(row => row.map(escape).join(',')).join('\r\n') +} + +export function exportCsv(name: string, headers: string[], rows: unknown[][]) { + const url = URL.createObjectURL( + new Blob([serializeCsv(headers, rows)], { type: 'text/csv;charset=utf-8;' }), + ) + const link = document.createElement('a') + link.href = url + link.download = `${name}-${new Date().toISOString().slice(0, 10)}.csv` + link.click() + setTimeout(() => URL.revokeObjectURL(url), 1000) +} diff --git a/src/features/admin/views/AdminView.vue b/src/features/admin/views/AdminView.vue index 88445fb..f191ace 100644 --- a/src/features/admin/views/AdminView.vue +++ b/src/features/admin/views/AdminView.vue @@ -1,2338 +1,859 @@ - - diff --git a/tests/admin.test.mjs b/tests/admin.test.mjs new file mode 100644 index 0000000..27c1f1e --- /dev/null +++ b/tests/admin.test.mjs @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict' +import { afterEach, mock, test } from 'node:test' +import { createUser, reviewClouds } from '../src/features/admin/api.ts' +import { serializeCsv, visibilityLabel } from '../src/features/admin/models.ts' + +afterEach(() => mock.restoreAll()) + +test('admin review preserves completed batches when a later batch fails', async () => { + const completed = [] + let call = 0 + mock.method(globalThis, 'fetch', async (url, options) => { + assert.equal(new URL(url).pathname, '/admin/clouds/review') + assert.equal(options.credentials, 'include') + const body = JSON.parse(options.body) + assert.equal(body.review_status, 'approved') + assert.ok(body.cloud_ids.length <= 100) + return ++call === 1 + ? Response.json({ message: '成功' }) + : Response.json({ message: '服务暂不可用' }, { status: 503 }) + }) + const ids = Array.from({ length: 101 }, (_, index) => `cloud-${index}`) + await assert.rejects( + reviewClouds(ids, 'approved', batch => completed.push(...batch)), + /服务暂不可用/, + ) + assert.deepEqual(completed, ids.slice(0, 100)) + assert.equal(call, 2) +}) + +test('admin account creation returns email delivery status without retrying creation', async () => { + const body = { + username: '观云者', + email: 'observer@example.test', + password: 'test-password', + role: 'user', + } + const fetch = mock.method(globalThis, 'fetch', async (url, options) => { + assert.equal(new URL(url).pathname, '/admin/users') + assert.equal(options.method, 'POST') + assert.equal(options.credentials, 'include') + assert.deepEqual(JSON.parse(options.body), body) + return Response.json( + { message: '已创建,邮件发送失败', user_id: 'new-user', email_sent: false }, + { status: 201 }, + ) + }) + assert.equal((await createUser(body)).email_sent, false) + assert.equal(fetch.mock.callCount(), 1) +}) + +test('CSV exports escape untrusted text, formulas, quotes and multiline descriptions', () => { + const csv = serializeCsv( + ['用户名', '说明'], + [ + ['=HYPERLINK("test")', '第一行\n第二行'], + [' @SUM(1)', '云,"雨"'], + ['-1+2', '\t=1'], + ], + ) + assert.ok(csv.startsWith('\uFEFF')) + assert.ok(csv.includes('"\'=HYPERLINK(""test"")"')) + assert.ok(csv.includes('"\' @SUM(1)"')) + assert.ok(csv.includes('"\'-1+2"')) + assert.ok(csv.includes('"\'\t=1"')) + assert.ok(csv.includes('"云,""雨"""')) + assert.ok(csv.includes('"第一行\n第二行"')) +}) + +test('pending and rejected images are never labeled publicly visible', () => { + for (const status of ['pending', 'rejected']) { + assert.equal(visibilityLabel({ status, is_hidden: false }), '尚未公开') + } + assert.equal(visibilityLabel({ status: 'approved', is_hidden: false }), '公开可见') + assert.equal(visibilityLabel({ status: 'approved', is_hidden: true }), '用户隐藏') +})