Author SHA1 Message Date
Mplan 93a096a116 fix(map): make zoom controls continuously responsive 2026-07-25 03:21:20 +08:00
Mplan ad0b6f1630 fix(admin): align cards and empty-state actions
Apply consistent hard shadows across dashboard panels, hide unavailable queue and selection actions, and present the image-management shortcut as a standard panel button.
2026-07-25 02:54:52 +08:00
Mplan 55c90e8c60 style(ui): unify sky-atlas visual system
Centralize interactive component styles, adopt the fresh eucalyptus palette, and keep map controls square with hard shadows. Update project guidance and the OpenCloud style skill to match the implementation.
2026-07-25 02:41:27 +08:00
28 changed files with 968 additions and 479 deletions
+182 -301
View File
@@ -1,354 +1,235 @@
---
name: opencloud-style
description: "Use when modifying OpenCloud visual design, Tailwind classes, component styling, layout, typography, or page-level visual hierarchy. Ensures the project keeps its current clean sky-atlas visual language — airy, fresh, bright, observational — and avoids inconsistent dark or purple defaults."
description: "Maintain OpenCloud's current visual system when changing Vue templates, Tailwind classes, CSS, Naive UI theming, layout, typography, colors, buttons, cards, modals, maps, or responsive behavior. Preserve the clean sky-atlas language: airy sky-blue surfaces, fresh eucalyptus green, square atlas-like panels, pixel-cloud identity, and hard offset shadows."
---
# OpenCloud Visual Style
Use this skill whenever changing OpenCloud visual design, component styling, Tailwind classes, layout, typography, or any page-level appearance.
Treat the current design tokens and shared `oc-*` classes as the visual contract. Keep the interface airy, bright, observational, and slightly editorial—like a field atlas for clouds.
## Start From the Sources of Truth
Inspect these files before making a material visual change:
1. `src/styles/tokens.css` — palette remapping, semantic colors, gradients, shadows, motion.
2. `src/styles/base.css` — body baseline and the global square-corner rule.
3. `src/styles/components.css` — reusable page, surface, form, and interaction contracts.
4. `src/lib/theme.ts` — Naive UI overrides; keep semantic colors aligned with `tokens.css`.
5. `docs/style-system.md` — naming and implementation guidance.
6. `scripts/check-style-system.mjs` — prohibited legacy patterns.
Use `src/style.css` only as the stylesheet assembly point. Put stable values in `tokens.css`, cross-page patterns in `components.css`, and truly component-specific behavior in scoped styles.
## Visual Direction
OpenCloud should feel like a clean sky atlas: airy, fresh, bright, and observational.
- Use white, slate-50, sky-50, and soft translucent white for primary surfaces.
- Use sky blue for atmosphere, page heroes, upload/create actions, and the pixel-cloud brand surface.
- Use fresh eucalyptus green for identity, navigation selection, account access, confirmation, and interactive hover states.
- Use leaf green only for explicit success, approved, public, or completed states.
- Use amber for pending/review/uncommon states and rose/red for destructive/rejected states.
- Keep slate-900 text dominant. Do not turn normal page sections into dark panels.
- Avoid purple, indigo, generic gray utilities, neon green, and decorative color proliferation.
- Base surfaces: `white`, `slate-50`, `sky-50`, soft translucent white (`bg-white/80`, `bg-white/88`).
- Main accent: teal/cyan for navigation, identity, account surfaces.
- Secondary accent: sky blue for upload and action highlights.
- Supporting accent: amber only for rarity, badges, pending/review states.
- Danger accent: rose/red only for destructive or rejected states.
- Avoid purple as a default accent.
- Avoid black selected states except for text or rare high-contrast badges.
## Current Palette
---
Tailwind `teal-*` and `emerald-*` are compatibility names remapped in `tokens.css`; they are not Tailwind defaults.
| Role | Main | Dark | Light surface |
| --- | --- | --- | --- |
| Eucalyptus brand/interaction | `#34745d` | `#274c40` | `#f2faf6` |
| Leaf success | `#4d7f3b` | `#35522c` | `#f4faef` |
| Sky information/action | `#0369a1` | `#075985` | `#e0f2fe` |
| Canvas | `#eef4f8` | — | `#f8fbfd` |
| Primary text | `#0f172a` | — | — |
| Secondary text | `#334155` | — | — |
| Muted text | `#64748b` | — | — |
| Border | `#d9e3ee` | — | — |
Rules:
- Do not use Tailwind `green-*` or `lime-*`.
- Do not reintroduce Tailwind's vivid teal values such as `#14b8a6` or `#0f766e`.
- Use semantic tokens or existing `teal-*`/`emerald-*` compatibility utilities instead of new hard-coded greens.
- Keep green text and actions dark enough for readable contrast; use pale green primarily as a surface.
- Synchronize changes to brand or success colors between `tokens.css` and `src/lib/theme.ts`.
## Shape and Border Rules
The product is square by default. `src/styles/base.css` forces Tailwind `rounded*` utilities to `0px`.
- Use square corners for page panels, cards, buttons, inputs, navigation, dropdowns, map controls, and ordinary modals.
- Use a one-pixel slate or semantic-color border to define surfaces.
- Do not add rounded rectangles as a generic polish treatment.
Documented exceptions:
- Circular close/overlay controls in `ImageDetailModal`.
- Circular location/marker dots and AMap-generated marker bubbles.
- The media-focused `ImageDetailModal` shell.
- AMap-generated information cards and the deliberately organic timeline control details in `MapView`.
Do not treat map floating buttons as exceptions: `.oc-map-button` is a square `40px × 40px` control with `0px` radius and a hard shadow, including its disabled state.
## Shadows and Interaction
Use hard offset shadows with zero blur as the signature depth treatment:
| Token | Value | Typical use |
| --- | --- | --- |
| `--oc-shadow-xs` | `3px 3px` | Small controls and map buttons |
| `--oc-shadow-sm` | `4px 4px` | Header logo and compact CTAs |
| `--oc-shadow-md` | `6px 6px` | Standard cards and navigation |
| `--oc-shadow-lg` | `8px 8px` | Menus and floating panels |
| `--oc-shadow-xl` | `12px 12px` | Large system/auth cards |
| `--oc-shadow-sky` | sky-tinted `4px 4px` | Sky actions |
| `--oc-shadow-primary` | eucalyptus-tinted `4px 4px` | Green interactions |
- On hover, interactive controls may move `translate(-1px, -1px)` and grow the hard shadow.
- On active, return the control to its origin.
- Preserve a visible `2px` sky focus outline with offset.
- Do not replace the hard-shadow language with generic soft shadows across ordinary UI.
- Soft/blurred shadows remain acceptable for media overlays, AMap internals, and image hover depth.
## Backgrounds and Brand Identity
Use the semantic gradients in `tokens.css`:
- App shell: a subtle eucalyptus radial glow over the pale blue-gray canvas.
- Standard page hero: `#e0f2fe` to `#f8fafc`.
- Soft page hero: `#f2faf6` to `#f8fafc`.
- App grid: subtle slate lines at `32px`.
The header identity is fixed:
- Use the pixel-style cloud/sun SVG, not an emoji or unrelated icon set.
- Put the icon in a square `40px` container with `bg-sky-100`, `border-sky-200`, and a `4px 4px` neutral hard shadow.
- Keep “LIVE SKY ATLAS” as the small tracked eyebrow and “OpenCloud” as the bold brand name.
- Keep the header white/translucent; green belongs to navigation and account interaction, not the logo background.
## Typography
**Font stack** (`src/style.css`, `App.vue`):
```
Use:
```text
"IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif
```
Monospace: `"IBM Plex Mono", "SFMono-Regular", monospace`
**Type scale:**
Use IBM Plex Mono for small atlas-like labels when a monospace accent is appropriate.
| Size | Use |
|------|-----|
| `text-[11px]` | Header subtitle, heatmap weekday labels |
| `text-xs` | Uppercase section labels, captions, metadata, tags, help text |
| `text-sm` | Body text, descriptions, nav items, form labels, detail sections |
| `text-base` | Hero description paragraphs |
| `text-lg` | Logo brand name, card titles, item counters |
| `text-xl` | Modal titles, subsection headings, stat cards |
| `text-2xl` | Subsection headings, stat values, settings section titles |
| `text-3xl` | Stat values, avatar initials |
| `text-4xl` | Page H1 headings (all page heroes) |
| `text-5xl` | Login/Register hero text only |
| `text-6xl`+ | Decorative single characters, error page emoji |
- Page eyebrow: `text-sm`, uppercase, `tracking-[0.24em]`, sky-700.
- Page title: `text-4xl`, bold, slate-900.
- Descriptions: `text-sm`, `leading-7`, slate-600.
- Section/stat labels: `text-xs`, uppercase, tracking `0.18em–0.20em`, slate-500.
- Header brand eyebrow: `text-[11px]`, uppercase, `tracking-[0.22em]`.
- Reserve `font-black` and very large display text for authentication hero statements.
**Font weights:**
- `font-medium` (500) — body text, nav items, form labels, tags
- `font-semibold` (600) — modal titles, item names, emphasis
- `font-bold` (700) — headings, stat values, card titles
- `font-black` (900) — Login/Register hero only, with `leading-[1.05]`
## Layout and Responsive Behavior
**Letter tracking:**
Prefer the shared page structure:
| Tracking | Where |
|----------|-------|
| `tracking-[0.12em]` | Nav links, upload/button text (uppercase) |
| `tracking-[0.18em]` | Info panel section labels |
| `tracking-[0.20em]` | Stat card labels |
| `tracking-[0.22em]` | Header subtitle, modal subtitle, quick-upload header |
| `tracking-[0.24em]` | Page hero section labels |
| `tracking-[0.26em]` | Login/Register hero labels |
```html
<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>
---
## Border Radius
**All border-radius is `0px` by default** — enforced via Naive UI theme override and global CSS:
```css
[class~='rounded'], [class*='rounded-'] { border-radius: 0 !important; }
<main class="oc-container oc-container--content oc-page-content">
<!-- content -->
</main>
```
Only exceptions (all `!important` or explicit inline):
- Close/map buttons: `rounded-full` / `border-radius: 9999px`
- ImageDetailModal shell: `rounded-[30px]`
- MapView info panel cards: `rounded-2xl`
- MiniLocationMap marker dot: `rounded-full`
- UploadView native inputs: `rounded-lg`
Container widths:
---
- `oc-container--narrow` — `max-w-4xl`
- `oc-container--content` — `max-w-6xl`
- `oc-container--wide` — `max-w-7xl`
## Shadows
Use `px-4 sm:px-6 lg:px-8` for page gutters. Validate at both desktop and a real `375px` mobile viewport. Do not accept horizontal overflow.
**Signature pattern: hard offset box shadows** — `shadow-[Xpx_Xpx_0_0_rgba(...)]` with zero blur.
## Reusable Component Contracts
| Shadow | Offset | Use |
|--------|--------|-----|
| `shadow-[3px_3px_0_0_rgba(...)]` | 3/3 | Small header buttons, login button |
| `shadow-[4px_4px_0_0_rgba(...)]` | 4/4 | Logo box, primary CTAs, hidden header, profile avatar |
| `shadow-[6px_6px_0_0_rgba(...)]` | 6/6 | Navigation bar, search box |
| `shadow-[8px_8px_0_0_rgba(...)]` | 8/8 | Dropdown menus, archive panel, account card |
| `shadow-[10px_10px_0_0_rgba(...)]` | 10/10 | Login/Register hero sections |
| `shadow-[12px_12px_0_0_rgba(...)]` | 12/12 | Login/Register cards, CommunityView section, AuthConfirm card |
Use existing semantic classes before writing repeated Tailwind bundles:
**Shadow colors by context:**
- `rgba(15,23,42,...)` — slate-900, neutral (most common)
- `rgba(14,165,233,...)` — sky-500 (sky-colored buttons)
- `rgba(20,184,166,...)` — teal-500 (teal accents)
- Page structure: `oc-page-hero`, `oc-page-hero--soft`, `oc-container`, `oc-page-content`.
- Typography: `oc-page-eyebrow`, `oc-page-title`, `oc-page-description`, `oc-section-label`, `oc-stat-label`, `oc-stat-value`, `oc-meta`.
- Forms: `oc-field-label`, `oc-field-required`, `oc-field-control`, `oc-field-help`, `oc-field-error`.
- Surfaces: `oc-surface`, `oc-inset-panel`, `oc-panel-card`, `oc-panel-card-soft`, `oc-empty-card`.
- Interaction: `oc-primary-button`, `oc-panel-button`, `oc-header-button`, `oc-icon-button`, `oc-map-button`, `oc-choice-button`, `oc-text-button`, `oc-menu-item`, `oc-nav-link`.
**Standard Tailwind shadows** also used sparingly:
- `shadow-sm` — stat cards, gallery cards, encyclopedia cards
- `shadow-lg` — cloud card hover lift
- `shadow-2xl` — modal shells
Class names describe function. Keep modifiers attached to their base class, for example:
---
## Gradients
All page backgrounds use gradients — avoid flat single-color backgrounds.
| Gradient | Where |
|----------|-------|
| `linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)` | Page hero sections (standard) |
| `linear-gradient(180deg,#f0fdfa_0%,#f8fafc_100%)` | Community page, soft app pages |
| `linear-gradient(135deg,#f0fdfa_0%,#fff_48%,#eff6ff_100%)` | Login hero |
| `linear-gradient(135deg,#eff6ff_0%,#fff_42%,#f0fdfa_100%)` | Register hero |
| `linear-gradient(180deg,#f8fafc_0%,#eef6ff_55%,#f8fafc_100%)` | Admin page |
| `linear-gradient(135deg,#ecfeff_0%,#ccfbf1_100%)` | Brand chip/avatar |
| `linear-gradient(180deg,#f0fdfa_0%,#fff_100%)` | Account card header |
**App shell background** (`App.vue`):
```
radial-gradient(circle_at_top_left, rgba(12,148,136,0.12), transparent 28%)
linear-gradient(180deg, #f8fbfd 0%, #eef4f8 100%)
```
Plus a dotted grid overlay: `bg-[size:32px_32px] opacity-35`.
**Body background** (`style.css`):
```
linear-gradient(180deg, rgba(15,23,42,0.02), rgba(15,23,42,0)), #eef4f8
```html
<NButton type="default" class="oc-panel-button oc-panel-button--sky">
保存
</NButton>
```
---
Available shared variants:
## Layout
- Primary: `oc-primary-button--teal`, `oc-primary-button--sky`
- Panel: `--neutral`, `--sky`, `--teal`, `--amber`, `--danger`
- Header: `--neutral`, `--sky`, `--teal`
- Icon: `--small`, `--tiny`, `--sky`, `--danger`
- Text/menu: muted, amber, and danger variants where defined
**Page max-widths:**
- `max-w-7xl` — AppHeader, GalleryView, AdminView
- `max-w-6xl` — UploadView, ProfileView, EncyclopediaView
- `max-w-4xl` — ProfileSettingsView, CommunityView
- `max-w-3xl` — QuickUploadModal, MapPickerModal, AuthConfirmView
- `max-w-2xl` — ResetPasswordView, NotFoundView, ForbiddenView, CloudEditModal
## Button Semantics
**Page hero pattern** (consistent across all views):
```
border-b border-sky-100 bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)]
max-w-{6xl|7xl} mx-auto px-4 py-10
text-sm font-medium uppercase tracking-[0.24em] text-sky-700 (subtitle)
h1 mt-3 text-4xl font-bold text-slate-900 (title)
p mt-4 max-w-2xl text-sm leading-7 text-slate-600 (description)
```
- Teal/eucalyptus: login, account access, active toggle, confirm.
- Sky: register, create, upload, forward action.
- Neutral: cancel and utility actions.
- Amber: pending/review/state-toggle actions.
- Danger: deletion and destructive actions.
**Horizontal padding:** `px-4` / `sm:px-6` / `lg:px-8`
For Naive UI buttons:
**Column systems:**
- Gallery masonry: `columns-2 gap-3 md:columns-3 xl:columns-4 [column-gap:0.75rem]`
- Card grids: `gap-4 md:grid-cols-2 xl:grid-cols-3` or `gap-4 md:grid-cols-4`
- Two-column splits: `lg:grid-cols-[1.1fr_0.9fr]`, `lg:grid-cols-[1.4fr_0.9fr]`
- Use `type="default"` with the shared `oc-*` class when a custom visual variant is intended.
- Do not rely on Naive UI default primary/secondary styling for page CTAs or panel actions.
- Keep `src/lib/theme.ts` as the global Naive UI contract; it is mounted by `App.vue`.
- Use dark text on light primary surfaces. Do not assume primary buttons should have white text.
**Gap scale:** `gap-4`, `gap-5`, `gap-6` for card grids; `gap-2` for nav items; `space-y-6`/`space-y-8` between sections.
## Icons, Cards, and Modals
---
- Use `@vicons/tabler` through `NIcon` for interface icons.
- Preserve the pixel-cloud SVG for the primary brand mark.
- Use one bordered surface and one appropriate hard-shadow token for ordinary cards.
- Keep modal structure square unless it is a documented media exception.
- Use `oc-overlay-button` for controls over dark media and `oc-icon-button` for controls on light surfaces.
- Keep semantic status colors stable: emerald success, amber pending, rose rejected/destructive, slate hidden/neutral.
## Component Conventions
## Implementation Workflow
### Buttons
**Primary CTA (upload, action):**
```
inline-flex h-8 items-center border border-sky-200 bg-sky-100 px-3
text-sm font-medium uppercase tracking-[0.12em] text-sky-800
shadow-[4px_4px_0_0_rgba(14,165,233,0.14)]
transition-colors hover:bg-sky-50 hover:text-sky-900
```
Mobile: `h-8 md:h-10 md:px-4`
**Secondary/neutral:**
```
inline-flex h-8 items-center border border-slate-200 bg-white/80 px-3
text-sm font-medium text-slate-700
shadow-[3px_3px_0_0_rgba(15,23,42,0.06)]
hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800
```
**Danger/delete:** `text-rose-600 hover:bg-rose-50 hover:text-rose-700`
**Map floating buttons:** `w-10 h-10 bg-white rounded-lg shadow-md flex items-center justify-center hover:bg-gray-50`
**Naive UI buttons** use `secondary strong` as default; `type="primary"` for primary actions; `block size="large"` for block forms.
### Cards
**Standard card:**
```
border bg-white text-left shadow-sm
transition-all hover:-translate-y-1 hover:shadow-lg
```
**Encyclopedia unlocked variant:** `border-amber-300 shadow-amber-100/60`
**Naive UI NCard:** `class="border border-slate-200 shadow-sm"`
### Modals
**Overlay:** `fixed inset-0 z-[110|130] bg-slate-950/60` (or `bg-black/85` for image detail)
**Shell:** `bg-white shadow-2xl` with max-w matching content width. ImageDetailModal uses `rounded-[30px]`; others are square.
**Close button:** Circular (`rounded-full`, `border-radius: 9999px`), semi-transparent background.
**Sections:** `border-b border-slate-200` separates header/body/footer.
### Inputs & Forms
**Native HTML inputs:**
```
border border-slate-300 px-3 py-2.5 text-sm
focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500
```
**Labels:** `block text-sm font-medium text-slate-700 mb-1`
**Required marker:** `<span class="text-red-500">*</span>`
**Error text:** `text-sm text-red-500 mt-1`
**Checkboxes:** `h-5 w-5 border-slate-300 text-sky-500 focus:ring-sky-500`
### Tags & Badges
All use `inline-flex border px-3 py-1 text-xs font-medium` with color variants:
- `common`: `bg-sky-100 text-sky-700 border-sky-200`
- `uncommon`: `bg-amber-100 text-amber-700 border-amber-200`
- `rare`: `bg-rose-100 text-rose-700 border-rose-200`
- `approved`/`success`: `bg-emerald-100 text-emerald-700 border-emerald-200`
- `pending`: `bg-amber-100 text-amber-700 border-amber-200`
- `rejected`: `bg-rose-100 text-rose-700 border-rose-200`
- `hidden`/neutral: `bg-slate-100 text-slate-600`
### Menus & Dropdowns
**Account dropdown:**
```
border border-slate-200 bg-white shadow-[8px_8px_0_0_rgba(15,23,42,0.08)]
```
**Menu items:**
```
flex items-center gap-3 px-4 py-2.5 text-sm font-medium text-slate-700
transition-colors hover:bg-teal-50 hover:text-teal-800
```
**Danger menu item:** `text-rose-600 hover:bg-rose-50 hover:text-rose-700`
### Navigation
**Header:** `sticky top-0 z-50 h-28 md:h-16 bg-white/88 backdrop-blur-xl border-b border-slate-200/80`, scroll-hides with `transition-transform duration-300`.
**Selected nav item:** `bg-teal-100 text-teal-800 ring-1 ring-teal-200`
---
## Core Palette
Use these Tailwind families first:
- `slate` — text, borders, neutral panels
- `sky` — page gradients, upload/action emphasis, map/cloud atmosphere
- `teal` — navigation selected states, account identity, calm success-adjacent UI
- `cyan` — subtle sky gradients and avatar/logo surfaces
- `emerald` — explicit success/approved state only
- `amber` — pending/review/uncommon rarity
- `rose` — rejected/destructive/rare rarity
**Key hex values:**
| Role | Value |
|------|-------|
| Page background | `#eef4f8` / `#f3f7fb` |
| Body text | `#0f172a` (slate-900) |
| Secondary text | `#334155` (slate-700) |
| Muted text | `#64748b` (slate-500) |
| Borders | `#d9e3ee`, `#e2e8f0`, `#cbd5e1` |
| Selection | `rgba(15,118,110,0.18)` (teal-700) |
| Text on primary | `#0f172a` (dark, not white — Naive UI override) |
**Naive UI theme tokens** (`App.vue`):
```
primaryColor: '#0f766e' (teal-700)
infoColor: '#0369a1' (sky-700)
successColor: '#0f766e' (teal-700)
warningColor: '#d97706' (amber-600)
errorColor: '#dc2626' (red-600)
```
---
## Animation & Transitions
**Hover effects:**
- Cards: `transition-all hover:-translate-y-1 hover:shadow-lg` (lift)
- Images: `transition duration-500 group-hover:scale-[1.04]` (subtle zoom)
- Gallery items: `transition-transform duration-300 hover:-translate-y-1`
- Badges on cards: `transition-opacity md:opacity-0 md:group-hover:opacity-100`
**Modal transitions:**
- ImageDetailModal: opacity 180ms, shell scale+translateY 220ms, `cubic-bezier(0.22, 1, 0.36, 1)`
- Other modals: default Vue transitions
**Header scroll:** `transition-transform duration-300`
**Image loading:** `transition-opacity duration-300` for thumbnail-to-hires crossfade, low-res preview gets `blur-[1px]` until full-res loads.
**Skeleton shimmer:**
```
animate-pulse bg-[linear-gradient(110deg,#0f172a_0%,#1e293b_45%,#334155_55%,#0f172a_100%)]
bg-[length:220%_100%]
```
---
## Icons
**Library:** `@vicons/tabler` (Tabler Icons), rendered via `<NIcon size="16|18|20|22|30|42">` from Naive UI.
Common icons: `User`, `Settings`, `Logout`, `Shield`, `Search`, `X`, `Clock`, `Location`/`MapIcon`, `CloudUpload`, `Map`/`Satellite`, `Adjustments`, `Calendar`, `Refresh`, `InfoCircle`, `Lock`, `Check`, `Eye`/`EyeOff`, `Trash`
---
1. Inspect the source-of-truth files and the target component.
2. Reuse an existing semantic class if it expresses the intent.
3. Use Tailwind only for one-off layout and responsive composition.
4. Add a token before repeating a stable raw value.
5. Add a shared `oc-*` class only for cross-page patterns or product-wide rules.
6. Keep feature-specific AMap, slider, media, or transition details scoped to the component.
7. When changing a global color, audit all matching Tailwind utilities, hard-coded hex/RGB values, gradients, shadows, canvas drawing, and Naive UI tokens.
8. Preserve unrelated user changes in the worktree.
## Anti-Patterns
- Do not use purple as a fallback visual direction.
- Do not use `bg-slate-900 text-white` for ordinary selected navigation.
- Do not introduce dark-mode-heavy sections unless the surrounding page already uses that language.
- Do not overuse rounded rectangles; the project uses sharp, atlas-like panels.
- Do not mix random accent colors within the same feature. Pick one accent family and keep it consistent.
- Do not use flat single-color backgrounds where a gradient belongs.
- Do not add border-radius without an explicit reason — `0px` is the project default.
- Do not use white text on primary buttons — Naive UI primary buttons use dark text (`#0f172a`).
---
- Default Tailwind `green-*`, `lime-*`, vivid teal, purple, indigo, or generic `gray-*`.
- Ad hoc rounded cards, pills, or map buttons.
- Soft drop shadows replacing hard atlas shadows across normal UI.
- New button styling copied into templates instead of shared `oc-*` variants.
- `NButton type="primary"` used as a shortcut around the shared button contract.
- White text on pale green or sky action surfaces.
- Emoji used as the site identity.
- Flat page backgrounds where the shared hero/canvas gradient applies.
- Green used indiscriminately for both brand interaction and success state.
## Validation
After style changes, run:
```bash
npx vue-tsc -b
npm run check:styles
git diff --check
npm run build
```
Run `npm run build` if the change touches routes, imported components, or package dependencies.
For material visual changes, render representative pages and inspect desktop and `375px` mobile screenshots. Check computed styles when shape, shadow, color token, or responsive overflow is part of the request.
+34 -4
View File
@@ -3,9 +3,11 @@
## Commands
- `npm run dev` — Vite dev server with HMR
- `npm run check:styles` — validates shared style contracts and blocks deprecated palettes/classes
- `npm run build` — `vue-tsc -b && vite build` (typecheck must pass or build aborts)
- `npx vue-tsc -b` — standalone typecheck (no script in package.json)
- No linter, formatter, or test runner exists
- `npm run preview` — previews the production build on strict port `5173`
- No general linter, formatter, or test runner exists; `check:styles` is the project-specific visual-system check
## Architecture
@@ -26,9 +28,13 @@
| `src/components/cloud/` | Cloud-related modals and widgets: ImageDetailModal, CloudEditModal, MapPickerModal, QuickUploadModal, MiniLocationMap |
| `src/components/layout/` | AppHeader (top nav bar with auth state) |
| `src/components/profile/` | ContributionHeatmap |
| `src/style.css` | Global visual system hooks: shared button/card utility classes, base typography, body background |
| `src/style.css` | Global stylesheet assembly point; imports Tailwind, tokens, base rules, and shared components |
| `src/styles/` | Visual system source: semantic tokens, global square-corner baseline, shared `oc-*` component contracts |
| `src/lib/theme.ts` | Naive UI theme overrides aligned with the semantic tokens |
| `src/views/` | Route-level page components (see Routes below) |
| `src/types/` | TypeScript types: domain models (`database.ts`), API DTOs (`api.ts`), AMap declarations (`amap.d.ts`), router meta (`router.d.ts`) |
| `docs/style-system.md` | Concise implementation and naming guide for the visual system |
| `scripts/check-style-system.mjs` | Automated guard against deprecated palettes and malformed shared classes |
## Routes
@@ -126,12 +132,33 @@ Optional:
Backend-only variables such as database credentials, JWT secrets, SMTP settings, cookie policy, CORS origins, and upload paths belong in `../opencloud-backend/.env`, never in the Vite environment.
## Visual System
- The design language is a clean sky atlas: airy sky-blue surfaces, fresh eucalyptus green, square editorial panels, pixel-cloud identity, and hard offset shadows.
- Read `.agents/skills/opencloud-style/SKILL.md` before material visual changes. Treat `src/styles/tokens.css`, `src/styles/base.css`, `src/styles/components.css`, and `src/lib/theme.ts` as the implementation sources of truth.
- Shared/stable decisions belong in layers:
- tokens and palette remapping → `src/styles/tokens.css`
- global baseline and square-corner policy → `src/styles/base.css`
- cross-page semantic classes → `src/styles/components.css`
- feature-specific map/slider/media details → the component's scoped style
- one-off layout and responsive composition → Tailwind in the template
- The global interface is square by default. Ordinary cards, inputs, dropdowns, buttons, modals, and map controls use `0px` radius. Circular marker dots, media overlay controls, AMap-generated bubbles/cards, and the media-focused image-detail shell are explicit exceptions.
- Use hard zero-blur offset shadows (`--oc-shadow-xs` through `--oc-shadow-xl`) for ordinary UI depth. Interactive controls may lift by `translate(-1px, -1px)` on hover.
- Brand/interaction green is fresh eucalyptus: main `#34745d`, dark `#274c40`, light surface `#f2faf6`.
- Success green is a distinct leaf green: main `#4d7f3b`, dark `#35522c`, light surface `#f4faef`.
- Existing `teal-*` and `emerald-*` Tailwind class names are compatibility aliases remapped in `tokens.css`; do not assume Tailwind's default values.
- Do not introduce Tailwind `green-*`/`lime-*`, vivid default teal, purple, indigo, or generic gray palettes. Use slate, sky, remapped teal/emerald, amber, and rose according to semantic purpose.
- The header brand mark is the pixel cloud/sun SVG inside a square `40px` `sky-100` surface with a `sky-200` border and neutral `4px` hard shadow. Do not replace it with emoji or give it a green background.
- Map floating buttons use `oc-map-button`: square `40px × 40px`, white background, slate border, `0px` radius, and a hard shadow even when disabled.
- For material visual changes, inspect representative desktop and real `375px` mobile screenshots and verify there is no horizontal overflow.
## Naive UI
- Used for modals, buttons, inputs, tags, progress bars, alerts, dropdowns, empty states, skeletons, and message toasts.
- No SSR — pure client-side rendering.
- Custom CSS overrides via scoped `<style>` blocks for slider styling, transitions, and component tweaks.
- **Do not rely on default Naive UI primary/secondary colors for page CTAs or panel actions.** The project now uses shared global classes in `src/style.css` to keep buttons visually consistent with the sky-atlas theme.
- Global overrides are defined in `src/lib/theme.ts` and mounted by `App.vue`; keep brand/success colors aligned with `src/styles/tokens.css`.
- **Do not rely on default Naive UI primary/secondary colors for page CTAs or panel actions.** Use the shared classes in `src/styles/components.css`.
- Auth buttons use `oc-primary-button` with semantic variants:
- `oc-primary-button--teal` — login / account-access actions
- `oc-primary-button--sky` — register / create / forward actions
@@ -146,12 +173,15 @@ Backend-only variables such as database credentials, JWT secrets, SMTP settings,
- `oc-panel-card-soft`
- `oc-empty-card`
- When styling `NButton`, prefer `type="default"` plus the shared class when a custom panel button variant is intended. Otherwise Naive UI theme variables may override white backgrounds or semantic colors.
- Primary and panel actions use dark text on light semantic surfaces; do not assume primary buttons should use white text.
## Visual Notes
- The site icon and header brand mark are a matching pixel-style cloud motif; avoid reintroducing emoji or unrelated icon styles for primary brand surfaces.
- The site icon and header brand mark are a matching pixel-style cloud motif on a light sky-blue square; avoid emoji or unrelated icon styles for primary brand surfaces.
- Header action buttons use slight hover lift (`translate(-1px, -1px)`) with hard-offset shadow growth; new header-like actions should follow that interaction pattern.
- Heatmap view toggles in `ContributionHeatmap` intentionally use separate buttons with gap spacing instead of `NButtonGroup`, because grouped buttons visually collide once hard shadows and hover transforms are applied.
- Reuse existing `oc-*` classes before duplicating Tailwind bundles. New shared modifiers must always appear with their base class.
- Validate style work with `npm run check:styles`, `git diff --check`, and `npm run build`.
## MVP Constraints
+16 -1
View File
@@ -15,6 +15,21 @@
`src/style.css` 只负责按以上顺序装配全局样式,不再直接堆放组件规则。
## 清新绿色策略
OpenCloud 的绿色应接近清晨的桉叶与嫩叶,而不是灰暗的苔藓色或高饱和的荧光青绿。
- 品牌、导航与账户交互使用清透但稳重的桉叶绿。
- 成功、通过和公开状态使用更偏嫩叶的成功绿色,与品牌色保持区分。
- `teal-*` 和 `emerald-*` 是现有模板的兼容类名,实际色值由 `tokens.css` 重映射;不要假定它们仍是 Tailwind 默认色。
- 不直接使用 Tailwind 默认 `green-*`、`lime-*`,也不要在组件里写亮绿色十六进制值或 RGB。
- 新增绿色前先判断它属于品牌交互还是成功状态,并优先使用已有语义类或设计令牌。
| 语义 | 主色 | 深色 | 浅色表面 |
| --- | --- | --- | --- |
| 品牌/交互 | `#34745d` | `#274c40` | `#f2faf6` |
| 成功/通过 | `#4d7f3b` | `#35522c` | `#f4faef` |
## 命名规则
- 所有共享类使用 `oc-` 前缀,避免与 Naive UI、AMap 和 Tailwind 冲突。
@@ -78,7 +93,7 @@ Naive UI 按钮继续使用 `type="default"`,视觉语义由共享类控制:
</NButton>
```
页面级主操作使用 `oc-primary-button`;面板内操作使用 `oc-panel-button`。可用变体为 `neutral`、`sky`、`teal`、`amber`、`danger`,不要临时创建新的强调色。
页面级主操作使用 `oc-primary-button`;面板内操作使用 `oc-panel-button`。可用变体为 `neutral`、`sky`、`teal`、`amber`、`danger`,不要临时创建新的强调色。其中 `teal` 是兼容命名,视觉上使用的是清新桉叶绿。
## 新增或修改样式的决策顺序
+20 -5
View File
@@ -7,30 +7,43 @@ const sourceRoot = new URL('../src', import.meta.url)
const deprecatedPatterns = [
{
pattern: /\b(?:bg|border|ring|text)-(?:gray|indigo|purple|violet)-\d+\b/g,
message: '使用项目约定的 slate/sky/teal 语义色,不要重新引入灰色或紫色系。',
message: '使用项目约定的 slate/sky/自然绿色语义色,不要重新引入灰色或紫色系。',
},
{
pattern: /\b(?:bg|border|ring|text)-(?:green|lime)-\d+\b/g,
message: '绿色必须使用项目重映射后的 teal 品牌色或 emerald 成功色,不要引入默认 green/lime 荧光色。',
},
{
pattern: /#(?:f0fdfa|ccfbf1|99f6e4|5eead4|2dd4bf|14b8a6|0d9488|0f766e|115e59|134e4a)\b/gi,
message: '检测到旧版高饱和 teal 色值,请改用自然绿色设计令牌。',
},
{
pattern: /text-xs uppercase tracking-\[0\.18em\] text-slate-500/g,
message: '重复的小节标签应使用 oc-section-label。',
vueOnly: true,
},
{
pattern: /border border-slate-200 bg-slate-50 p-4/g,
message: '标准内嵌信息面板应使用 oc-inset-panel。',
vueOnly: true,
},
{
pattern: /border-b border-sky-100 bg-\[linear-gradient\(180deg,#e0f2fe_0%,#f8fafc_100%\)\]/g,
message: '标准页面头部应使用 oc-page-hero。',
vueOnly: true,
},
]
async function collectVueFiles(directory) {
const styleSourceExtensions = new Set(['.vue', '.css', '.ts'])
async function collectStyleSourceFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true })
const files = await Promise.all(entries.map(async (entry) => {
const path = join(directory, entry.name)
return entry.isDirectory() ? collectVueFiles(path) : [path]
return entry.isDirectory() ? collectStyleSourceFiles(path) : [path]
}))
return files.flat().filter((path) => extname(path) === '.vue')
return files.flat().filter((path) => styleSourceExtensions.has(extname(path)))
}
function lineNumberAt(source, index) {
@@ -39,10 +52,12 @@ function lineNumberAt(source, index) {
const violations = []
for (const file of await collectVueFiles(sourceRoot.pathname)) {
for (const file of await collectStyleSourceFiles(sourceRoot.pathname)) {
const source = await readFile(file, 'utf8')
for (const rule of deprecatedPatterns) {
if (rule.vueOnly && extname(file) !== '.vue') continue
for (const match of source.matchAll(rule.pattern)) {
violations.push({
file: relative(projectRoot.pathname, file),
+5 -3
View File
@@ -123,7 +123,9 @@ watch(() => props.initialValue, value => {
</div>
<button
type="button"
class="p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
class="oc-icon-button"
aria-label="关闭编辑窗口"
title="关闭编辑窗口"
@click="close"
>
✕
@@ -191,7 +193,7 @@ watch(() => props.initialValue, value => {
<button
v-if="hasCoordinates"
type="button"
class="text-xs font-medium text-slate-400 transition-colors hover:text-slate-700"
class="oc-text-button oc-text-button--muted text-xs"
@click="clearCoordinates"
>
清空
@@ -216,7 +218,7 @@ watch(() => props.initialValue, value => {
/>
<button
type="button"
class="flex h-10 w-10 items-center justify-center border border-sky-200 bg-sky-50 text-sky-700 transition-colors hover:bg-sky-100"
class="oc-icon-button oc-icon-button--sky"
title="地图选点"
aria-label="地图选点"
@click="mapPickerOpen = true"
+3 -3
View File
@@ -187,7 +187,7 @@ onBeforeUnmount(() => {
<button
type="button"
@click="close"
class="image-detail-close-button absolute right-4 top-4 z-30 flex h-10 min-h-10 w-10 min-w-10 max-w-10 appearance-none items-center justify-center overflow-hidden border-0 bg-transparent p-0 text-white/70 shadow-none transition-colors hover:text-white [&_svg]:stroke-[2.5]"
class="image-detail-close-button oc-overlay-button absolute right-4 top-4 z-30 h-10 w-10 rounded-full [&_svg]:stroke-[2.5]"
aria-label="关闭大图"
title="关闭大图"
>
@@ -223,7 +223,7 @@ onBeforeUnmount(() => {
<button
type="button"
@click="toggleInfo"
class="absolute bottom-4 right-4 z-30 flex h-11 w-11 items-center justify-center rounded-full bg-transparent text-white drop-shadow-[0_2px_8px_rgba(15,23,42,0.9)] transition-colors hover:text-sky-100"
class="oc-overlay-button absolute bottom-4 right-4 z-30 h-11 w-11 rounded-full drop-shadow-[0_2px_8px_rgba(15,23,42,0.9)]"
:aria-label="infoOpen ? '隐藏详细信息' : '查看详细信息'"
:title="infoOpen ? '隐藏详细信息' : '查看详细信息'"
>
@@ -254,7 +254,7 @@ onBeforeUnmount(() => {
<button
type="button"
@click="infoOpen = false"
class="absolute right-3 top-3 rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
class="oc-icon-button oc-icon-button--small absolute right-3 top-3 rounded-full"
aria-label="隐藏详细信息"
title="隐藏详细信息"
>
+3 -1
View File
@@ -158,7 +158,9 @@ onBeforeUnmount(() => {
</div>
<button
type="button"
class="p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
class="oc-icon-button"
aria-label="关闭地图选点窗口"
title="关闭地图选点窗口"
@click="close"
>
✕
+1 -1
View File
@@ -77,7 +77,7 @@ async function initializeMap() {
marker = new amapLib.Marker({
position: center.value,
title: props.locationName || '拍摄位置',
content: '<div class="h-4 w-4 rounded-full border-2 border-white bg-teal-500 shadow-[0_0_0_4px_rgba(20,184,166,0.22)]"></div>',
content: '<div class="h-4 w-4 rounded-full border-2 border-white bg-teal-500 shadow-[0_0_0_4px_rgba(52,116,93,0.24)]"></div>',
offset: new amapLib.Pixel(-8, -8),
} as AMap.MarkerOptions)
marker.setMap(mapInstance)
+4 -4
View File
@@ -207,7 +207,7 @@ onBeforeUnmount(() => {
</div>
<button
type="button"
class="quick-upload-close flex h-9 w-9 items-center justify-center border border-slate-200 bg-slate-50 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800"
class="quick-upload-close oc-icon-button"
aria-label="关闭快捷上传"
title="关闭快捷上传"
@click="close"
@@ -252,8 +252,8 @@ onBeforeUnmount(() => {
<img :src="activeItem.preview" alt="图片预览" class="h-56 w-full object-contain" />
</div>
<div class="mt-3 flex items-center justify-between gap-2">
<button type="button" class="text-sm font-medium text-sky-700 hover:text-sky-900" @click="fileInput?.click()">重新选择</button>
<button type="button" class="text-sm font-medium text-rose-600 hover:text-rose-800" @click="handleRemove(activeItem.id)">移除</button>
<button type="button" class="oc-text-button text-sm" @click="fileInput?.click()">重新选择</button>
<button type="button" class="oc-text-button oc-text-button--danger text-sm" @click="handleRemove(activeItem.id)">移除</button>
</div>
</div>
@@ -320,7 +320,7 @@ onBeforeUnmount(() => {
</div>
<button
type="button"
class="flex h-10 w-10 items-center justify-center border border-sky-200 bg-sky-50 text-sky-700 transition-colors hover:bg-sky-100"
class="oc-icon-button oc-icon-button--sky"
title="地图选点"
aria-label="地图选点"
@click="openMapPicker"
+22 -22
View File
@@ -138,7 +138,7 @@ async function handleLogout() {
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex h-16 items-center justify-between">
<RouterLink to="/" class="flex items-center gap-3">
<div class="flex h-10 w-10 items-center justify-center border border-slate-300 bg-[linear-gradient(135deg,#ecfeff_0%,#ccfbf1_100%)] shadow-[4px_4px_0_0_rgba(15,23,42,0.08)]">
<div class="flex h-10 w-10 items-center justify-center border border-sky-200 bg-sky-100 shadow-[4px_4px_0_0_rgba(15,23,42,0.08)]">
<svg
class="h-8 w-8"
viewBox="0 0 16 16"
@@ -173,21 +173,21 @@ async function handleLogout() {
<nav class="hidden items-center gap-2 border border-slate-200 bg-white px-2 py-2 shadow-[6px_6px_0_0_rgba(15,23,42,0.05)] md:flex">
<RouterLink
to="/"
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
class="oc-nav-link"
:class="route.name === 'map' ? activeNavClass : inactiveNavClass"
>
地图
</RouterLink>
<RouterLink
to="/community"
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
class="oc-nav-link"
:class="route.name === 'community' ? activeNavClass : inactiveNavClass"
>
社区
</RouterLink>
<RouterLink
to="/gallery"
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
class="oc-nav-link"
:class="route.name === 'gallery' ? activeNavClass : inactiveNavClass"
>
画廊
@@ -195,7 +195,7 @@ async function handleLogout() {
<RouterLink
v-if="authStore.isLoggedIn"
to="/encyclopedia"
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
class="oc-nav-link"
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? activeNavClass : inactiveNavClass"
>
图鉴
@@ -210,7 +210,7 @@ async function handleLogout() {
class="no-underline"
>
<span
class="inline-flex h-8 items-center border border-sky-200 bg-sky-100 px-3 text-sm font-medium uppercase tracking-[0.12em] text-sky-800 shadow-[4px_4px_0_0_rgba(14,165,233,0.14)] transition-[background-color,color,transform,box-shadow] hover:-translate-x-px hover:-translate-y-px hover:bg-sky-50 hover:text-sky-900 hover:shadow-[5px_5px_0_0_rgba(14,165,233,0.18)] md:h-10 md:px-4"
class="oc-header-button oc-header-button--sky uppercase tracking-[0.12em]"
:class="route.name === 'upload' ? 'ring-1 ring-sky-300' : ''"
>
上传
@@ -227,8 +227,8 @@ async function handleLogout() {
>
<button
type="button"
class="inline-flex h-8 max-w-[8.5rem] items-center gap-2 border border-teal-100 bg-white/80 px-3 text-sm font-medium text-teal-700 shadow-[3px_3px_0_0_rgba(20,184,166,0.08)] transition-[background-color,color,transform,box-shadow,border-color] hover:-translate-x-px hover:-translate-y-px hover:border-teal-200 hover:bg-teal-50/70 hover:text-teal-800 hover:shadow-[4px_4px_0_0_rgba(20,184,166,0.12)] md:h-10 md:max-w-none"
:class="route.name === 'profile' || route.name === 'profile-settings' ? 'border-teal-200 bg-teal-50/80 text-teal-800 ring-1 ring-teal-200 shadow-[4px_4px_0_0_rgba(20,184,166,0.12)]' : ''"
class="oc-header-button oc-header-button--teal max-w-[8.5rem] gap-2 md:max-w-none"
:class="route.name === 'profile' || route.name === 'profile-settings' ? 'border-teal-200 bg-teal-50/80 text-teal-800 ring-1 ring-teal-200 shadow-[4px_4px_0_0_rgba(52,116,93,0.16)]' : ''"
aria-haspopup="menu"
:aria-expanded="accountCardOpen"
>
@@ -244,9 +244,9 @@ async function handleLogout() {
role="menu"
>
<div class="border border-slate-200 bg-white shadow-[8px_8px_0_0_rgba(15,23,42,0.08)]">
<div class="border-b border-slate-200 bg-[linear-gradient(180deg,#f0fdfa_0%,#ffffff_100%)] px-4 py-4">
<div class="border-b border-slate-200 bg-[linear-gradient(180deg,#f2faf6_0%,#ffffff_100%)] px-4 py-4">
<div class="flex items-center gap-3">
<div class="flex h-11 w-11 shrink-0 items-center justify-center border border-teal-200 bg-[linear-gradient(135deg,#ecfeff_0%,#ccfbf1_100%)] text-lg font-bold text-teal-800">
<div class="flex h-11 w-11 shrink-0 items-center justify-center border border-teal-200 bg-[linear-gradient(135deg,#eef9f3_0%,#d9efe3_100%)] text-lg font-bold text-teal-800">
{{ displayUsername.slice(0, 1).toUpperCase() }}
</div>
<div class="min-w-0">
@@ -265,7 +265,7 @@ async function handleLogout() {
<div class="py-2">
<RouterLink
to="/profile"
class="flex items-center gap-3 px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-teal-50 hover:text-teal-800"
class="oc-menu-item"
role="menuitem"
>
<NIcon size="18"><User /></NIcon>
@@ -273,7 +273,7 @@ async function handleLogout() {
</RouterLink>
<RouterLink
to="/profile/settings"
class="flex items-center gap-3 px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-teal-50 hover:text-teal-800"
class="oc-menu-item"
role="menuitem"
>
<NIcon size="18"><Settings /></NIcon>
@@ -282,7 +282,7 @@ async function handleLogout() {
<RouterLink
v-if="authStore.isAdmin"
to="/admin"
class="flex items-center gap-3 px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-amber-50 hover:text-amber-700"
class="oc-menu-item oc-menu-item--amber"
role="menuitem"
>
<NIcon size="18"><Shield /></NIcon>
@@ -293,7 +293,7 @@ async function handleLogout() {
<div class="border-t border-slate-200 p-2">
<button
type="button"
class="flex w-full items-center gap-3 px-2 py-2.5 text-left text-sm font-medium text-rose-600 transition-colors hover:bg-rose-50 hover:text-rose-700"
class="oc-menu-item oc-menu-item--danger px-2"
role="menuitem"
@click="handleLogout"
>
@@ -312,7 +312,7 @@ async function handleLogout() {
class="no-underline"
>
<span
class="inline-flex h-8 items-center border border-slate-200 bg-white/80 px-3 text-sm font-medium text-slate-700 shadow-[3px_3px_0_0_rgba(15,23,42,0.06)] transition-[background-color,color,transform,box-shadow,border-color] hover:-translate-x-px hover:-translate-y-px hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800 hover:shadow-[4px_4px_0_0_rgba(15,23,42,0.08)] md:h-10 md:px-4"
class="oc-header-button oc-header-button--neutral"
:class="route.name === 'login' ? 'bg-teal-50 ring-0 ring-teal-100 text-teal-800' : ''"
>
登录
@@ -323,7 +323,7 @@ async function handleLogout() {
class="no-underline"
>
<span
class="inline-flex h-8 items-center border border-sky-200 bg-sky-100 px-3 text-sm font-medium text-sky-800 shadow-[4px_4px_0_0_rgba(14,165,233,0.14)] transition-[background-color,color,transform,box-shadow] hover:-translate-x-px hover:-translate-y-px hover:bg-sky-50 hover:text-sky-900 hover:shadow-[5px_5px_0_0_rgba(14,165,233,0.18)] md:h-10 md:px-4"
class="oc-header-button oc-header-button--sky"
:class="route.name === 'register' ? 'ring-0 ring-sky-300' : ''"
>
注册
@@ -337,21 +337,21 @@ async function handleLogout() {
<nav class="flex items-center gap-2 overflow-x-auto border-t border-slate-200/80 py-2 md:hidden">
<RouterLink
to="/"
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
class="oc-nav-link shrink-0"
:class="route.name === 'map' ? activeNavClass : inactiveNavClass"
>
地图
</RouterLink>
<RouterLink
to="/community"
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
class="oc-nav-link shrink-0"
:class="route.name === 'community' ? activeNavClass : inactiveNavClass"
>
社区
</RouterLink>
<RouterLink
to="/gallery"
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
class="oc-nav-link shrink-0"
:class="route.name === 'gallery' ? activeNavClass : inactiveNavClass"
>
画廊
@@ -359,7 +359,7 @@ async function handleLogout() {
<RouterLink
v-if="authStore.isLoggedIn"
to="/encyclopedia"
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
class="oc-nav-link shrink-0"
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? activeNavClass : inactiveNavClass"
>
图鉴
@@ -367,7 +367,7 @@ async function handleLogout() {
<RouterLink
v-if="!authStore.loading && authStore.isLoggedIn"
to="/profile"
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
class="oc-nav-link shrink-0"
:class="route.name === 'profile' ? activeNavClass : inactiveNavClass"
>
主页
@@ -379,7 +379,7 @@ async function handleLogout() {
<button
v-if="headerHidden"
type="button"
class="absolute left-1/2 top-3 flex -translate-x-1/2 items-center gap-2 border border-slate-300 bg-white/94 px-3 py-1.5 text-xs font-medium tracking-[0.16em] text-slate-700 shadow-[4px_4px_0_0_rgba(15,23,42,0.08)] backdrop-blur transition-colors hover:border-slate-900 hover:text-slate-900"
class="absolute left-1/2 top-3 flex -translate-x-1/2 items-center gap-2 border border-slate-300 bg-white/94 px-3 py-1.5 text-xs font-medium tracking-[0.16em] text-slate-700 shadow-[4px_4px_0_0_rgba(15,23,42,0.08)] backdrop-blur transition-colors hover:border-teal-300 hover:bg-teal-50 hover:text-teal-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600"
@click="showHeader(true)"
>
<span>▼</span>
@@ -379,7 +379,7 @@ function formatMonthTooltip(monthKey: string) {
v-for="cell in week"
:key="cell.date"
type="button"
class="relative h-3.5 w-3.5 border transition-transform hover:scale-125"
class="oc-heatmap-cell relative h-3.5 w-3.5 border hover:scale-125"
:class="[
getCellClass(cell),
props.activeDate === cell.date ? 'ring-2 ring-slate-900 ring-offset-1' : '',
-1
View File
@@ -6,7 +6,6 @@ export function loadAMap() {
version: '2.0',
plugins: [
'AMap.Scale',
'AMap.ToolBar',
'AMap.ControlBar',
'AMap.Geolocation',
'AMap.Marker',
+8 -5
View File
@@ -3,12 +3,15 @@ import type { GlobalThemeOverrides } from 'naive-ui'
/** Keep these values aligned with the semantic tokens in styles/tokens.css. */
export const openCloudThemeOverrides: GlobalThemeOverrides = {
common: {
primaryColor: '#0f766e',
primaryColorHover: '#115e59',
primaryColorPressed: '#134e4a',
primaryColorSuppl: '#0f766e',
primaryColor: '#34745d',
primaryColorHover: '#2d5f4e',
primaryColorPressed: '#274c40',
primaryColorSuppl: '#34745d',
infoColor: '#0369a1',
successColor: '#0f766e',
successColor: '#4d7f3b',
successColorHover: '#3f6633',
successColorPressed: '#35522c',
successColorSuppl: '#4d7f3b',
warningColor: '#d97706',
errorColor: '#dc2626',
borderRadius: '0px',
+309 -37
View File
@@ -117,8 +117,270 @@
}
.oc-map-button {
@apply flex h-10 w-10 items-center justify-center bg-white shadow-md transition-colors hover:bg-slate-50;
border-radius: 0.5rem !important;
@apply flex h-10 w-10 items-center justify-center border border-slate-200 bg-white text-slate-600;
box-shadow: var(--oc-shadow-xs);
}
/* Shared interactive contracts */
.oc-panel-button:not(.n-button),
.oc-header-button,
.oc-icon-button,
.oc-map-button,
.oc-choice-button,
.oc-text-button,
.oc-menu-item,
.oc-nav-link,
.oc-interactive-card {
transition:
background-color var(--oc-duration-fast) var(--oc-ease-standard),
color var(--oc-duration-fast) var(--oc-ease-standard),
border-color var(--oc-duration-fast) var(--oc-ease-standard),
box-shadow var(--oc-duration-fast) var(--oc-ease-standard),
transform var(--oc-duration-fast) var(--oc-ease-standard);
}
.oc-panel-button:not(.n-button) {
@apply inline-flex min-h-9 items-center justify-center border px-3 py-2 text-sm font-medium;
}
.oc-panel-button:not(.n-button):not(:disabled):hover,
.oc-header-button:not(:disabled):hover,
.oc-icon-button:not(:disabled):hover,
.oc-map-button:not(:disabled):hover,
.oc-choice-button:not(:disabled):hover {
transform: translate(-1px, -1px);
}
.oc-panel-button--neutral:not(.n-button) {
@apply border-slate-200 bg-white text-slate-600;
box-shadow: var(--oc-shadow-xs);
}
.oc-panel-button--neutral:not(.n-button):hover,
.oc-panel-button--neutral:not(.n-button):focus-visible {
@apply border-teal-200 bg-teal-50 text-teal-800;
box-shadow: var(--oc-shadow-primary);
}
.oc-panel-button--sky:not(.n-button) {
@apply border-sky-200 bg-sky-100 text-sky-800;
box-shadow: var(--oc-shadow-sky);
}
.oc-panel-button--sky:not(.n-button):hover,
.oc-panel-button--sky:not(.n-button):focus-visible {
@apply border-sky-300 bg-sky-50 text-sky-900;
box-shadow: 5px 5px 0 0 rgb(14 165 233 / 18%);
}
.oc-panel-button--teal:not(.n-button) {
@apply border-teal-200 bg-teal-50 text-teal-800;
box-shadow: var(--oc-shadow-teal);
}
.oc-panel-button--teal:not(.n-button):hover,
.oc-panel-button--teal:not(.n-button):focus-visible {
@apply border-teal-300 bg-teal-100 text-teal-900;
box-shadow: var(--oc-shadow-primary-hover);
}
.oc-panel-button--danger:not(.n-button) {
@apply border-rose-200 bg-rose-50 text-rose-700;
box-shadow: 4px 4px 0 0 rgb(244 63 94 / 10%);
}
.oc-panel-button--danger:not(.n-button):hover,
.oc-panel-button--danger:not(.n-button):focus-visible {
@apply border-rose-300 bg-rose-100 text-rose-800;
box-shadow: 5px 5px 0 0 rgb(244 63 94 / 14%);
}
.oc-panel-button--amber:not(.n-button) {
@apply border-amber-200 bg-amber-50 text-amber-800;
box-shadow: 4px 4px 0 0 rgb(245 158 11 / 10%);
}
.oc-panel-button--amber:not(.n-button):hover,
.oc-panel-button--amber:not(.n-button):focus-visible {
@apply border-amber-300 bg-amber-100 text-amber-900;
box-shadow: 5px 5px 0 0 rgb(245 158 11 / 14%);
}
.oc-icon-button {
@apply flex h-10 w-10 shrink-0 items-center justify-center border border-slate-200 bg-white text-slate-600;
box-shadow: var(--oc-shadow-xs);
}
.oc-icon-button:hover,
.oc-icon-button:focus-visible,
.oc-map-button:hover,
.oc-map-button:focus-visible {
@apply border-teal-200 bg-teal-50 text-teal-800;
box-shadow: var(--oc-shadow-primary);
}
.oc-icon-button--small {
@apply h-8 w-8;
}
.oc-icon-button--tiny {
@apply h-6 w-6 text-xs;
box-shadow: 2px 2px 0 0 rgb(15 23 42 / 8%);
}
.oc-icon-button--sky {
@apply border-sky-200 bg-sky-50 text-sky-700;
box-shadow: 3px 3px 0 0 rgb(14 165 233 / 12%);
}
.oc-icon-button--sky:hover,
.oc-icon-button--sky:focus-visible {
@apply border-sky-300 bg-sky-100 text-sky-900;
box-shadow: 4px 4px 0 0 rgb(14 165 233 / 16%);
}
.oc-icon-button--danger {
@apply border-rose-300 bg-rose-500 text-white;
box-shadow: 3px 3px 0 0 rgb(159 18 57 / 18%);
}
.oc-icon-button--danger:hover,
.oc-icon-button--danger:focus-visible {
@apply border-rose-400 bg-rose-600 text-white;
box-shadow: 4px 4px 0 0 rgb(159 18 57 / 22%);
}
.oc-overlay-button {
@apply flex shrink-0 items-center justify-center text-white/75 hover:bg-white/10 hover:text-white;
transition:
background-color var(--oc-duration-fast) var(--oc-ease-standard),
color var(--oc-duration-fast) var(--oc-ease-standard),
transform var(--oc-duration-fast) var(--oc-ease-standard);
}
.oc-text-button {
@apply inline-flex items-center font-medium text-sky-700 underline-offset-4 hover:text-sky-900 hover:underline;
}
.oc-text-button--muted {
@apply text-slate-500 hover:text-slate-800;
}
.oc-text-button--danger {
@apply text-rose-600 hover:text-rose-800;
}
.oc-choice-button {
@apply inline-flex min-h-9 items-center justify-center border px-3 py-2 text-sm font-medium;
}
.oc-choice-button--inactive {
@apply border-slate-200 bg-white text-slate-600 hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800;
box-shadow: var(--oc-shadow-xs);
}
.oc-choice-button--active {
@apply border-teal-200 bg-teal-50 text-teal-800;
box-shadow: var(--oc-shadow-primary);
}
.oc-menu-item {
@apply flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-medium text-slate-700 hover:bg-teal-50 hover:text-teal-800;
}
.oc-menu-item--danger {
@apply text-rose-600 hover:bg-rose-50 hover:text-rose-700;
}
.oc-menu-item--amber {
@apply hover:bg-amber-50 hover:text-amber-700;
}
.oc-nav-link {
@apply px-3 py-2 text-sm font-medium uppercase tracking-[0.12em];
}
.oc-header-button {
@apply inline-flex h-8 items-center justify-center border px-3 text-sm font-medium md:h-10 md:px-4;
}
.oc-header-button--neutral {
@apply border-slate-200 bg-white/80 text-slate-700 hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800;
box-shadow: var(--oc-shadow-xs);
}
.oc-header-button--sky {
@apply border-sky-200 bg-sky-100 text-sky-800 hover:border-sky-300 hover:bg-sky-50 hover:text-sky-900;
box-shadow: var(--oc-shadow-sky);
}
.oc-header-button--teal {
@apply border-teal-100 bg-white/80 text-teal-700 hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800;
box-shadow: var(--oc-shadow-teal);
}
.oc-header-button:hover {
box-shadow: 5px 5px 0 0 rgb(15 23 42 / 8%);
}
.oc-interactive-card:focus-visible {
@apply outline-none ring-2 ring-sky-600 ring-offset-2;
}
.oc-interactive-row {
transition:
background-color var(--oc-duration-fast) var(--oc-ease-standard),
color var(--oc-duration-fast) var(--oc-ease-standard),
box-shadow var(--oc-duration-fast) var(--oc-ease-standard);
}
.oc-interactive-row:hover {
@apply bg-sky-50/60;
}
.oc-interactive-row:focus-visible,
.oc-heatmap-cell:focus-visible {
@apply outline-none ring-2 ring-sky-600 ring-offset-2;
}
.oc-heatmap-cell {
transition:
border-color var(--oc-duration-fast) var(--oc-ease-standard),
box-shadow var(--oc-duration-fast) var(--oc-ease-standard),
transform var(--oc-duration-fast) var(--oc-ease-standard);
}
:is(
.oc-primary-button,
.oc-panel-button,
.oc-header-button,
.oc-icon-button,
.oc-overlay-button,
.oc-map-button,
.oc-choice-button,
.oc-text-button,
.oc-menu-item,
.oc-nav-link
):focus-visible {
outline: 2px solid rgb(2 132 199);
outline-offset: 2px;
}
.oc-overlay-button:focus-visible {
outline-color: rgb(125 211 252);
}
:is(.oc-panel-button, .oc-header-button, .oc-icon-button, .oc-map-button, .oc-choice-button):disabled {
@apply cursor-not-allowed border-slate-200 bg-slate-100 text-slate-400 shadow-none;
transform: none !important;
}
.oc-map-button:disabled {
box-shadow: var(--oc-shadow-xs);
}
:is(.oc-panel-button, .oc-primary-button, .oc-header-button, .oc-icon-button, .oc-map-button, .oc-choice-button):not(:disabled):active {
transform: translate(0, 0);
}
.oc-system-page {
@@ -141,6 +403,16 @@
transform var(--oc-duration-fast) var(--oc-ease-standard);
}
.oc-primary-button.n-button.n-button--disabled,
.oc-panel-button.n-button.n-button--disabled {
border-color: rgb(226 232 240) !important;
background: rgb(241 245 249) !important;
color: rgb(148 163 184) !important;
box-shadow: none !important;
opacity: 0.72;
transform: none !important;
}
.oc-primary-button.n-button .n-button__content,
.oc-panel-button.n-button .n-button__content {
font-weight: 500;
@@ -157,21 +429,21 @@
}
.oc-primary-button--teal.n-button {
border-color: rgb(153 246 228) !important;
background: rgb(255 255 255) !important;
color: rgb(17 94 89) !important;
border-color: var(--color-teal-200) !important;
background: white !important;
color: var(--color-teal-700) !important;
box-shadow: var(--oc-shadow-teal) !important;
}
.oc-primary-button--teal.n-button:hover,
.oc-primary-button--teal.n-button:focus-visible {
border-color: rgb(94 234 212) !important;
background: rgb(234 250 245) !important;
color: rgb(19 78 74) !important;
border-color: var(--color-teal-300) !important;
background: var(--color-teal-50) !important;
color: var(--color-teal-800) !important;
}
.oc-primary-button--teal.n-button:not(.n-button--disabled):hover {
box-shadow: 5px 5px 0 0 rgb(20 184 166 / 14%) !important;
box-shadow: var(--oc-shadow-primary-hover) !important;
}
.oc-primary-button--sky.n-button {
@@ -193,27 +465,27 @@
}
.oc-panel-button.n-button {
--n-color: rgb(255 255 255) !important;
--n-color-hover: rgb(240 253 250) !important;
--n-color-pressed: rgb(236 253 245) !important;
--n-color: white !important;
--n-color-hover: var(--color-teal-50) !important;
--n-color-pressed: var(--color-teal-100) !important;
--n-border: 1px solid rgb(226 232 240) !important;
--n-border-hover: 1px solid rgb(153 246 228) !important;
--n-border-pressed: 1px solid rgb(94 234 212) !important;
--n-border-hover: 1px solid var(--color-teal-200) !important;
--n-border-pressed: 1px solid var(--color-teal-300) !important;
--n-text-color: rgb(71 85 105) !important;
--n-text-color-hover: rgb(19 78 74) !important;
--n-text-color-pressed: rgb(17 94 89) !important;
--n-text-color-hover: var(--color-teal-800) !important;
--n-text-color-pressed: var(--color-teal-700) !important;
border-color: rgb(226 232 240) !important;
background: rgb(255 255 255) !important;
background: white !important;
color: rgb(71 85 105) !important;
box-shadow: var(--oc-shadow-xs) !important;
}
.oc-panel-button--neutral.n-button:hover,
.oc-panel-button--neutral.n-button:focus-visible {
border-color: rgb(153 246 228) !important;
background: rgb(240 253 250) !important;
color: rgb(19 78 74) !important;
box-shadow: 4px 4px 0 0 rgb(20 184 166 / 10%) !important;
border-color: var(--color-teal-200) !important;
background: var(--color-teal-50) !important;
color: var(--color-teal-800) !important;
box-shadow: var(--oc-shadow-primary) !important;
}
.oc-panel-button--sky.n-button {
@@ -241,27 +513,27 @@
}
.oc-panel-button--teal.n-button {
--n-color: rgb(242 250 247) !important;
--n-color-hover: rgb(234 250 245) !important;
--n-color-pressed: rgb(220 252 231) !important;
--n-border: 1px solid rgb(153 246 228) !important;
--n-border-hover: 1px solid rgb(94 234 212) !important;
--n-border-pressed: 1px solid rgb(45 212 191) !important;
--n-text-color: rgb(17 94 89) !important;
--n-text-color-hover: rgb(19 78 74) !important;
--n-text-color-pressed: rgb(19 78 74) !important;
border-color: rgb(153 246 228) !important;
background: rgb(242 250 247) !important;
color: rgb(17 94 89) !important;
--n-color: var(--color-teal-50) !important;
--n-color-hover: var(--color-teal-100) !important;
--n-color-pressed: var(--color-teal-200) !important;
--n-border: 1px solid var(--color-teal-200) !important;
--n-border-hover: 1px solid var(--color-teal-300) !important;
--n-border-pressed: 1px solid var(--color-teal-400) !important;
--n-text-color: var(--color-teal-700) !important;
--n-text-color-hover: var(--color-teal-800) !important;
--n-text-color-pressed: var(--color-teal-800) !important;
border-color: var(--color-teal-200) !important;
background: var(--color-teal-50) !important;
color: var(--color-teal-700) !important;
box-shadow: var(--oc-shadow-teal) !important;
}
.oc-panel-button--teal.n-button:hover,
.oc-panel-button--teal.n-button:focus-visible {
border-color: rgb(94 234 212) !important;
background: rgb(234 250 245) !important;
color: rgb(19 78 74) !important;
box-shadow: 5px 5px 0 0 rgb(20 184 166 / 14%) !important;
border-color: var(--color-teal-300) !important;
background: var(--color-teal-100) !important;
color: var(--color-teal-800) !important;
box-shadow: var(--oc-shadow-primary-hover) !important;
}
.oc-panel-button--danger.n-button {
+37 -8
View File
@@ -7,6 +7,33 @@
:root {
color-scheme: light;
/*
* Tailwind compatibility palettes.
* Existing teal/emerald utilities intentionally resolve to OpenCloud's
* fresh eucalyptus and leaf colors instead of Tailwind's vivid defaults.
*/
--color-teal-50: #f2faf6;
--color-teal-100: #ddf2e6;
--color-teal-200: #bce4cf;
--color-teal-300: #8fceb0;
--color-teal-400: #60b18d;
--color-teal-500: #438f71;
--color-teal-600: #34745d;
--color-teal-700: #2d5f4e;
--color-teal-800: #274c40;
--color-teal-900: #213e35;
--color-emerald-50: #f4faef;
--color-emerald-100: #e5f3d9;
--color-emerald-200: #cce7b7;
--color-emerald-300: #a8d58d;
--color-emerald-400: #7fbc63;
--color-emerald-500: #609d48;
--color-emerald-600: #4d7f3b;
--color-emerald-700: #3f6633;
--color-emerald-800: #35522c;
--color-emerald-900: #2d4427;
--oc-font-sans: 'IBM Plex Sans', 'Noto Sans SC', 'PingFang SC', sans-serif;
--oc-font-mono: 'IBM Plex Mono', 'SFMono-Regular', monospace;
@@ -17,20 +44,20 @@
--oc-color-text-secondary: #334155;
--oc-color-text-muted: #64748b;
--oc-color-border: #d9e3ee;
--oc-color-primary: #0f766e;
--oc-color-primary-hover: #115e59;
--oc-color-primary-pressed: #134e4a;
--oc-color-primary: #34745d;
--oc-color-primary-hover: #2d5f4e;
--oc-color-primary-pressed: #274c40;
--oc-color-info: #0369a1;
--oc-color-success: #0f766e;
--oc-color-success: #4d7f3b;
--oc-color-warning: #d97706;
--oc-color-danger: #dc2626;
--oc-color-selection: rgb(15 118 110 / 18%);
--oc-color-selection: rgb(52 116 93 / 20%);
--oc-gradient-canvas:
radial-gradient(circle at top left, rgb(12 148 136 / 12%), transparent 28%),
radial-gradient(circle at top left, rgb(52 116 93 / 11%), transparent 28%),
linear-gradient(180deg, #f8fbfd 0%, #eef4f8 100%);
--oc-gradient-page: linear-gradient(180deg, #e0f2fe 0%, #f8fafc 100%);
--oc-gradient-page-soft: linear-gradient(180deg, #f0fdfa 0%, #f8fafc 100%);
--oc-gradient-page-soft: linear-gradient(180deg, #f2faf6 0%, #f8fafc 100%);
--oc-gradient-grid:
linear-gradient(rgb(15 23 42 / 3%) 1px, transparent 1px),
linear-gradient(90deg, rgb(15 23 42 / 3%) 1px, transparent 1px);
@@ -41,7 +68,9 @@
--oc-shadow-lg: 8px 8px 0 0 rgb(15 23 42 / 8%);
--oc-shadow-xl: 12px 12px 0 0 rgb(15 23 42 / 8%);
--oc-shadow-sky: 4px 4px 0 0 rgb(14 165 233 / 14%);
--oc-shadow-teal: 4px 4px 0 0 rgb(20 184 166 / 10%);
--oc-shadow-primary: 4px 4px 0 0 rgb(52 116 93 / 14%);
--oc-shadow-primary-hover: 5px 5px 0 0 rgb(52 116 93 / 20%);
--oc-shadow-teal: var(--oc-shadow-primary);
--oc-duration-fast: 160ms;
--oc-ease-standard: ease;
+2 -2
View File
@@ -6,7 +6,7 @@ declare namespace AMap {
remove(layer: unknown): void
destroy(): void
setCenter(center: [number, number]): void
setZoom(zoom: number): void
setZoom(zoom: number, immediately?: boolean, duration?: number): void
setPitch(pitch: number): void
getPitch(): number
setRotation(rotation: number): void
@@ -14,7 +14,7 @@ declare namespace AMap {
on(event: string, callback: (...args: unknown[]) => void): void
off(event: string, callback: (...args: unknown[]) => void): void
getCenter(): { lng: number; lat: number }
getZoom(): number
getZoom(digits?: number): number
getAllOverlays(type?: string): unknown[]
addOverlay(overlay: unknown): void
removeOverlay(overlay: unknown): void
+52 -31
View File
@@ -519,7 +519,7 @@ onMounted(loadAdminData)
<button
type="button"
class="hidden h-8 w-8 items-center justify-center border border-slate-200 bg-white text-slate-500 shadow-[3px_3px_0_0_rgba(15,23,42,0.06)] transition-colors hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 lg:absolute lg:-right-4 lg:top-6 lg:z-20 lg:flex"
class="oc-icon-button oc-icon-button--small hidden lg:absolute lg:-right-4 lg:top-6 lg:z-20 lg:flex"
:aria-label="sidebarCollapsed ? '展开侧边栏' : '折叠侧边栏'"
:aria-expanded="!sidebarCollapsed"
:title="sidebarCollapsed ? '展开侧边栏' : '折叠侧边栏'"
@@ -538,7 +538,7 @@ onMounted(loadAdminData)
type="button"
class="group relative flex min-w-[150px] items-center gap-3 overflow-hidden border px-3 py-3 text-left transition-[background-color,border-color,color,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-2 lg:w-full lg:min-w-0"
:class="activeTab === tab.key
? 'border-teal-200 bg-teal-50 text-teal-900 shadow-[3px_3px_0_0_rgba(20,184,166,0.14)]'
? 'border-teal-200 bg-teal-50 text-teal-900 shadow-[3px_3px_0_0_rgba(52,116,93,0.16)]'
: 'border-transparent bg-white text-slate-600 hover:border-slate-200 hover:bg-slate-50 hover:text-slate-950'"
:aria-current="activeTab === tab.key ? 'page' : undefined"
:aria-label="tab.label"
@@ -580,7 +580,7 @@ onMounted(loadAdminData)
type="button"
class="flex h-14 min-w-0 flex-1 items-center gap-3 overflow-hidden border px-3 py-2 text-left transition-[background-color,border-color,color,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 lg:w-full"
:class="accountMenuOpen
? 'border-teal-200 bg-teal-50 text-teal-800 shadow-[3px_3px_0_0_rgba(20,184,166,0.12)]'
? 'border-teal-200 bg-teal-50 text-teal-800 shadow-[3px_3px_0_0_rgba(52,116,93,0.16)]'
: 'border-slate-200 bg-slate-50 text-slate-700 hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800'"
aria-label="打开管理员账户菜单"
aria-haspopup="menu"
@@ -615,9 +615,9 @@ onMounted(loadAdminData)
role="menu"
aria-label="管理员账户"
>
<div class="border-b border-slate-200 bg-[linear-gradient(180deg,#f0fdfa_0%,#ffffff_100%)] px-4 py-4">
<div class="border-b border-slate-200 bg-[linear-gradient(180deg,#f2faf6_0%,#ffffff_100%)] px-4 py-4">
<div class="flex items-center gap-3">
<div class="flex h-11 w-11 shrink-0 items-center justify-center border border-teal-200 bg-[linear-gradient(135deg,#ecfeff_0%,#ccfbf1_100%)] text-lg font-bold text-teal-800">
<div class="flex h-11 w-11 shrink-0 items-center justify-center border border-teal-200 bg-[linear-gradient(135deg,#eef9f3_0%,#d9efe3_100%)] text-lg font-bold text-teal-800">
{{ displayInitial }}
</div>
<div class="min-w-0">
@@ -651,7 +651,7 @@ onMounted(loadAdminData)
<div class="border-t border-slate-200 p-2">
<button
type="button"
class="flex w-full items-center gap-3 px-2 py-2.5 text-left text-sm font-medium text-rose-600 transition-colors hover:bg-rose-50 hover:text-rose-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-rose-500"
class="oc-menu-item oc-menu-item--danger px-2"
role="menuitem"
@click="handleLogout"
>
@@ -715,7 +715,7 @@ onMounted(loadAdminData)
</NAlert>
<section v-if="loading" class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div v-for="n in 8" :key="n" class="border border-slate-200 bg-white p-5">
<div v-for="n in 8" :key="n" class="oc-panel-card border border-slate-200 bg-white p-5">
<NSkeleton class="h-4 w-1/2" />
<NSkeleton class="mt-4 h-8 w-2/3" />
<NSkeleton class="mt-3 h-3 w-full" />
@@ -728,8 +728,8 @@ onMounted(loadAdminData)
<div
class="flex flex-col justify-between gap-5 border p-6 sm:flex-row sm:items-center"
:class="dashboardStats.pending
? 'border-amber-200 bg-[linear-gradient(135deg,#fffbeb_0%,#fff_52%,#f0fdfa_100%)] shadow-[5px_5px_0_0_rgba(245,158,11,0.12)]'
: 'border-teal-200 bg-[linear-gradient(135deg,#f0fdfa_0%,#fff_55%,#eff6ff_100%)] shadow-[5px_5px_0_0_rgba(20,184,166,0.10)]'"
? 'border-amber-200 bg-[linear-gradient(135deg,#fffbeb_0%,#fff_52%,#f2faf6_100%)] shadow-[5px_5px_0_0_rgba(245,158,11,0.12)]'
: 'border-teal-200 bg-[linear-gradient(135deg,#f2faf6_0%,#fff_55%,#eff6ff_100%)] shadow-[5px_5px_0_0_rgba(52,116,93,0.16)]'"
>
<div class="flex items-center gap-5">
<div
@@ -753,18 +753,18 @@ onMounted(loadAdminData)
</div>
</div>
<NButton
v-if="dashboardStats.pending > 0"
type="default"
secondary
strong
class="oc-panel-button oc-panel-button--amber shrink-0"
:disabled="!dashboardStats.pending"
@click="activeTab = 'review'"
>
进入审核队列
</NButton>
</div>
<div class="grid grid-cols-2 border border-slate-200 bg-white">
<div class="oc-panel-card grid grid-cols-2 border border-slate-200 bg-white">
<div class="border-b border-r border-slate-100 p-4">
<p class="text-xs font-medium text-slate-500">今日上传</p>
<p class="mt-2 text-2xl font-bold text-sky-800">{{ dashboardStats.todayUploads }}</p>
@@ -785,7 +785,7 @@ onMounted(loadAdminData)
</div>
<div class="grid gap-5 lg:grid-cols-[1.05fr_0.95fr]">
<div class="border border-slate-200 bg-white p-5 sm:p-6">
<div class="oc-panel-card border border-slate-200 bg-white p-5 sm:p-6">
<div class="flex items-start justify-between gap-4">
<div>
<p class="oc-section-label">Review Health</p>
@@ -823,7 +823,7 @@ onMounted(loadAdminData)
</div>
</div>
<div class="border border-slate-200 bg-white p-5 sm:p-6">
<div class="oc-panel-card border border-slate-200 bg-white p-5 sm:p-6">
<div>
<p class="oc-section-label">Cloud Type Mix</p>
<h3 class="mt-2 text-xl font-bold text-slate-950">云型分布</h3>
@@ -849,22 +849,29 @@ onMounted(loadAdminData)
</div>
</div>
<div class="border border-slate-200 bg-white">
<div class="oc-panel-card border border-slate-200 bg-white">
<div class="flex items-end justify-between gap-4 border-b border-slate-200 px-5 py-4">
<div>
<p class="oc-section-label">Latest Activity</p>
<h3 class="mt-2 text-xl font-bold text-slate-950">最近上传</h3>
</div>
<button type="button" class="text-sm font-medium text-sky-700 hover:text-sky-900" @click="activeTab = 'images'">
<NButton
size="small"
type="default"
secondary
strong
class="oc-panel-button oc-panel-button--neutral"
@click="activeTab = 'images'"
>
查看全部图片
</button>
</NButton>
</div>
<div v-if="recentImages.length" class="divide-y divide-slate-100">
<button
v-for="item in recentImages"
:key="item.id"
type="button"
class="grid w-full grid-cols-[52px_minmax(0,1fr)_auto] items-center gap-3 px-5 py-3 text-left transition-colors hover:bg-sky-50/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-sky-600 sm:grid-cols-[52px_minmax(0,1fr)_minmax(120px,0.5fr)_auto]"
class="oc-interactive-row grid w-full grid-cols-[52px_minmax(0,1fr)_auto] items-center gap-3 px-5 py-3 text-left sm:grid-cols-[52px_minmax(0,1fr)_minmax(120px,0.5fr)_auto]"
@click="selectedImage = item"
>
<img :src="item.thumbnail_url || item.image_url" :alt="item.cloudTypeName" class="h-11 w-13 object-cover" />
@@ -883,13 +890,20 @@ onMounted(loadAdminData)
</section>
<section v-else-if="activeTab === 'review'">
<div class="mb-4 flex flex-col gap-3 border border-slate-200 bg-white p-4 md:flex-row md:items-center md:justify-between">
<div class="oc-panel-card mb-4 flex flex-col gap-3 border border-slate-200 bg-white p-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-xl font-bold text-slate-950">待审核队列</h2>
<p class="oc-field-help">共 {{ pendingImages.length }} 张待处理图片。</p>
</div>
<div class="flex flex-wrap gap-2">
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="toggleAllPendingSelection">
<NButton
v-if="pendingImages.length > 0"
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--neutral"
@click="toggleAllPendingSelection"
>
{{ selectedReviewCount === pendingImages.length && pendingImages.length ? '取消全选' : '全选待审核' }}
</NButton>
<template v-if="selectedReviewCount">
@@ -918,7 +932,7 @@ onMounted(loadAdminData)
</div>
<div v-if="activeReviewImage" class="grid min-w-0 gap-4 xl:grid-cols-[220px_minmax(0,1fr)]">
<aside class="min-w-0 self-start border border-slate-200 bg-white xl:sticky xl:top-20 xl:max-h-[calc(100vh-6rem)]">
<aside class="oc-panel-card min-w-0 self-start border border-slate-200 bg-white xl:sticky xl:top-20 xl:max-h-[calc(100vh-6rem)]">
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3">
<div>
<p class="text-sm font-semibold text-slate-900">审核队列</p>
@@ -939,7 +953,7 @@ onMounted(loadAdminData)
>
<button
type="button"
class="flex min-w-0 flex-1 items-center gap-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600"
class="oc-interactive-row flex min-w-0 flex-1 items-center gap-3 text-left"
:aria-pressed="activeReviewImage.id === item.id"
@click="activeReviewId = item.id"
>
@@ -965,7 +979,7 @@ onMounted(loadAdminData)
<div class="grid min-w-0 gap-4 lg:grid-cols-[minmax(0,1fr)_300px]">
<div class="min-w-0 space-y-4">
<div class="border border-slate-200 bg-white">
<div class="oc-panel-card border border-slate-200 bg-white">
<div class="flex items-center justify-between gap-4 border-b border-slate-200 px-4 py-3">
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-slate-900">{{ activeReviewImage.cloudTypeName }}</p>
@@ -995,7 +1009,7 @@ onMounted(loadAdminData)
</div>
</div>
<div class="border border-slate-200 bg-white p-5">
<div class="oc-panel-card border border-slate-200 bg-white p-5">
<p class="oc-section-label">Uploader Notes</p>
<h3 class="mt-2 text-lg font-bold text-slate-950">上传说明</h3>
<p class="mt-3 text-sm leading-7 text-slate-700">
@@ -1004,7 +1018,7 @@ onMounted(loadAdminData)
</div>
</div>
<aside class="self-start border border-slate-200 bg-white lg:sticky lg:top-20">
<aside class="oc-panel-card self-start border border-slate-200 bg-white lg:sticky lg:top-20">
<div class="border-b border-slate-200 bg-[linear-gradient(180deg,#fffbeb_0%,#fff_100%)] p-5">
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">Review Decision</p>
<h3 class="mt-2 text-xl font-bold text-slate-950">审核资料</h3>
@@ -1088,13 +1102,13 @@ onMounted(loadAdminData)
</div>
</div>
<div v-else class="border border-dashed border-slate-300 bg-white p-10">
<div v-else class="oc-panel-card border border-dashed border-slate-300 bg-white p-10">
<NEmpty description="当前没有待审核图片" />
</div>
</section>
<section v-else-if="activeTab === 'users'">
<div class="overflow-hidden border border-slate-200 bg-white">
<div class="oc-panel-card overflow-hidden border border-slate-200 bg-white">
<div class="grid grid-cols-[1.1fr_0.8fr_0.7fr_0.9fr_1.2fr] border-b border-slate-200 bg-slate-50 px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-slate-500">
<span>用户</span>
<span>角色</span>
@@ -1145,7 +1159,7 @@ onMounted(loadAdminData)
</section>
<section v-else>
<div class="mb-4 flex flex-col gap-3 border border-slate-200 bg-white p-4 md:flex-row md:items-center md:justify-between">
<div class="oc-panel-card mb-4 flex flex-col gap-3 border border-slate-200 bg-white p-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-xl font-bold text-slate-950">图片管理</h2>
<p class="oc-field-help">最近 {{ images.length }} 张图片,可调整审核状态、可见性或删除。</p>
@@ -1160,7 +1174,14 @@ onMounted(loadAdminData)
<option value="approved">已通过</option>
<option value="rejected">已拒绝</option>
</select>
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="toggleAllFilteredImageSelection">
<NButton
v-if="filteredImages.length > 0"
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--neutral"
@click="toggleAllFilteredImageSelection"
>
{{ selectedManagedCount === filteredImages.length && filteredImages.length ? '取消全选' : '全选当前列表' }}
</NButton>
<template v-if="selectedManagedCount">
@@ -1219,8 +1240,8 @@ onMounted(loadAdminData)
</div>
<div v-if="filteredImages.length" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<article v-for="item in filteredImages" :key="item.id" class="overflow-hidden border border-slate-200 bg-white">
<button type="button" class="block w-full overflow-hidden bg-slate-100" @click="selectedImage = item">
<article v-for="item in filteredImages" :key="item.id" class="oc-panel-card overflow-hidden border border-slate-200 bg-white">
<button type="button" class="oc-interactive-card block w-full overflow-hidden bg-slate-100" @click="selectedImage = item">
<img :src="item.thumbnail_url || item.image_url" :alt="item.cloudTypeName" class="h-52 w-full object-cover transition-transform hover:scale-105" />
</button>
<div class="p-4">
@@ -1263,7 +1284,7 @@ onMounted(loadAdminData)
</article>
</div>
<div v-else class="border border-dashed border-slate-300 bg-white p-10">
<div v-else class="oc-panel-card border border-dashed border-slate-300 bg-white p-10">
<NEmpty description="没有符合筛选条件的图片" />
</div>
</section>
+4 -4
View File
@@ -73,10 +73,10 @@ onUnmounted(() => {
<template>
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
<div class="mx-auto grid max-w-6xl gap-8 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
<section class="border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_52%,#f0fdfa_100%)] p-8 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)]">
<div class="mx-auto grid min-w-0 max-w-6xl gap-8 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
<section class="min-w-0 overflow-hidden border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_52%,#f2faf6_100%)] p-6 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)] sm:p-8">
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Password Recovery</p>
<h1 class="mt-4 max-w-xl text-5xl font-black leading-[1.05] text-slate-900">
<h1 class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl">
重新拿回你的
<span class="block text-sky-700">OpenCloud 账号</span>
</h1>
@@ -97,7 +97,7 @@ onUnmounted(() => {
</div>
</section>
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
<NCard class="min-w-0 shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
<NResult
v-if="submitted"
status="success"
+7 -7
View File
@@ -42,11 +42,11 @@ async function handleLogin() {
<template>
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
<div class="mx-auto grid max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-stretch">
<section class="flex h-full flex-col justify-between border border-slate-200 bg-[linear-gradient(135deg,#f0fdfa_0%,#ffffff_48%,#eff6ff_100%)] p-8 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)]">
<div class="mx-auto grid min-w-0 max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-stretch">
<section class="flex min-w-0 h-full flex-col justify-between overflow-hidden border border-slate-200 bg-[linear-gradient(135deg,#f2faf6_0%,#ffffff_48%,#eff6ff_100%)] p-6 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)] sm:p-8">
<div>
<p class="text-sm uppercase tracking-[0.26em] text-teal-700">Sky Log In</p>
<h1 class="mt-4 max-w-xl text-5xl font-black leading-[1.05] text-slate-900">
<h1 class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl">
登录后继续记录
<span class="block text-teal-700">你眼中的每一朵云</span>
</h1>
@@ -54,13 +54,13 @@ async function handleLogin() {
在地图、图鉴和画廊之间同步你的观测记录。上传新的云图后,图鉴会自动点亮,画廊也会按时间收纳你的作品。
</p> -->
</div>
<div class="mt-8 flex gap-4">
<div class="border border-slate-200 bg-white px-4 py-3">
<div class="mt-8 grid min-w-0 grid-cols-2 gap-4">
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
<div class="oc-stat-label">Collection</div>
<div class="oc-stat-value">10</div>
<div class="oc-field-help">基础云属待收集</div>
</div>
<div class="border border-slate-200 bg-white px-4 py-3">
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
<div class="oc-stat-label">Mode</div>
<div class="oc-stat-value">Atlas</div>
<div class="oc-field-help">地图、图鉴、画廊一体</div>
@@ -68,7 +68,7 @@ async function handleLogin() {
</div>
</section>
<NCard class="h-full shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
<NCard class="min-w-0 h-full shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
<div class="mb-8">
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Member Access</div>
<h2 class="mt-3 text-3xl font-bold text-slate-900">登录 OpenCloud</h2>
+7 -7
View File
@@ -47,11 +47,11 @@ async function handleRegister() {
<template>
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
<div class="mx-auto grid max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-stretch">
<section class="flex h-full flex-col justify-between border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_42%,#f0fdfa_100%)] p-8 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)]">
<div class="mx-auto grid min-w-0 max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-stretch">
<section class="flex min-w-0 h-full flex-col justify-between overflow-hidden border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_42%,#f2faf6_100%)] p-6 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)] sm:p-8">
<div>
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Observer Join</p>
<h1 class="mt-4 max-w-xl text-5xl font-black leading-[1.05] text-slate-900">
<h1 class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl">
加入天空探索者
<span class="block text-sky-700">建立你的云图档案</span>
</h1>
@@ -59,13 +59,13 @@ async function handleRegister() {
注册后即可上传云图、点亮图鉴、在社区画廊里按时间展示你的观测记录。所有页面都会围绕你的个人云层档案同步更新。
</p> -->
</div>
<div class="mt-8 flex gap-4">
<div class="border border-slate-200 bg-white px-4 py-3">
<div class="mt-8 grid min-w-0 grid-cols-2 gap-4">
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
<div class="oc-stat-label">Review</div>
<div class="oc-stat-value">Queue</div>
<div class="oc-field-help">上传后进入审核队列</div>
</div>
<div class="border border-slate-200 bg-white px-4 py-3">
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
<div class="oc-stat-label">Profile</div>
<div class="oc-stat-value">Journal</div>
<div class="oc-field-help">自动生成个人天空日志</div>
@@ -73,7 +73,7 @@ async function handleRegister() {
</div>
</section>
<NCard class="h-full shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
<NCard class="min-w-0 h-full shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
<NResult
v-if="emailSent"
status="success"
+1 -1
View File
@@ -2,7 +2,7 @@
</script>
<template>
<div class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#f0fdfa_0%,#f8fafc_100%)] px-4 py-16">
<div class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#f2faf6_0%,#f8fafc_100%)] px-4 py-16">
<section class="mx-auto max-w-4xl border border-slate-200 bg-white p-10 shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
<p class="text-sm font-semibold uppercase tracking-[0.24em] text-teal-700">Community</p>
<h1 class="mt-4 text-4xl font-bold text-slate-900">社区</h1>
+1 -1
View File
@@ -242,7 +242,7 @@ watch(() => route.params.id, () => {
v-for="item in gallery"
:key="item.id"
type="button"
class="overflow-hidden border border-slate-200 bg-white text-left shadow-sm transition-transform duration-300 hover:-translate-y-1 hover:shadow-lg"
class="oc-interactive-card overflow-hidden border border-slate-200 bg-white text-left shadow-sm transition-transform duration-300 hover:-translate-y-1 hover:shadow-lg"
@click="openGalleryDetail(item)"
>
<img :src="item.thumbnail_url || item.image_url" :alt="cloudType.name" class="h-56 w-full object-cover" />
+1 -1
View File
@@ -110,7 +110,7 @@ onMounted(async () => {
:key="cloudType.id"
type="button"
@click="openCard(cloudType)"
class="group overflow-hidden border bg-white text-left shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg"
class="oc-interactive-card group overflow-hidden border bg-white text-left shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg"
:class="isUnlocked(cloudType.id) ? 'border-amber-300 shadow-amber-100/60' : 'border-slate-200 hover:border-slate-300'"
>
<div
+5 -3
View File
@@ -367,7 +367,7 @@ onUnmounted(() => {
<button
v-if="searchQuery"
type="button"
class="flex h-8 w-8 shrink-0 items-center justify-center text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
class="oc-icon-button oc-icon-button--small"
title="清空搜索"
aria-label="清空搜索"
@click="clearSearch"
@@ -430,7 +430,7 @@ onUnmounted(() => {
:key="cloud.id"
type="button"
@click="openDetail(cloud)"
class="group relative mb-3 block w-full break-inside-avoid overflow-hidden border border-slate-200 bg-slate-200 text-left shadow-sm transition-transform duration-300 hover:-translate-y-1 hover:shadow-lg"
class="oc-interactive-card group relative mb-3 block w-full break-inside-avoid overflow-hidden border border-slate-200 bg-slate-200 text-left shadow-sm transition-transform duration-300 hover:-translate-y-1 hover:shadow-lg"
>
<img
:src="cloud.thumbnail_url || cloud.image_url"
@@ -482,6 +482,7 @@ onUnmounted(() => {
<NButton
secondary
strong
type="default"
:disabled="currentPage <= 1"
class="oc-panel-button oc-panel-button--neutral"
@click="goToPage(currentPage - 1)"
@@ -509,6 +510,7 @@ onUnmounted(() => {
<NButton
secondary
strong
type="default"
:disabled="currentPage >= totalPages"
class="oc-panel-button oc-panel-button--neutral"
@click="goToPage(currentPage + 1)"
@@ -576,7 +578,7 @@ onUnmounted(() => {
>
<button
type="button"
class="flex h-10 w-10 items-center justify-center border border-slate-200 bg-slate-50 text-slate-600 transition-colors hover:border-slate-900 hover:text-slate-900"
class="oc-icon-button"
title="图片管理"
aria-label="图片管理"
>
+227 -14
View File
@@ -8,7 +8,7 @@ import { loadAMap } from '@/lib/amap'
import { useAuthStore } from '@/stores/auth'
import type { ApiCloud } from '@/types/api'
import { NIcon } from 'naive-ui'
import { Adjustments, Calendar, CloudUpload, Refresh, Map, Satellite, X } from '@vicons/tabler'
import { Adjustments, Calendar, CloudUpload, Minus, Plus, Refresh, Map, Satellite, X } from '@vicons/tabler'
interface CloudMarkerData {
id: string
@@ -40,10 +40,15 @@ const archiveMonth = ref(formatMonthInput(new Date()))
const currentMinuteOfDay = ref(getMinuteOfDay(new Date()))
const selectedMinuteOfDay = ref(currentMinuteOfDay.value)
const sliderDragging = ref(false)
const currentZoom = ref(5)
const VISIBLE_WINDOW_MS = 2 * 60 * 60 * 1000
const MIN_MARKER_OPACITY = 0.3
const HIDE_HEADER_EVENT = 'opencloud:hide-header'
const MIN_MAP_ZOOM = 4
const MAX_MAP_ZOOM = 18
const ZOOM_HOLD_DELAY_MS = 180
const ZOOM_CONTINUOUS_SPEED = 2.2
let AMapLib: typeof AMap | null = null
let mapInst: AMap.Map | null = null
@@ -53,6 +58,13 @@ let mks: AMap.Marker[] = []
let hoverIW: AMap.InfoWindow | null = null
let allClouds: CloudMarkerData[] = []
let redrawTimer: number | null = null
let zoomHoldDelayTimer: number | null = null
let zoomAnimationFrame: number | null = null
let activeZoomPointerId: number | null = null
let zoomContinuousDirection: -1 | 1 | null = null
let zoomAnimationValue = currentZoom.value
let zoomLastFrameTime = 0
let zoomHoldStarted = false
const rarityColors: Record<string, string> = {
common: '#cbd5e1',
@@ -243,6 +255,165 @@ function hideHeader() {
window.dispatchEvent(new CustomEvent(HIDE_HEADER_EVENT))
}
function syncCurrentZoom() {
if (!mapInst) return
currentZoom.value = mapInst.getZoom(2)
}
function changeZoom(direction: -1 | 1, step = 1) {
if (!mapInst) return false
const zoom = mapInst.getZoom(2)
const nextZoom = Math.min(MAX_MAP_ZOOM, Math.max(MIN_MAP_ZOOM, zoom + direction * step))
if (Math.abs(nextZoom - zoom) < 0.001) return false
mapInst.setZoom(nextZoom, true)
currentZoom.value = nextZoom
return true
}
function stopZoomAnimation() {
if (zoomAnimationFrame !== null) {
window.cancelAnimationFrame(zoomAnimationFrame)
zoomAnimationFrame = null
}
zoomContinuousDirection = null
zoomLastFrameTime = 0
}
function clearZoomHoldDelay() {
if (zoomHoldDelayTimer !== null) {
window.clearTimeout(zoomHoldDelayTimer)
zoomHoldDelayTimer = null
}
}
function clearZoomHold() {
clearZoomHoldDelay()
stopZoomAnimation()
activeZoomPointerId = null
zoomHoldStarted = false
}
function runContinuousZoomFrame(timestamp: number) {
if (!mapInst || zoomContinuousDirection === null) {
zoomAnimationFrame = null
return
}
if (zoomLastFrameTime === 0) {
zoomLastFrameTime = timestamp
zoomAnimationFrame = window.requestAnimationFrame(runContinuousZoomFrame)
return
}
const elapsedSeconds = Math.min((timestamp - zoomLastFrameTime) / 1000, 0.05)
zoomLastFrameTime = timestamp
const nextZoom = Math.min(
MAX_MAP_ZOOM,
Math.max(
MIN_MAP_ZOOM,
zoomAnimationValue + zoomContinuousDirection * ZOOM_CONTINUOUS_SPEED * elapsedSeconds,
),
)
if (Math.abs(nextZoom - zoomAnimationValue) < 0.0001) {
stopZoomAnimation()
return
}
zoomAnimationValue = nextZoom
mapInst.setZoom(nextZoom, true)
currentZoom.value = nextZoom
if (nextZoom === MIN_MAP_ZOOM || nextZoom === MAX_MAP_ZOOM) {
stopZoomAnimation()
return
}
zoomAnimationFrame = window.requestAnimationFrame(runContinuousZoomFrame)
}
function beginContinuousZoom(direction: -1 | 1) {
if (!mapInst) return
zoomHoldStarted = true
zoomContinuousDirection = direction
zoomAnimationValue = mapInst.getZoom(4)
zoomLastFrameTime = 0
zoomAnimationFrame = window.requestAnimationFrame(runContinuousZoomFrame)
}
function startZoomHold(direction: -1 | 1, event: PointerEvent) {
if (event.pointerType === 'mouse' && event.button !== 0) return
if (activeZoomPointerId !== null) return
event.preventDefault()
clearZoomHold()
activeZoomPointerId = event.pointerId
const button = event.currentTarget as HTMLButtonElement
button.setPointerCapture?.(event.pointerId)
const zoom = mapInst?.getZoom(4)
if (
zoom === undefined
|| (direction === 1 && zoom >= MAX_MAP_ZOOM)
|| (direction === -1 && zoom <= MIN_MAP_ZOOM)
) {
activeZoomPointerId = null
if (button.hasPointerCapture?.(event.pointerId)) {
button.releasePointerCapture(event.pointerId)
}
return
}
zoomHoldDelayTimer = window.setTimeout(() => {
zoomHoldDelayTimer = null
beginContinuousZoom(direction)
}, ZOOM_HOLD_DELAY_MS)
}
function finishZoomHold(direction: -1 | 1, event: PointerEvent) {
if (activeZoomPointerId === null || event.pointerId !== activeZoomPointerId) return
const pointerId = activeZoomPointerId
const wasContinuous = zoomHoldStarted
activeZoomPointerId = null
clearZoomHoldDelay()
stopZoomAnimation()
zoomHoldStarted = false
const button = event.currentTarget as HTMLButtonElement
if (button.hasPointerCapture?.(pointerId)) {
button.releasePointerCapture(pointerId)
}
if (!wasContinuous) {
changeZoom(direction)
}
}
function cancelZoomHold(event: PointerEvent) {
if (activeZoomPointerId === null || event.pointerId !== activeZoomPointerId) return
const pointerId = activeZoomPointerId
activeZoomPointerId = null
clearZoomHoldDelay()
stopZoomAnimation()
zoomHoldStarted = false
const button = event.currentTarget as HTMLButtonElement
if (button.hasPointerCapture?.(pointerId)) {
button.releasePointerCapture(pointerId)
}
}
function handleZoomClick(direction: -1 | 1, event: MouseEvent) {
if (event.detail === 0) {
changeZoom(direction)
}
}
function toCloudMarker(row: ApiCloud): CloudMarkerData {
return {
id: row.id,
@@ -423,14 +594,16 @@ async function returnRealtime() {
}
onMounted(async () => {
window.addEventListener('blur', clearZoomHold)
try {
AMapLib = await loadAMap()
AMapLib.getConfig().appname = 'amap-jsapi-skill'
mapInst = new AMapLib.Map(mapEl.value!, {
viewMode: '3D',
pitch: 0,
rotation: 0,
zoom: 5,
zooms: [4, 18],
zoom: currentZoom.value,
zooms: [MIN_MAP_ZOOM, MAX_MAP_ZOOM],
center: [104.07, 30.67],
mapStyle: 'amap://styles/normal',
features: ['bg', 'road', 'building', 'point'],
@@ -440,12 +613,13 @@ onMounted(async () => {
} as AMap.MapOptions)
mapInst.addControl(new AMapLib.Scale())
mapInst.addControl(new AMapLib.ToolBar({ position: 'LT' } as Record<string, unknown>))
mapInst.addControl(new AMapLib.ControlBar({ position: { right: '50px', bottom: '224px' } } as Record<string, unknown>))
mapInst.on('click', () => { previewCloud.value = null; hideHoverCard() })
mapInst.on('zoomstart', hideHeader)
mapInst.on('zoomchange', syncCurrentZoom)
mapInst.on('movestart', hideHeader)
mapInst.on('dragstart', hideHeader)
syncCurrentZoom()
await refresh()
startMarkerDecayTimer()
} catch (e) {
@@ -454,10 +628,13 @@ onMounted(async () => {
})
onUnmounted(() => {
window.removeEventListener('blur', clearZoomHold)
clearZoomHold()
if (redrawTimer !== null) {
window.clearInterval(redrawTimer)
redrawTimer = null
}
mapInst?.off('zoomchange', syncCurrentZoom)
mapInst?.destroy()
mapInst = null
AMapLib = null
@@ -468,11 +645,45 @@ onUnmounted(() => {
<div class="relative h-[100dvh] min-h-screen">
<div ref="mapEl" class="w-full h-full"></div>
<div
class="absolute left-4 top-32 z-20 flex w-10 flex-col gap-2 md:top-20"
role="group"
:aria-label="`地图缩放控制,当前级别 ${currentZoom.toFixed(2)}`"
>
<button
type="button"
class="oc-map-button touch-none select-none"
title="放大地图(按住可连续放大)"
aria-label="放大地图,按住可连续放大"
:disabled="currentZoom >= MAX_MAP_ZOOM"
@pointerdown="startZoomHold(1, $event)"
@pointerup="finishZoomHold(1, $event)"
@pointercancel="cancelZoomHold"
@lostpointercapture="cancelZoomHold"
@click="handleZoomClick(1, $event)"
>
<NIcon size="20"><Plus /></NIcon>
</button>
<button
type="button"
class="oc-map-button touch-none select-none"
title="缩小地图(按住可连续缩小)"
aria-label="缩小地图,按住可连续缩小"
:disabled="currentZoom <= MIN_MAP_ZOOM"
@pointerdown="startZoomHold(-1, $event)"
@pointerup="finishZoomHold(-1, $event)"
@pointercancel="cancelZoomHold"
@lostpointercapture="cancelZoomHold"
@click="handleZoomClick(-1, $event)"
>
<NIcon size="20"><Minus /></NIcon>
</button>
</div>
<div class="absolute bottom-6 right-7 z-20 flex w-10 flex-col items-center gap-2">
<button
type="button"
class="w-10 h-10 rounded-lg shadow-md flex items-center justify-center"
:class="authStore.loading || !authStore.isLoggedIn ? 'cursor-not-allowed bg-slate-100 text-slate-300 shadow-none' : 'bg-white hover:bg-slate-50'"
class="oc-map-button"
:title="authStore.isLoggedIn ? '快速上传图片' : '请先登录后上传图片'"
aria-label="快速上传图片"
:disabled="authStore.loading || !authStore.isLoggedIn"
@@ -482,14 +693,14 @@ onUnmounted(() => {
<CloudUpload />
</NIcon>
</button>
<button @click="refresh" class="oc-map-button" title="刷新">
<button type="button" @click="refresh" class="oc-map-button" title="刷新" aria-label="刷新地图">
<span class="text-lg">
<NIcon>
<Refresh/>
</NIcon>
</span>
</button>
<button @click="toggleSat" class="oc-map-button" :title="satelliteOn ? '切换普通视图' : '切换卫星视图'">
<button type="button" @click="toggleSat" class="oc-map-button" :title="satelliteOn ? '切换普通视图' : '切换卫星视图'" :aria-label="satelliteOn ? '切换普通视图' : '切换卫星视图'">
<NIcon size="20" style="display: inline-flex; vertical-align: middle;">
<Map v-if="satelliteOn" />
<Satellite v-else />
@@ -565,23 +776,25 @@ onUnmounted(() => {
>
<div class="flex items-center justify-between gap-2">
<p class="text-sm font-semibold text-slate-900">历史位置</p>
<button type="button" class="text-xs font-medium text-sky-700 hover:text-sky-900" @click="returnRealtime">
<button type="button" class="oc-text-button text-xs" @click="returnRealtime">
返回实时
</button>
</div>
<div class="mt-4 grid grid-cols-2 gap-2">
<button
type="button"
class="border px-3 py-2 text-sm font-medium transition-colors"
:class="archiveKind === 'day' ? 'border-teal-200 bg-teal-50 text-teal-800' : 'border-slate-200 bg-white text-slate-600 hover:bg-slate-50'"
class="oc-choice-button"
:class="archiveKind === 'day' ? 'oc-choice-button--active' : 'oc-choice-button--inactive'"
:aria-pressed="archiveKind === 'day'"
@click="archiveKind = 'day'"
>
某天
</button>
<button
type="button"
class="border px-3 py-2 text-sm font-medium transition-colors"
:class="archiveKind === 'month' ? 'border-teal-200 bg-teal-50 text-teal-800' : 'border-slate-200 bg-white text-slate-600 hover:bg-slate-50'"
class="oc-choice-button"
:class="archiveKind === 'month' ? 'oc-choice-button--active' : 'oc-choice-button--inactive'"
:aria-pressed="archiveKind === 'month'"
@click="archiveKind = 'month'"
>
某月
@@ -603,7 +816,7 @@ onUnmounted(() => {
<button
type="button"
class="mt-3 w-full border border-sky-200 bg-sky-100 px-3 py-2 text-sm font-medium text-sky-800 shadow-[4px_4px_0_0_rgba(14,165,233,0.14)] transition-colors hover:bg-sky-50"
class="oc-panel-button oc-panel-button--sky mt-3 w-full"
@click="applyArchiveFilter"
>
查看拍摄位置
+2 -1
View File
@@ -167,7 +167,8 @@ async function savePassword() {
<NButton
secondary
strong
class="oc-panel-button oc-panel-button--neutral"
type="default"
class="oc-panel-button oc-panel-button--teal"
:loading="passwordSaving"
@click="savePassword"
>
+8 -6
View File
@@ -491,7 +491,7 @@ watch(selectedUploadDate, async newValue => {
<div v-else class="grid gap-6 lg:grid-cols-[1.25fr_0.95fr] lg:items-start">
<div class="flex gap-5">
<div
class="flex h-20 w-20 shrink-0 items-center justify-center border border-slate-300 bg-[linear-gradient(135deg,#ecfeff_0%,#ccfbf1_100%)] text-3xl font-bold text-slate-900 shadow-[4px_4px_0_0_rgba(15,23,42,0.08)]"
class="flex h-20 w-20 shrink-0 items-center justify-center border border-slate-300 bg-[linear-gradient(135deg,#eef9f3_0%,#d9efe3_100%)] text-3xl font-bold text-slate-900 shadow-[4px_4px_0_0_rgba(15,23,42,0.08)]"
>
<img
v-if="profileData?.avatar_url"
@@ -516,7 +516,7 @@ watch(selectedUploadDate, async newValue => {
<RouterLink v-if="isOwnProfile" to="/profile/settings">
<button
type="button"
class="inline-flex h-8 w-8 items-center justify-center border border-slate-300 bg-white text-slate-600 transition-colors hover:border-slate-900 hover:text-slate-900"
class="oc-icon-button oc-icon-button--small"
title="个人资料设置"
aria-label="个人资料设置"
>
@@ -649,18 +649,20 @@ watch(selectedUploadDate, async newValue => {
v-if="isOwnProfile"
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--neutral"
@click="toggleSelectionMode"
>
{{ selectionMode ? '取消选择' : '选择图片' }}
</NButton>
<NButton secondary strong class="oc-panel-button oc-panel-button--neutral" @click="loadProfilePage(true)">
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="loadProfilePage(true)">
刷新数据
</NButton>
<NButton
v-if="selectedUploadDate"
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--neutral"
@click="clearSelectedUploadDate"
>
@@ -706,7 +708,7 @@ watch(selectedUploadDate, async newValue => {
:key="item.id"
type="button"
@click="selectedCloud = item"
class="group overflow-hidden border bg-white text-left shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg"
class="oc-interactive-card group overflow-hidden border bg-white text-left shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg"
:class="selectedUploadDate && toDayKey(item.created_at) === selectedUploadDate ? 'border-sky-500 shadow-sky-100' : 'border-slate-200'"
>
<div class="relative">
@@ -773,7 +775,7 @@ watch(selectedUploadDate, async newValue => {
<RouterLink v-if="isOwnProfile && !selectedUploadDate" to="/upload">
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky">去上传第一张</NButton>
</RouterLink>
<NButton v-else-if="selectedUploadDate" secondary strong class="oc-panel-button oc-panel-button--neutral" @click="clearSelectedUploadDate">返回全部记录</NButton>
<NButton v-else-if="selectedUploadDate" secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="clearSelectedUploadDate">返回全部记录</NButton>
</template>
</NEmpty>
</div>
@@ -845,7 +847,7 @@ watch(selectedUploadDate, async newValue => {
>
<button
type="button"
class="flex h-10 w-10 items-center justify-center border border-slate-200 bg-slate-50 text-slate-600 transition-colors hover:border-slate-900 hover:text-slate-900"
class="oc-icon-button"
title="图片管理"
aria-label="图片管理"
>
+6 -4
View File
@@ -302,9 +302,11 @@ onUnmounted(() => {
<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>
<button
type="button"
@click.stop="handleRemove(item.id)"
class="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center bg-red-500 text-xs text-white"
style="opacity:0.8"
class="oc-icon-button oc-icon-button--danger oc-icon-button--tiny absolute -right-1 -top-1"
title="移除图片"
aria-label="移除图片"
>✕</button>
</div>
<div
@@ -374,7 +376,7 @@ onUnmounted(() => {
v-if="activeItem.latitude !== null || activeItem.longitude !== null"
type="button"
@click="clearCoordinates"
class="text-xs font-medium text-slate-400 transition-colors hover:text-slate-600"
class="oc-text-button oc-text-button--muted text-xs"
>
清空
</button>
@@ -408,7 +410,7 @@ onUnmounted(() => {
@click="openMapPicker"
title="地图选点"
aria-label="地图选点"
class="flex h-10 w-10 items-center justify-center rounded-lg border border-sky-200 bg-sky-50 text-sky-700 transition-colors hover:bg-sky-100"
class="oc-icon-button oc-icon-button--sky"
>
<span class="text-lg leading-none">🗺️</span>
</button>