重组前端功能目录并统一代码规范工具链 (#10)

Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
2026-09-29 18:09:38 +08:00
parent 720ad29f06
commit 0af0256c97
101 changed files with 14644 additions and 10309 deletions
-16
View File
@@ -1,16 +0,0 @@
import AMapLoader from '@amap/amap-jsapi-loader'
export function loadAMap() {
return AMapLoader.load({
key: import.meta.env.VITE_AMAP_KEY,
version: '2.0',
plugins: [
'AMap.Scale',
'AMap.ToolBar',
'AMap.ControlBar',
'AMap.Geolocation',
'AMap.Marker',
'AMap.InfoWindow',
],
})
}
+142 -136
View File
@@ -1,9 +1,9 @@
import type {
ApiCloud,
AuthUser,
BackendCloudInfo,
BackendCloudType,
BackendUserProfile,
ApiCloud,
AuthUser,
BackendCloudInfo,
BackendCloudType,
BackendUserProfile,
} from '@/types/api'
import type { CloudType } from '@/types/database'
@@ -13,185 +13,191 @@ export const API_URL = (import.meta.env.VITE_API_URL || DEFAULT_API_URL).replace
export const AUTH_EXPIRED_EVENT = 'opencloud:auth-expired'
export class ApiError extends Error {
status: number
detail: unknown
status: number
detail: unknown
constructor(message: string, status: number, detail?: unknown) {
super(message)
this.name = 'ApiError'
this.status = status
this.detail = detail
}
constructor(message: string, status: number, detail?: unknown) {
super(message)
this.name = 'ApiError'
this.status = status
this.detail = detail
}
}
interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
body?: BodyInit | Record<string, unknown> | null
auth?: boolean
// Kept for callers that explicitly disable retries. Cookie sessions do not
// use a refresh-and-retry flow, so this option is intentionally ignored.
retry?: boolean
body?: BodyInit | Record<string, unknown> | null
auth?: boolean
}
function isJsonBody(body: ApiRequestOptions['body']): body is Record<string, unknown> {
return !!body
&& typeof body === 'object'
&& !(body instanceof FormData)
&& !(body instanceof Blob)
&& !(body instanceof URLSearchParams)
&& !(body instanceof ArrayBuffer)
return (
!!body &&
typeof body === 'object' &&
!(body instanceof FormData) &&
!(body instanceof Blob) &&
!(body instanceof URLSearchParams) &&
!(body instanceof ArrayBuffer)
)
}
function validationMessage(value: unknown) {
if (typeof value !== 'string') return ''
if (typeof value !== 'string') return ''
try {
const issues = JSON.parse(value) as unknown
if (!Array.isArray(issues)) return value
const messages = issues
.map(issue => issue && typeof issue === 'object' && 'message' in issue ? String(issue.message) : '')
.filter(Boolean)
return messages.join(';') || value
} catch {
return value
}
try {
const issues = JSON.parse(value) as unknown
if (!Array.isArray(issues)) return value
const messages = issues
.map(issue =>
issue && typeof issue === 'object' && 'message' in issue
? String(issue.message)
: '',
)
.filter(Boolean)
return messages.join(';') || value
} catch {
return value
}
}
function errorMessage(payload: unknown, fallback: string) {
if (!payload || typeof payload !== 'object') return fallback
if (!payload || typeof payload !== 'object') return fallback
if ('detail' in payload) {
const detail = payload.detail
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
const messages = detail
.map(item => item && typeof item === 'object' && 'msg' in item ? String(item.msg) : '')
.filter(Boolean)
if (messages.length) return messages.join(';')
if ('detail' in payload) {
const detail = payload.detail
if (typeof detail === 'string') return detail
if (Array.isArray(detail)) {
const messages = detail
.map(item =>
item && typeof item === 'object' && 'msg' in item ? String(item.msg) : '',
)
.filter(Boolean)
if (messages.length) return messages.join(';')
}
}
}
if ('error' in payload) {
const error = payload.error
if (typeof error === 'string') return error
if (error && typeof error === 'object' && 'message' in error) {
return validationMessage(error.message) || fallback
if ('error' in payload) {
const error = payload.error
if (typeof error === 'string') return error
if (error && typeof error === 'object' && 'message' in error) {
return validationMessage(error.message) || fallback
}
}
}
return fallback
return fallback
}
async function readPayload(response: Response) {
const text = await response.text()
if (!text) return undefined
const text = await response.text()
if (!text) return undefined
const contentType = response.headers.get('content-type') || ''
if (!contentType.includes('json')) return text
const contentType = response.headers.get('content-type') || ''
if (!contentType.includes('json')) return text
try {
return JSON.parse(text) as unknown
} catch {
return text
}
try {
return JSON.parse(text) as unknown
} catch {
return text
}
}
async function toApiError(response: Response) {
const payload = await readPayload(response)
const fallback = typeof payload === 'string' && payload.trim()
? payload.trim()
: `请求失败(${response.status})`
const payload = await readPayload(response)
const fallback =
typeof payload === 'string' && payload.trim()
? payload.trim()
: `请求失败(${response.status})`
return new ApiError(errorMessage(payload, fallback), response.status, payload)
return new ApiError(errorMessage(payload, fallback), response.status, payload)
}
async function request<T>(path: string, options: ApiRequestOptions = {}) {
const { body, auth = true, retry: _retry, ...requestOptions } = options
const headers = new Headers(requestOptions.headers)
let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined
const { body, auth = true, ...requestOptions } = options
const headers = new Headers(requestOptions.headers)
let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined
if (isJsonBody(body)) {
headers.set('Content-Type', 'application/json')
requestBody = JSON.stringify(body)
}
if (isJsonBody(body)) {
headers.set('Content-Type', 'application/json')
requestBody = JSON.stringify(body)
}
const response = await fetch(`${API_URL}${path}`, {
...requestOptions,
body: requestBody,
headers,
credentials: 'include',
})
const response = await fetch(`${API_URL}${path}`, {
...requestOptions,
body: requestBody,
headers,
credentials: 'include',
})
if (response.status === 401 && auth && typeof window !== 'undefined') {
window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT))
}
if (response.status === 401 && auth && typeof window !== 'undefined') {
window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT))
}
if (!response.ok) throw await toApiError(response)
return await readPayload(response) as T
if (!response.ok) throw await toApiError(response)
return (await readPayload(response)) as T
}
export function apiRequest<T>(path: string, options: ApiRequestOptions = {}) {
return request<T>(path, options)
return request<T>(path, options)
}
export function imageUrl(id: string, variant: 'preview' | 'original' = 'preview') {
return `${API_URL}/image/${encodeURIComponent(id)}/${variant}`
return `${API_URL}/image/${encodeURIComponent(id)}/${variant}`
}
export function toAuthUser(profile: BackendUserProfile): AuthUser {
return {
id: profile.id,
email: profile.email,
username: profile.name,
avatar_id: profile.avatar_id,
role: profile.role,
is_disabled: profile.is_disabled,
cloud_count: profile.cloud_count,
received_like_count: profile.received_like_count,
created_at: profile.created_at,
}
return {
id: profile.id,
email: profile.email,
username: profile.name,
image: profile.image,
role: profile.role,
is_disabled: profile.is_disabled,
cloud_count: profile.cloud_count,
received_like_count: profile.received_like_count,
created_at: profile.created_at,
}
}
export function toCloudType(row: BackendCloudType): CloudType {
return {
id: row.id,
name: row.name,
genus: row.genus || '',
icon_id: row.icon_id,
// Compatibility value for the excluded legacy admin view. Migrated pages
// display rarity_value directly because API.md defines no semantic mapping.
rarity: 'common',
rarity_value: row.rarity,
description: row.description,
created_at: row.created_at,
}
return {
id: row.id,
name: row.name,
genus: row.genus || '',
icon_id: row.icon_id,
// Compatibility value for the excluded legacy admin view. Migrated pages
// display rarity_value directly because API.md defines no semantic mapping.
rarity: 'common',
rarity_value: row.rarity,
description: row.description,
created_at: row.created_at,
}
}
export function toApiCloud(row: BackendCloudInfo): ApiCloud {
return {
id: row.id,
user_id: row.owner.id,
cloud_type_id: row.type.id,
image_url: imageUrl(row.id, 'original'),
thumbnail_url: imageUrl(row.id, 'preview'),
latitude: row.latitude,
longitude: row.longitude,
description: row.description,
captured_at: row.captured_at,
status: row.status,
is_hidden: row.is_hidden,
created_at: row.uploaded_at,
updated_at: row.updated_at,
cloud_type: {
id: row.type.id,
name: row.type.name,
rarity: 'common',
rarity_value: row.type.rarity,
},
owner: { id: row.owner.id, username: row.owner.name },
cloud_type_name: row.type.name,
cloud_type_rarity: 'common',
cloud_type_rarity_value: row.type.rarity,
username: row.owner.name,
received_like_count: row.received_like_count,
}
return {
id: row.id,
user_id: row.owner.id,
cloud_type_id: row.type.id,
image_url: imageUrl(row.id, 'original'),
thumbnail_url: imageUrl(row.id, 'preview'),
latitude: row.latitude,
longitude: row.longitude,
description: row.description,
captured_at: row.captured_at,
status: row.status,
is_hidden: row.is_hidden,
created_at: row.uploaded_at,
updated_at: row.updated_at,
cloud_type: {
id: row.type.id,
name: row.type.name,
rarity: 'common',
rarity_value: row.type.rarity,
},
owner: { id: row.owner.id, username: row.owner.name },
cloud_type_name: row.type.name,
cloud_type_rarity: 'common',
cloud_type_rarity_value: row.type.rarity,
username: row.owner.name,
received_like_count: row.received_like_count,
}
}
-17
View File
@@ -1,17 +0,0 @@
export const CLOUD_TYPES = [
{ id: 1, name: '积云' },
{ id: 2, name: '层云' },
{ id: 3, name: '卷云' },
{ id: 4, name: '积雨云' },
{ id: 5, name: '层积云' },
{ id: 6, name: '高积云' },
{ id: 7, name: '高层云' },
{ id: 8, name: '雨层云' },
{ id: 9, name: '卷层云' },
{ id: 10, name: '卷积云' },
] as const
export function getCloudTypeName(id: string | string[]) {
const cloudTypeId = Number(Array.isArray(id) ? id[0] : id)
return CLOUD_TYPES.find(item => item.id === cloudTypeId)?.name ?? '云型详情'
}
+45 -41
View File
@@ -4,60 +4,64 @@ const defaultTitle = 'OpenCloud'
const defaultDescription = 'OpenCloud - 活的天空地图,拍云、识别、收藏'
function resolveSiteUrl() {
return (import.meta.env.VITE_SITE_URL || window.location.origin).replace(/\/$/, '')
return (import.meta.env.VITE_SITE_URL || window.location.origin).replace(/\/$/, '')
}
function upsertMeta(name: string, content: string) {
let element = document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`)
if (!element) {
element = document.createElement('meta')
element.name = name
document.head.appendChild(element)
}
element.content = content
let element = document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`)
if (!element) {
element = document.createElement('meta')
element.name = name
document.head.appendChild(element)
}
element.content = content
}
function upsertPropertyMeta(property: string, content: string) {
let element = document.querySelector<HTMLMetaElement>(`meta[property="${property}"]`)
if (!element) {
element = document.createElement('meta')
element.setAttribute('property', property)
document.head.appendChild(element)
}
element.content = content
let element = document.querySelector<HTMLMetaElement>(`meta[property="${property}"]`)
if (!element) {
element = document.createElement('meta')
element.setAttribute('property', property)
document.head.appendChild(element)
}
element.content = content
}
function upsertCanonical(href: string) {
let element = document.querySelector<HTMLLinkElement>('link[rel="canonical"]')
if (!element) {
element = document.createElement('link')
element.rel = 'canonical'
document.head.appendChild(element)
}
element.href = href
let element = document.querySelector<HTMLLinkElement>('link[rel="canonical"]')
if (!element) {
element = document.createElement('link')
element.rel = 'canonical'
document.head.appendChild(element)
}
element.href = href
}
export function applyRouteSeo(route: RouteLocationNormalizedLoaded) {
const title = typeof route.meta.title === 'function' ? route.meta.title(route) : route.meta.title
const description = typeof route.meta.description === 'function' ? route.meta.description(route) : route.meta.description
const robots = route.meta.noindex ? 'noindex, nofollow' : 'index, follow'
const canonicalPath = route.meta.canonicalPath || route.path
const canonical = `${resolveSiteUrl()}${canonicalPath}`
const pageTitle = title ? `${title} | OpenCloud` : defaultTitle
const pageDescription = description || defaultDescription
const title =
typeof route.meta.title === 'function' ? route.meta.title(route) : route.meta.title
const description =
typeof route.meta.description === 'function'
? route.meta.description(route)
: route.meta.description
const robots = route.meta.noindex ? 'noindex, nofollow' : 'index, follow'
const canonicalPath = route.meta.canonicalPath || route.path
const canonical = `${resolveSiteUrl()}${canonicalPath}`
const pageTitle = title ? `${title} | OpenCloud` : defaultTitle
const pageDescription = description || defaultDescription
document.title = pageTitle
upsertMeta('description', pageDescription)
upsertMeta('robots', robots)
upsertCanonical(canonical)
document.title = pageTitle
upsertMeta('description', pageDescription)
upsertMeta('robots', robots)
upsertCanonical(canonical)
upsertPropertyMeta('og:site_name', 'OpenCloud')
upsertPropertyMeta('og:type', 'website')
upsertPropertyMeta('og:title', pageTitle)
upsertPropertyMeta('og:description', pageDescription)
upsertPropertyMeta('og:url', canonical)
upsertPropertyMeta('og:site_name', 'OpenCloud')
upsertPropertyMeta('og:type', 'website')
upsertPropertyMeta('og:title', pageTitle)
upsertPropertyMeta('og:description', pageDescription)
upsertPropertyMeta('og:url', canonical)
upsertMeta('twitter:card', 'summary')
upsertMeta('twitter:title', pageTitle)
upsertMeta('twitter:description', pageDescription)
upsertMeta('twitter:card', 'summary')
upsertMeta('twitter:title', pageTitle)
upsertMeta('twitter:description', pageDescription)
}
+52 -52
View File
@@ -2,57 +2,57 @@ import type { GlobalThemeOverrides } from 'naive-ui'
/** Keep these values aligned with the semantic tokens in styles/tokens.css. */
export const openCloudThemeOverrides: GlobalThemeOverrides = {
common: {
primaryColor: '#34745d',
primaryColorHover: '#2d5f4e',
primaryColorPressed: '#274c40',
primaryColorSuppl: '#34745d',
infoColor: '#0369a1',
successColor: '#4d7f3b',
successColorHover: '#3f6633',
successColorPressed: '#35522c',
successColorSuppl: '#4d7f3b',
warningColor: '#d97706',
errorColor: '#dc2626',
borderRadius: '0px',
borderRadiusSmall: '0px',
fontFamily: '"IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif',
fontFamilyMono: '"IBM Plex Mono", "SFMono-Regular", monospace',
bodyColor: '#eef4f8',
cardColor: '#ffffff',
modalColor: '#ffffff',
popoverColor: '#ffffff',
tableColor: '#ffffff',
textColorBase: '#0f172a',
textColor1: '#0f172a',
textColor2: '#334155',
textColor3: '#64748b',
textColorDisabled: 'var(--oc-color-text-disabled)',
dividerColor: '#d9e3ee',
borderColor: '#d9e3ee',
inputColor: '#ffffff',
},
Button: {
borderRadiusTiny: '0px',
borderRadiusSmall: '0px',
borderRadiusMedium: '0px',
borderRadiusLarge: '0px',
textColorPrimary: '#0f172a',
textColorHoverPrimary: '#0f172a',
},
Card: {
borderRadius: '0px',
borderRadiusEmbedded: '0px',
paddingMedium: '24px',
},
Input: { borderRadius: '0px' },
Select: {
peers: {
InternalSelection: { borderRadius: '0px' },
common: {
primaryColor: '#34745d',
primaryColorHover: '#2d5f4e',
primaryColorPressed: '#274c40',
primaryColorSuppl: '#34745d',
infoColor: '#0369a1',
successColor: '#4d7f3b',
successColorHover: '#3f6633',
successColorPressed: '#35522c',
successColorSuppl: '#4d7f3b',
warningColor: '#d97706',
errorColor: '#dc2626',
borderRadius: '0px',
borderRadiusSmall: '0px',
fontFamily: '"IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif',
fontFamilyMono: '"IBM Plex Mono", "SFMono-Regular", monospace',
bodyColor: '#eef4f8',
cardColor: '#ffffff',
modalColor: '#ffffff',
popoverColor: '#ffffff',
tableColor: '#ffffff',
textColorBase: '#0f172a',
textColor1: '#0f172a',
textColor2: '#334155',
textColor3: '#64748b',
textColorDisabled: 'var(--oc-color-text-disabled)',
dividerColor: '#d9e3ee',
borderColor: '#d9e3ee',
inputColor: '#ffffff',
},
},
Tabs: { tabBorderRadius: '0px' },
Tag: { borderRadius: '0px' },
Alert: { borderRadius: '0px' },
Modal: { borderRadius: '0px' },
Button: {
borderRadiusTiny: '0px',
borderRadiusSmall: '0px',
borderRadiusMedium: '0px',
borderRadiusLarge: '0px',
textColorPrimary: '#0f172a',
textColorHoverPrimary: '#0f172a',
},
Card: {
borderRadius: '0px',
borderRadiusEmbedded: '0px',
paddingMedium: '24px',
},
Input: { borderRadius: '0px' },
Select: {
peers: {
InternalSelection: { borderRadius: '0px' },
},
},
Tabs: { tabBorderRadius: '0px' },
Tag: { borderRadius: '0px' },
Alert: { borderRadius: '0px' },
Modal: { borderRadius: '0px' },
}