refactor(styles): 建立统一的生产级样式体系 (#4)

## 变更内容

- 将全局样式拆分为设计令牌、基础规则和语义组件层
- 集中管理 Naive UI 主题配置
- 迁移页面容器、表单、信息面板和按钮等重复样式
- 增加样式体系文档与自动契约检查

## 验证

- `npm run check:styles`
- `npm run build`Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
2026-07-23 02:41:04 +08:00
parent 51743645e2
commit 16b3dd4aaa
27 changed files with 828 additions and 480 deletions
+44 -44
View File
@@ -203,17 +203,17 @@ onUnmounted(() => {
<template>
<div>
<section class="border-b border-sky-100 bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)]">
<div class="max-w-6xl mx-auto px-4 py-10">
<p class="text-sm font-medium uppercase tracking-[0.24em] text-sky-700">Cloud Upload</p>
<h1 class="mt-3 text-4xl font-bold text-slate-900">上传云图</h1>
<p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
<section class="oc-page-hero">
<div class="oc-container oc-container--content oc-page-hero__inner">
<p class="oc-page-eyebrow">Cloud Upload</p>
<h1 class="oc-page-title">上传云图</h1>
<p class="oc-page-description">
批量整理你的云图记录。类别和拍摄时间必填,经纬度既可以手动输入,也可以在迷你地图里点选回填。
</p>
</div>
</section>
<div class="max-w-6xl mx-auto px-4 py-8">
<div class="oc-container oc-container--content oc-page-content">
<input ref="fileInput" type="file" accept="image/*" multiple class="hidden" @change="handleFileSelect" />
<div v-if="successMsg" class="flex items-center justify-center min-h-[60vh]">
@@ -231,13 +231,13 @@ onUnmounted(() => {
</div>
<div class="mt-5">
<div class="flex items-center gap-3">
<h3 class="text-2xl font-bold text-gray-900">{{ badge.cloudName }}</h3>
<h3 class="text-2xl font-bold text-slate-900">{{ badge.cloudName }}</h3>
<NTag :bordered="false" type="warning">
{{ rarityLabel(badge.rarity) }}
</NTag>
</div>
<p class="mt-1 text-sm text-gray-500">{{ badge.cloudNameEn }}</p>
<p class="mt-4 text-sm text-gray-600">解锁时间:{{ formatUnlockedAt(badge.unlockedAt) }}</p>
<p class="mt-1 text-sm text-slate-500">{{ badge.cloudNameEn }}</p>
<p class="mt-4 text-sm text-slate-600">解锁时间:{{ formatUnlockedAt(badge.unlockedAt) }}</p>
</div>
<div class="mt-6 flex gap-3">
@@ -258,7 +258,7 @@ onUnmounted(() => {
<template v-else>
<div v-if="uploading" class="mb-6 border border-slate-200 bg-white p-5 shadow-sm">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-gray-700">正在上传 {{ currentItemIndex }} / {{ totalItems }}...</span>
<span class="text-sm font-medium text-slate-700">正在上传 {{ currentItemIndex }} / {{ totalItems }}...</span>
<span class="text-sm font-medium text-sky-600">{{ overallProgress }}%</span>
</div>
<NProgress
@@ -278,17 +278,17 @@ onUnmounted(() => {
@drop.prevent="handleDrop"
@click="fileInput?.click()"
class="flex flex-col items-center justify-center border-2 border-dashed py-20 cursor-pointer transition-colors bg-white shadow-sm"
:class="dragOver ? 'border-sky-400 bg-sky-50' : 'border-gray-300 hover:border-sky-400 hover:bg-gray-50'"
:class="dragOver ? 'border-sky-400 bg-sky-50' : 'border-slate-300 hover:border-sky-400 hover:bg-slate-50'"
>
<span class="text-5xl mb-4">☁️</span>
<p class="text-lg font-medium text-gray-700 mb-1">点击或拖拽图片到此处</p>
<p class="text-sm text-gray-400">支持 JPG、PNG 格式,可一次选择多张</p>
<p class="mb-1 text-lg font-medium text-slate-700">点击或拖拽图片到此处</p>
<p class="text-sm text-slate-400">支持 JPG、PNG 格式,可一次选择多张</p>
</div>
<template v-else>
<div class="flex gap-6">
<div class="flex-shrink-0 w-[480px]">
<div v-if="activeItem" class="bg-gray-900 overflow-hidden mb-3 border border-slate-900">
<div v-if="activeItem" class="mb-3 overflow-hidden border border-slate-900 bg-slate-900">
<img :src="activeItem.preview" alt="预览" class="w-full h-[360px] object-contain" />
</div>
<div class="flex gap-2 overflow-x-auto pb-2">
@@ -297,7 +297,7 @@ onUnmounted(() => {
:key="item.id"
@click="selectItem(item.id)"
class="relative flex-shrink-0 w-16 h-16 overflow-hidden cursor-pointer border-2 transition-colors bg-white"
:class="activeId === item.id ? 'border-sky-500' : 'border-transparent hover:border-gray-300'"
:class="activeId === item.id ? 'border-sky-500' : 'border-transparent hover:border-slate-300'"
>
<img :src="item.preview" alt="" class="w-full h-full object-cover" />
<div v-if="Object.keys(item.errors).length > 0" class="absolute top-0 right-0 w-2.5 h-2.5 bg-red-500"></div>
@@ -309,30 +309,30 @@ onUnmounted(() => {
</div>
<div
@click="fileInput?.click()"
class="flex-shrink-0 w-16 h-16 border-2 border-dashed border-gray-300 flex items-center justify-center cursor-pointer bg-white hover:border-sky-400 hover:bg-gray-50 transition-colors"
class="flex h-16 w-16 flex-shrink-0 cursor-pointer items-center justify-center border-2 border-dashed border-slate-300 bg-white transition-colors hover:border-sky-400 hover:bg-slate-50"
>
<span class="text-gray-400 text-xl">+</span>
<span class="text-xl text-slate-400">+</span>
</div>
</div>
</div>
<div class="flex-1 min-w-0" v-if="activeItem">
<NCard class="border border-gray-200 shadow-sm">
<NCard class="border border-slate-200 shadow-sm">
<div class="space-y-5">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">图片信息</h2>
<span class="text-sm text-gray-400">{{ items.findIndex(i => i.id === activeId) + 1 }} / {{ items.length }}</span>
<h2 class="text-lg font-semibold text-slate-900">图片信息</h2>
<span class="text-sm text-slate-400">{{ items.findIndex(i => i.id === activeId) + 1 }} / {{ items.length }}</span>
</div>
<!-- 类别 * -->
<div :id="`field-${activeItem.id}-cloudCategory`">
<label class="block text-sm font-medium text-gray-700 mb-1">
类别 <span class="text-red-500">*</span>
<label class="oc-field-label">
类别 <span class="oc-field-required">*</span>
</label>
<select
:value="activeItem.cloudCategoryId ?? ''"
@change="onCategoryChange"
class="w-full px-3 py-2.5 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors bg-white"
class="oc-field-control"
>
<option value="" disabled>请选择</option>
<option v-for="ct in cloudsStore.cloudTypes" :key="ct.id" :value="ct.id">
@@ -346,35 +346,35 @@ onUnmounted(() => {
type="text"
placeholder="输入云的类型"
@input="activeItem.errors = {}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
class="oc-field-control"
/>
</div>
<p v-if="activeItem.errors.cloudCategory" class="text-sm text-red-500 mt-1">{{ activeItem.errors.cloudCategory }}</p>
<p v-if="activeItem.errors.cloudCategory" class="oc-field-error">{{ activeItem.errors.cloudCategory }}</p>
</div>
<!-- 拍摄时间 * -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
拍摄时间 <span class="text-red-500">*</span>
<label class="oc-field-label">
拍摄时间 <span class="oc-field-required">*</span>
</label>
<input
type="datetime-local"
:value="formatDatetimeLocal(activeItem.capturedAt)"
@change="onCapturedAtChange"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
class="oc-field-control"
/>
<p v-if="activeItem.errors.capturedAt" class="text-sm text-red-500 mt-1">{{ activeItem.errors.capturedAt }}</p>
<p v-if="activeItem.errors.capturedAt" class="oc-field-error">{{ activeItem.errors.capturedAt }}</p>
</div>
<!-- 经纬度 -->
<div>
<div class="mb-1 flex items-center justify-between gap-3">
<label class="block text-sm font-medium text-gray-700">经纬度</label>
<label class="oc-field-label mb-0">经纬度</label>
<button
v-if="activeItem.latitude !== null || activeItem.longitude !== null"
type="button"
@click="clearCoordinates"
class="text-xs font-medium text-gray-400 transition-colors hover:text-gray-600"
class="text-xs font-medium text-slate-400 transition-colors hover:text-slate-600"
>
清空
</button>
@@ -387,9 +387,9 @@ onUnmounted(() => {
type="number"
step="any"
placeholder="纬度(如 39.90)"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
class="oc-field-control"
/>
<p v-if="activeItem.errors.latitude" class="text-sm text-red-500 mt-1">{{ activeItem.errors.latitude }}</p>
<p v-if="activeItem.errors.latitude" class="oc-field-error">{{ activeItem.errors.latitude }}</p>
</div>
<div>
<input
@@ -398,9 +398,9 @@ onUnmounted(() => {
type="number"
step="any"
placeholder="经度(如 116.40)"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
class="oc-field-control"
/>
<p v-if="activeItem.errors.longitude" class="text-sm text-red-500 mt-1">{{ activeItem.errors.longitude }}</p>
<p v-if="activeItem.errors.longitude" class="oc-field-error">{{ activeItem.errors.longitude }}</p>
</div>
<div>
<button
@@ -414,28 +414,28 @@ onUnmounted(() => {
</button>
</div>
</div>
<p class="text-xs text-gray-400 mt-1">选填,可手动输入,或点击右侧小地图图标选择</p>
<p class="mt-1 text-xs text-slate-400">选填,可手动输入,或点击右侧小地图图标选择</p>
</div>
<!-- 位置名称 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">位置名称</label>
<label class="oc-field-label">位置名称</label>
<input
v-model="activeItem.locationName"
type="text"
placeholder="如:北京、成都"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors"
class="oc-field-control"
/>
</div>
<!-- 图片描述 -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">图片描述</label>
<label class="oc-field-label">图片描述</label>
<textarea
v-model="activeItem.description"
rows="3"
placeholder="描述一下这张图片..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-colors resize-none"
class="oc-field-control resize-none"
></textarea>
</div>
@@ -443,14 +443,14 @@ onUnmounted(() => {
<div class="border border-slate-200 bg-slate-50 px-4 py-3">
<div class="flex items-center justify-between gap-4">
<div>
<p class="text-sm font-medium text-gray-800">公开展示</p>
<p class="mt-1 text-xs text-gray-500">关闭后不会出现在画廊、地图和公开主页中。</p>
<p class="text-sm font-medium text-slate-800">公开展示</p>
<p class="mt-1 text-xs text-slate-500">关闭后不会出现在画廊、地图和公开主页中。</p>
</div>
<input
:checked="!activeItem.isHidden"
type="checkbox"
:id="'isPublic-' + activeItem.id"
class="h-5 w-5 border-gray-300 text-sky-500 focus:ring-sky-500"
class="h-5 w-5 border-slate-300 text-sky-500 focus:ring-sky-500"
@change="updateActivePublicState(($event.target as HTMLInputElement).checked)"
/>
</div>
@@ -461,7 +461,7 @@ onUnmounted(() => {
</div>
<div class="mt-6 flex items-center justify-between">
<p class="text-sm text-gray-500">共 {{ items.length }} 张图片</p>
<p class="text-sm text-slate-500">共 {{ items.length }} 张图片</p>
<div class="flex gap-3">
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="clearAll()" :disabled="uploading">清空</NButton>
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky" @click="handleSubmit" :disabled="uploading">