36 changed files with 869 additions and 1676 deletions
+301 -182
View File
@@ -1,235 +1,354 @@
---
name: opencloud-style
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."
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."
---
# OpenCloud Visual Style
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.
Use this skill whenever changing OpenCloud visual design, component styling, Tailwind classes, layout, typography, or any page-level appearance.
## Visual Direction
- 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.
OpenCloud should feel like a clean sky atlas: airy, fresh, bright, and observational.
## Current Palette
- 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.
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
Use:
```text
**Font stack** (`src/style.css`, `App.vue`):
```
"IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif
```
Monospace: `"IBM Plex Mono", "SFMono-Regular", monospace`
Use IBM Plex Mono for small atlas-like labels when a monospace accent is appropriate.
**Type scale:**
- 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.
| 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 |
## Layout and Responsive Behavior
**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]`
Prefer the shared page structure:
**Letter tracking:**
```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>
| 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 |
<main class="oc-container oc-container--content oc-page-content">
<!-- content -->
</main>
---
## 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; }
```
Container widths:
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`
- `oc-container--narrow` — `max-w-4xl`
- `oc-container--content` — `max-w-6xl`
- `oc-container--wide` — `max-w-7xl`
---
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.
## Shadows
## Reusable Component Contracts
**Signature pattern: hard offset box shadows** — `shadow-[Xpx_Xpx_0_0_rgba(...)]` with zero blur.
Use existing semantic classes before writing repeated Tailwind bundles:
| 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 |
- 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`.
**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)
Class names describe function. Keep modifiers attached to their base class, for example:
**Standard Tailwind shadows** also used sparingly:
- `shadow-sm` — stat cards, gallery cards, encyclopedia cards
- `shadow-lg` — cloud card hover lift
- `shadow-2xl` — modal shells
```html
<NButton type="default" class="oc-panel-button oc-panel-button--sky">
保存
</NButton>
---
## 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
```
Available shared variants:
---
- 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
## Layout
## Button Semantics
**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
- 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.
**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)
```
For Naive UI buttons:
**Horizontal padding:** `px-4` / `sm:px-6` / `lg:px-8`
- 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.
**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]`
## Icons, Cards, and Modals
**Gap scale:** `gap-4`, `gap-5`, `gap-6` for card grids; `gap-2` for nav items; `space-y-6`/`space-y-8` between sections.
- 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.
---
## Implementation Workflow
## Component Conventions
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.
### 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`
---
## Anti-Patterns
- 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.
- 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`).
---
## Validation
After style changes, run:
```bash
npm run check:styles
git diff --check
npm run build
npx vue-tsc -b
```
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.
Run `npm run build` if the change touches routes, imported components, or package dependencies.
+4 -34
View File
@@ -3,11 +3,9 @@
## 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)
- `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
- No linter, formatter, or test runner exists
## Architecture
@@ -28,13 +26,9 @@
| `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 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/style.css` | Global visual system hooks: shared button/card utility classes, base typography, body background |
| `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
@@ -132,33 +126,12 @@ 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.
- 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`.
- **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.
- 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
@@ -173,15 +146,12 @@ 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 on a light sky-blue square; avoid emoji or unrelated icon styles for primary brand surfaces.
- 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.
- 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
+2 -20
View File
@@ -15,21 +15,6 @@
`src/style.css` 只负责按以上顺序装配全局样式,不再直接堆放组件规则。
## 清新绿色策略
OpenCloud 的绿色应接近清晨的桉叶与嫩叶,而不是灰暗的苔藓色或高饱和的荧光青绿。
- 品牌、导航与账户交互使用清透但稳重的桉叶绿。
- 成功、通过和公开状态使用更偏嫩叶的成功绿色,与品牌色保持区分。
- `teal-*` 和 `emerald-*` 是现有模板的兼容类名,实际色值由 `tokens.css` 重映射;不要假定它们仍是 Tailwind 默认色。
- 不直接使用 Tailwind 默认 `green-*`、`lime-*`,也不要在组件里写亮绿色十六进制值或 RGB。
- 新增绿色前先判断它属于品牌交互还是成功状态,并优先使用已有语义类或设计令牌。
| 语义 | 主色 | 深色 | 浅色表面 |
| --- | --- | --- | --- |
| 品牌/交互 | `#34745d` | `#274c40` | `#f2faf6` |
| 成功/通过 | `#4d7f3b` | `#35522c` | `#f4faef` |
## 命名规则
- 所有共享类使用 `oc-` 前缀,避免与 Naive UI、AMap 和 Tailwind 冲突。
@@ -58,8 +43,6 @@ OpenCloud 的绿色应接近清晨的桉叶与嫩叶,而不是灰暗的苔藓
容器宽度只有三个语义档位:`oc-container--narrow`、`oc-container--content`、`oc-container--wide`。不要在同类页面重复手写 `max-w-* mx-auto px-*`。
Hero 同时包含左侧介绍和右侧状态卡时,使用 `oc-page-hero__split`。它会在有效视口达到 `900px` 时启用接近均衡的双栏,避免浏览器缩放到 125% 后过早堆叠;需要两栏等高时同时添加 `oc-page-hero__split--stretch`。
### 表单
```html
@@ -78,8 +61,7 @@ Hero 同时包含左侧介绍和右侧状态卡时,使用 `oc-page-hero__split
- `oc-section-label`:详情面板中的字段标题。
- `oc-stat-label` / `oc-stat-value`:统计数据的标签和值。
- `oc-inset-panel`:详情中的浅色信息块。
- `oc-panel-card` / `oc-panel-card-soft` / `oc-panel-card-xl`:普通、柔和和大型卡片的硬阴影。
- `oc-floating-panel` / `oc-modal-shell`:浮动菜单与普通方形弹窗的共享表面。
- `oc-panel-card` / `oc-panel-card-soft`:Naive UI 卡片的硬阴影。
- `oc-surface`:普通 HTML 容器的标准白色表面。
- `oc-empty-card`:空状态表面。
@@ -96,7 +78,7 @@ Naive UI 按钮继续使用 `type="default"`,视觉语义由共享类控制:
</NButton>
```
页面级主操作使用 `oc-primary-button`;面板内操作使用 `oc-panel-button`。可用变体为 `neutral`、`sky`、`teal`、`amber`、`danger`,不要临时创建新的强调色。其中 `teal` 是兼容命名,视觉上使用的是清新桉叶绿。
页面级主操作使用 `oc-primary-button`;面板内操作使用 `oc-panel-button`。可用变体为 `neutral`、`sky`、`teal`、`amber`、`danger`,不要临时创建新的强调色。
## 新增或修改样式的决策顺序
+5 -32
View File
@@ -7,55 +7,30 @@ 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/自然绿色语义色,不要重新引入灰色或紫色系。',
},
{
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: /#(?:c084fc|a855f7|9333ea|7e22ce)\b/gi,
message: '检测到禁用的紫色硬编码,请改用 rose 或项目语义令牌。',
},
{
pattern: /#(?:1f2937|9ca3af)\b/gi,
message: '检测到默认 gray 调色板硬编码,请改用对应的 slate 色值或语义令牌。',
},
{
pattern: /#60a5fa\b/gi,
message: '检测到默认 blue-400 硬编码,请按用途改用 sky 或 amber 等项目语义色。',
message: '使用项目约定的 slate/sky/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,
},
]
const styleSourceExtensions = new Set(['.vue', '.css', '.ts'])
async function collectStyleSourceFiles(directory) {
async function collectVueFiles(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() ? collectStyleSourceFiles(path) : [path]
return entry.isDirectory() ? collectVueFiles(path) : [path]
}))
return files.flat().filter((path) => styleSourceExtensions.has(extname(path)))
return files.flat().filter((path) => extname(path) === '.vue')
}
function lineNumberAt(source, index) {
@@ -64,12 +39,10 @@ function lineNumberAt(source, index) {
const violations = []
for (const file of await collectStyleSourceFiles(sourceRoot.pathname)) {
for (const file of await collectVueFiles(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),
+8 -11
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { NAlert, NButton, NIcon } from 'naive-ui'
import { Map as MapIcon, X } from '@vicons/tabler'
import { NAlert, NButton } from 'naive-ui'
import MapPickerModal from '@/components/cloud/MapPickerModal.vue'
import type { CloudType } from '@/types/database'
@@ -116,7 +115,7 @@ watch(() => props.initialValue, value => {
class="fixed inset-0 z-[130] flex items-center justify-center bg-slate-950/60 px-4 py-6"
@click="close"
>
<div class="oc-modal-shell w-full max-w-2xl" @click.stop>
<div class="w-full max-w-2xl bg-white shadow-2xl" @click.stop>
<div class="flex items-start justify-between border-b border-slate-200 px-6 py-5">
<div>
<h3 class="text-xl font-semibold text-slate-900">编辑图片信息</h3>
@@ -124,12 +123,10 @@ watch(() => props.initialValue, value => {
</div>
<button
type="button"
class="oc-icon-button"
aria-label="关闭编辑窗口"
title="关闭编辑窗口"
class="p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
@click="close"
>
<NIcon size="20"><X /></NIcon>
✕
</button>
</div>
@@ -194,7 +191,7 @@ watch(() => props.initialValue, value => {
<button
v-if="hasCoordinates"
type="button"
class="oc-text-button oc-text-button--muted text-xs"
class="text-xs font-medium text-slate-400 transition-colors hover:text-slate-700"
@click="clearCoordinates"
>
清空
@@ -219,12 +216,12 @@ watch(() => props.initialValue, value => {
/>
<button
type="button"
class="oc-icon-button oc-icon-button--sky"
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"
title="地图选点"
aria-label="地图选点"
@click="mapPickerOpen = true"
>
<NIcon size="20"><MapIcon /></NIcon>
<span class="text-lg leading-none">🗺️</span>
</button>
</div>
</div>
@@ -234,7 +231,7 @@ watch(() => props.initialValue, value => {
<textarea
v-model="form.description"
rows="4"
class="oc-field-control resize-none"
class="w-full resize-none 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"
placeholder="描述一下这张图片..."
></textarea>
</div>
@@ -1,55 +1,61 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NCard } from 'naive-ui'
import { NCard, NProgress } from 'naive-ui'
const props = defineProps<{
unlockedCount: number
totalCount: number
percent: number
isLoggedIn: boolean
}>()
const displayedUnlockedCount = computed(() => {
if (!props.isLoggedIn) return 0
return Math.min(Math.max(props.unlockedCount, 0), props.totalCount)
})
const remainingCount = computed(() => Math.max(props.totalCount - displayedUnlockedCount.value, 0))
function lerpHex(a: string, b: string, t: number) {
const ah = parseInt(a.slice(1), 16)
const bh = parseInt(b.slice(1), 16)
const r = Math.round(((ah >> 16) & 0xff) + (((bh >> 16) & 0xff) - ((ah >> 16) & 0xff)) * t)
const g = Math.round(((ah >> 8) & 0xff) + (((bh >> 8) & 0xff) - ((ah >> 8) & 0xff)) * t)
const bv = Math.round((ah & 0xff) + ((bh & 0xff) - (ah & 0xff)) * t)
return `#${((r << 16) | (g << 8) | bv).toString(16).padStart(6, '0')}`
}
function progressColor(pct: number) {
const t = Math.min(1, Math.max(0, pct / 100))
if (t <= 0.5) return lerpHex('#0ea5e9', '#f59e0b', t * 2)
return lerpHex('#f59e0b', '#ef4444', (t - 0.5) * 2)
}
const progressText = computed(() => `${props.unlockedCount}/${props.totalCount}`)
const currentColor = computed(() => progressColor(props.isLoggedIn ? props.percent : 0))
</script>
<template>
<NCard class="oc-panel-card-soft border border-sky-100 bg-white/88 backdrop-blur" :bordered="false">
<div class="flex items-end justify-between gap-4">
<NCard class="oc-panel-card-soft border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
<div class="flex items-center justify-between">
<div>
<p class="oc-stat-label">Collection Status</p>
<p class="mt-1 text-base font-semibold text-slate-900">图鉴收集进度</p>
<p class="text-sm text-slate-500">当前进度</p>
<p class="mt-1 text-3xl font-bold" :style="{ color: currentColor }">{{ progressText }}</p>
</div>
<div
class="flex h-16 w-16 items-center justify-center border text-xl font-semibold text-white transition-colors duration-500"
:style="{ backgroundColor: currentColor, borderColor: currentColor }"
>
{{ isLoggedIn ? percent : 0 }}%
</div>
<p class="shrink-0 font-mono tabular-nums text-slate-500">
<span class="text-3xl font-bold text-sky-800">{{ displayedUnlockedCount }}</span>
<span class="ml-1 text-sm">/ {{ totalCount }}</span>
</p>
</div>
<div
class="mt-5 grid grid-cols-5 gap-2 sm:grid-cols-10"
role="progressbar"
aria-label="云属收集进度"
aria-valuemin="0"
:aria-valuemax="totalCount"
:aria-valuenow="displayedUnlockedCount"
:aria-valuetext="`已收录 ${displayedUnlockedCount} 种,共 ${totalCount} 种`"
>
<span
v-for="index in totalCount"
:key="index"
aria-hidden="true"
class="encyclopedia-progress-cell aspect-square border border-slate-300 bg-slate-50"
:class="{ 'encyclopedia-progress-cell--active': index <= displayedUnlockedCount }"
:style="{ animationDelay: `${(index - 1) * 90}ms` }"
></span>
</div>
<NProgress
class="mt-5"
type="line"
:show-indicator="false"
:percentage="isLoggedIn ? percent : 0"
:color="{ stops: ['#0ea5e9', currentColor] }"
:height="12"
rail-color="#e2e8f0"
/>
<p class="mt-4 border-t border-slate-200 pt-3 text-sm text-slate-500">
<p class="mt-4 text-sm text-slate-500">
<template v-if="isLoggedIn">
已收录 {{ displayedUnlockedCount }} 种云属,还差 {{ remainingCount }} 种。
已解锁 {{ unlockedCount }} 枚徽章,还差 {{ Math.max(totalCount - unlockedCount, 0) }} 枚。
</template>
<template v-else>
登录后可同步你的个人图鉴进度。
@@ -57,52 +63,3 @@ const remainingCount = computed(() => Math.max(props.totalCount - displayedUnloc
</p>
</NCard>
</template>
<style scoped>
.encyclopedia-progress-cell--active {
background: rgb(224 242 254);
border-color: rgb(125 211 252);
box-shadow: 3px 3px 0 0 rgb(3 105 161 / 14%);
animation:
encyclopedia-cell-activate 1ms linear both,
encyclopedia-cell-pulse 220ms ease-out both;
}
@keyframes encyclopedia-cell-activate {
from {
background: rgb(248 250 252);
border-color: rgb(203 213 225);
box-shadow: none;
}
to {
background: rgb(224 242 254);
border-color: rgb(125 211 252);
box-shadow: 3px 3px 0 0 rgb(3 105 161 / 14%);
}
}
@keyframes encyclopedia-cell-pulse {
0% {
transform: scale(1);
}
28% {
transform: scale(0.9);
}
62% {
transform: scale(1.06);
}
100% {
transform: scale(1);
}
}
@media (prefers-reduced-motion: reduce) {
.encyclopedia-progress-cell--active {
animation: none;
}
}
</style>
+7 -13
View File
@@ -181,13 +181,13 @@ onBeforeUnmount(() => {
@click="close"
>
<div
class="image-detail-shell relative w-fit max-w-[92vw] overflow-hidden shadow-2xl"
class="image-detail-shell relative w-fit max-w-[92vw] overflow-hidden rounded-[30px] shadow-2xl"
@click.stop
>
<button
type="button"
@click="close"
class="image-detail-close-button oc-overlay-button absolute right-4 top-4 z-30 h-10 w-10 [&_svg]:stroke-[2.5]"
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]"
aria-label="关闭大图"
title="关闭大图"
>
@@ -223,7 +223,7 @@ onBeforeUnmount(() => {
<button
type="button"
@click="toggleInfo"
class="image-detail-info-button oc-overlay-button absolute bottom-4 right-4 z-30 h-11 w-11 drop-shadow-[0_2px_8px_rgba(15,23,42,0.9)]"
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"
:aria-label="infoOpen ? '隐藏详细信息' : '查看详细信息'"
:title="infoOpen ? '隐藏详细信息' : '查看详细信息'"
>
@@ -242,7 +242,7 @@ onBeforeUnmount(() => {
<h2 class="text-xl font-bold text-slate-900">{{ title }}</h2>
<span
v-if="badgeLabel"
class="border px-3 py-1 text-xs font-medium"
class="rounded-full border px-3 py-1 text-xs font-medium"
:class="badgeClass"
>
{{ badgeLabel }}
@@ -254,11 +254,11 @@ onBeforeUnmount(() => {
<button
type="button"
@click="infoOpen = false"
class="image-detail-panel-close oc-icon-button oc-icon-button--small absolute right-3 top-3"
class="absolute right-3 top-3 rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
aria-label="隐藏详细信息"
title="隐藏详细信息"
>
<NIcon size="16"><X /></NIcon>
✕
</button>
</div>
@@ -301,13 +301,7 @@ onBeforeUnmount(() => {
transform: translateY(10px) scale(0.97);
}
.image-detail-shell.image-detail-shell {
border-radius: 30px !important;
}
.image-detail-close-button.image-detail-close-button,
.image-detail-info-button.image-detail-info-button,
.image-detail-panel-close.image-detail-panel-close {
.image-detail-close-button.image-detail-close-button {
border-radius: 9999px !important;
}
</style>
+4 -10
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { NButton, NIcon } from 'naive-ui'
import { X } from '@vicons/tabler'
import { NButton } from 'naive-ui'
import { loadAMap } from '@/lib/amap'
const props = withDefaults(defineProps<{
@@ -50,8 +49,6 @@ function placeMarker(longitude: number, latitude: number) {
mapPickerMarker = new mapPickerAMap.Marker({
position: [longitude, latitude],
title: '拍摄位置',
content: '<div aria-hidden="true" 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 mapPickerAMap.Pixel(-8, -8),
} as AMap.MarkerOptions)
mapPickerMarker.setMap(mapPickerInstance)
} else {
@@ -88,7 +85,6 @@ async function initializeMap() {
try {
mapPickerAMap = await loadAMap()
mapPickerAMap.getConfig().appname = 'amap-jsapi-skill'
const hasCoordinates =
typeof props.latitude === 'number' &&
@@ -154,7 +150,7 @@ onBeforeUnmount(() => {
class="fixed inset-0 z-[140] flex items-center justify-center bg-slate-950/55 px-4 py-6"
@click="close"
>
<div class="oc-modal-shell w-full max-w-3xl overflow-hidden" @click.stop>
<div class="w-full max-w-3xl overflow-hidden bg-white shadow-2xl" @click.stop>
<div class="flex items-start justify-between border-b border-slate-200 px-6 py-5">
<div>
<h3 class="text-xl font-semibold text-slate-900">{{ title }}</h3>
@@ -162,12 +158,10 @@ onBeforeUnmount(() => {
</div>
<button
type="button"
class="oc-icon-button"
aria-label="关闭地图选点窗口"
title="关闭地图选点窗口"
class="p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
@click="close"
>
<NIcon size="20"><X /></NIcon>
✕
</button>
</div>
+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(52,116,93,0.24)]"></div>',
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>',
offset: new amapLib.Pixel(-8, -8),
} as AMap.MarkerOptions)
marker.setMap(mapInstance)
+13 -13
View File
@@ -198,7 +198,7 @@ onBeforeUnmount(() => {
class="fixed inset-0 z-[130] flex items-center justify-center bg-slate-950/55 px-4 py-6 backdrop-blur-sm"
@click="close"
>
<div class="oc-modal-shell w-full max-w-3xl overflow-hidden" @click.stop>
<div class="w-full max-w-3xl overflow-hidden border border-slate-200 bg-white shadow-2xl" @click.stop>
<div class="flex items-start justify-between border-b border-slate-200 px-5 py-4">
<div>
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-sky-700">Quick Upload</p>
@@ -207,7 +207,7 @@ onBeforeUnmount(() => {
</div>
<button
type="button"
class="oc-icon-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"
aria-label="关闭快捷上传"
title="关闭快捷上传"
@click="close"
@@ -228,13 +228,7 @@ onBeforeUnmount(() => {
<span class="font-medium text-slate-700">正在上传 {{ currentItemIndex }} / {{ totalItems }}</span>
<span class="font-medium text-sky-700">{{ overallProgress }}%</span>
</div>
<NProgress
type="line"
:percentage="overallProgress"
:show-indicator="false"
color="var(--oc-color-info)"
rail-color="var(--oc-color-border)"
/>
<NProgress type="line" :percentage="overallProgress" :show-indicator="false" color="#0ea5e9" rail-color="#dbeafe" />
</div>
<div
@@ -257,9 +251,9 @@ onBeforeUnmount(() => {
<div class="overflow-hidden border border-slate-900 bg-slate-950">
<img :src="activeItem.preview" alt="图片预览" class="h-56 w-full object-contain" />
</div>
<div class="mt-3 flex items-center gap-2">
<button type="button" class="oc-panel-button oc-panel-button--neutral flex-1" @click="fileInput?.click()">重新选择</button>
<button type="button" class="oc-panel-button oc-panel-button--danger flex-1" @click="handleRemove(activeItem.id)">移除</button>
<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>
</div>
</div>
@@ -326,7 +320,7 @@ onBeforeUnmount(() => {
</div>
<button
type="button"
class="oc-icon-button oc-icon-button--sky"
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"
title="地图选点"
aria-label="地图选点"
@click="openMapPicker"
@@ -400,3 +394,9 @@ onBeforeUnmount(() => {
/>
</Teleport>
</template>
<style scoped>
.quick-upload-close.quick-upload-close {
border-radius: 9999px !important;
}
</style>
+40 -65
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { NIcon, NSpace } from 'naive-ui'
import { ChevronDown, Logout, Settings, Shield, User } from '@vicons/tabler'
import { Logout, Settings, Shield, User } from '@vicons/tabler'
import { useAuthStore } from '@/stores/auth'
import { RouterLink, useRoute, useRouter } from 'vue-router'
@@ -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="oc-brand-mark">
<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)]">
<svg
class="h-8 w-8"
viewBox="0 0 16 16"
@@ -170,24 +170,24 @@ async function handleLogout() {
</div>
</RouterLink>
<nav class="oc-panel-card hidden items-center gap-2 border border-slate-200 bg-white px-2 py-2 md:flex">
<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="oc-nav-link"
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
:class="route.name === 'map' ? activeNavClass : inactiveNavClass"
>
地图
</RouterLink>
<RouterLink
to="/community"
class="oc-nav-link"
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
:class="route.name === 'community' ? activeNavClass : inactiveNavClass"
>
社区
</RouterLink>
<RouterLink
to="/gallery"
class="oc-nav-link"
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
:class="route.name === 'gallery' ? activeNavClass : inactiveNavClass"
>
画廊
@@ -195,7 +195,7 @@ async function handleLogout() {
<RouterLink
v-if="authStore.isLoggedIn"
to="/encyclopedia"
class="oc-nav-link"
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? activeNavClass : inactiveNavClass"
>
图鉴
@@ -210,7 +210,7 @@ async function handleLogout() {
class="no-underline"
>
<span
class="oc-header-button oc-header-button--sky uppercase tracking-[0.12em]"
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="route.name === 'upload' ? 'ring-1 ring-sky-300' : ''"
>
上传
@@ -227,8 +227,8 @@ async function handleLogout() {
>
<button
type="button"
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' : ''"
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)]' : ''"
aria-haspopup="menu"
:aria-expanded="accountCardOpen"
>
@@ -243,10 +243,10 @@ async function handleLogout() {
class="absolute right-0 top-full z-50 w-72 pt-3"
role="menu"
>
<div class="oc-floating-panel border border-slate-200 bg-white">
<div class="border-b border-slate-200 bg-[linear-gradient(180deg,#f2faf6_0%,#ffffff_100%)] px-4 py-4">
<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="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,#eef9f3_0%,#d9efe3_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,#ecfeff_0%,#ccfbf1_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="oc-menu-item"
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"
role="menuitem"
>
<NIcon size="18"><User /></NIcon>
@@ -273,7 +273,7 @@ async function handleLogout() {
</RouterLink>
<RouterLink
to="/profile/settings"
class="oc-menu-item"
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"
role="menuitem"
>
<NIcon size="18"><Settings /></NIcon>
@@ -282,7 +282,7 @@ async function handleLogout() {
<RouterLink
v-if="authStore.isAdmin"
to="/admin"
class="oc-menu-item oc-menu-item--amber"
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"
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="oc-menu-item oc-menu-item--danger px-2"
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"
role="menuitem"
@click="handleLogout"
>
@@ -312,7 +312,7 @@ async function handleLogout() {
class="no-underline"
>
<span
class="oc-header-button oc-header-button--neutral"
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="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="oc-header-button oc-header-button--sky"
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="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="oc-nav-link shrink-0"
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
:class="route.name === 'map' ? activeNavClass : inactiveNavClass"
>
地图
</RouterLink>
<RouterLink
to="/community"
class="oc-nav-link shrink-0"
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
:class="route.name === 'community' ? activeNavClass : inactiveNavClass"
>
社区
</RouterLink>
<RouterLink
to="/gallery"
class="oc-nav-link shrink-0"
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
:class="route.name === 'gallery' ? activeNavClass : inactiveNavClass"
>
画廊
@@ -359,56 +359,31 @@ async function handleLogout() {
<RouterLink
v-if="authStore.isLoggedIn"
to="/encyclopedia"
class="oc-nav-link shrink-0"
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? activeNavClass : inactiveNavClass"
>
图鉴
</RouterLink>
<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="route.name === 'profile' ? activeNavClass : inactiveNavClass"
>
主页
</RouterLink>
</nav>
</div>
</header>
<Transition name="header-reveal">
<div
v-if="headerHidden"
class="pointer-events-none absolute inset-x-0 top-3 flex justify-center"
>
<button
type="button"
class="oc-header-button oc-header-button--neutral pointer-events-auto gap-2 bg-white/94 text-xs tracking-[0.16em] backdrop-blur"
@click="showHeader(true)"
>
<NIcon size="15"><ChevronDown /></NIcon>
<span>展开导航</span>
</button>
</div>
</Transition>
<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"
@click="showHeader(true)"
>
<span>▼</span>
<span>展开导航</span>
</button>
</div>
</template>
<style scoped>
.header-reveal-enter-active,
.header-reveal-leave-active {
transition:
opacity 240ms ease,
transform 240ms ease;
}
.header-reveal-enter-from,
.header-reveal-leave-to {
opacity: 0;
transform: translateY(-0.75rem);
}
@media (prefers-reduced-motion: reduce) {
.header-reveal-enter-active,
.header-reveal-leave-active {
transition-duration: 1ms;
}
.header-reveal-enter-from,
.header-reveal-leave-to {
transform: none;
}
}
</style>
+24 -68
View File
@@ -22,11 +22,15 @@ const props = withDefaults(defineProps<{
cells: ContributionHeatmapCell[]
totalCount: number
activeDays: number
currentStreak: number
bestStreak: number
activeDate?: string | null
title?: string
subtitle?: string
}>(), {
activeDate: null,
title: '上传活跃度',
subtitle: '按上传日期统计',
})
const emit = defineEmits<{
@@ -150,38 +154,31 @@ const visibleCells = computed(() => {
return cells
})
const contributionQuartiles = computed(() => {
const counts = visibleCells.value
.filter(cell => cell.inRange && cell.count > 0)
.map(cell => cell.count)
.sort((a, b) => a - b)
if (!counts.length) return [0, 0, 0] as const
const percentile = (value: number) => {
const index = Math.ceil(counts.length * value) - 1
return counts[Math.max(0, index)] || 0
}
return [percentile(0.25), percentile(0.5), percentile(0.75)] as const
const maxCount = computed(() => {
return visibleCells.value.reduce((max, cell) => Math.max(max, cell.count), 0)
})
function getLevel(count: number) {
if (count <= 0) return 0
const [firstQuartile, secondQuartile, thirdQuartile] = contributionQuartiles.value
const max = Math.max(maxCount.value, 1)
const ratio = count / max
if (count <= firstQuartile) return 1
if (count <= secondQuartile) return 2
if (count <= thirdQuartile) return 3
return 4
if (ratio >= 0.75 || count >= 8) return 4
if (ratio >= 0.5 || count >= 5) return 3
if (ratio >= 0.25 || count >= 3) return 2
return 1
}
function getCellClass(cell: { count: number; inRange: boolean }) {
if (!cell.inRange) return 'bg-transparent border-transparent'
const level = getLevel(cell.count)
return `oc-heatmap-cell--level-${level}`
if (level === 0) return 'bg-zinc-100 border-zinc-200'
if (level === 1) return 'bg-[#e0f2fe] border-[#bae6fd]'
if (level === 2) return 'bg-[#7dd3fc] border-[#38bdf8]'
if (level === 3) return 'bg-[#0ea5e9] border-[#0284c7]'
return 'bg-[#0f3d63] border-[#0b2f4d]'
}
const weeks = computed(() => {
@@ -287,7 +284,7 @@ function formatMonthTooltip(monthKey: string) {
</script>
<template>
<div class="oc-panel-card border border-slate-200 bg-white p-6">
<div class="oc-panel-card border border-slate-200 bg-white p-6 shadow-sm">
<div class="flex flex-wrap items-start justify-between gap-6">
<div class="min-w-0 flex-1">
<h3 class="text-2xl font-bold text-slate-900">{{ title }}</h3>
@@ -382,7 +379,7 @@ function formatMonthTooltip(monthKey: string) {
v-for="cell in week"
:key="cell.date"
type="button"
class="oc-heatmap-cell relative h-3.5 w-3.5 border hover:scale-125"
class="relative h-3.5 w-3.5 border transition-transform hover:scale-125"
:class="[
getCellClass(cell),
props.activeDate === cell.date ? 'ring-2 ring-slate-900 ring-offset-1' : '',
@@ -399,53 +396,12 @@ function formatMonthTooltip(monthKey: string) {
<div class="mt-5 flex items-center justify-end gap-2 text-xs text-slate-500">
<span>少</span>
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-0"></span>
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-1"></span>
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-2"></span>
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-3"></span>
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-4"></span>
<span class="h-3 w-3 border border-slate-200 bg-slate-100"></span>
<span class="h-3 w-3 border border-sky-200 bg-sky-100"></span>
<span class="h-3 w-3 border border-sky-400 bg-sky-300"></span>
<span class="h-3 w-3 border border-teal-600 bg-teal-500"></span>
<span class="h-3 w-3 border border-slate-900 bg-slate-900"></span>
<span>多</span>
</div>
</div>
</template>
<style scoped>
.oc-heatmap-cell,
.oc-heatmap-legend-cell {
transition:
background-color var(--oc-duration-fast) var(--oc-ease-standard),
border-color var(--oc-duration-fast) var(--oc-ease-standard),
transform var(--oc-duration-fast) var(--oc-ease-standard);
}
.oc-heatmap-legend-cell {
width: 0.75rem;
height: 0.75rem;
border-width: 1px;
}
.oc-heatmap-cell--level-0 {
background: var(--oc-heatmap-empty);
border-color: var(--oc-heatmap-empty-border);
}
.oc-heatmap-cell--level-1 {
background: var(--oc-heatmap-level-1);
border-color: var(--oc-heatmap-level-1-border);
}
.oc-heatmap-cell--level-2 {
background: var(--oc-heatmap-level-2);
border-color: var(--oc-heatmap-level-2-border);
}
.oc-heatmap-cell--level-3 {
background: var(--oc-heatmap-level-3);
border-color: var(--oc-heatmap-level-3-border);
}
.oc-heatmap-cell--level-4 {
background: var(--oc-heatmap-level-4);
border-color: var(--oc-heatmap-level-4-border);
}
</style>
+7 -10
View File
@@ -3,22 +3,19 @@ import type { GlobalThemeOverrides } from 'naive-ui'
/** Keep these values aligned with the semantic tokens in styles/tokens.css. */
export const openCloudThemeOverrides: GlobalThemeOverrides = {
common: {
primaryColor: '#34745d',
primaryColorHover: '#2d5f4e',
primaryColorPressed: '#274c40',
primaryColorSuppl: '#34745d',
primaryColor: '#0f766e',
primaryColorHover: '#115e59',
primaryColorPressed: '#134e4a',
primaryColorSuppl: '#0f766e',
infoColor: '#0369a1',
successColor: '#4d7f3b',
successColorHover: '#3f6633',
successColorPressed: '#35522c',
successColorSuppl: '#4d7f3b',
successColor: '#0f766e',
warningColor: '#d97706',
errorColor: '#dc2626',
borderRadius: '0px',
borderRadiusSmall: '0px',
fontFamily: '"IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif',
fontFamilyMono: '"IBM Plex Mono", "SFMono-Regular", monospace',
bodyColor: '#eef4f8',
bodyColor: '#f3f7fb',
cardColor: '#ffffff',
modalColor: '#ffffff',
popoverColor: '#ffffff',
@@ -27,7 +24,7 @@ export const openCloudThemeOverrides: GlobalThemeOverrides = {
textColor1: '#0f172a',
textColor2: '#334155',
textColor3: '#64748b',
textColorDisabled: 'var(--oc-color-text-disabled)',
textColorDisabled: '#94a3b8',
dividerColor: '#d9e3ee',
borderColor: '#d9e3ee',
inputColor: '#ffffff',
+3 -18
View File
@@ -23,14 +23,7 @@ export const useAuthStore = defineStore('auth', () => {
function applyAuth(auth: AuthResponse) {
setAccessToken(auth.access_token)
user.value = auth.user
profile.value = {
id: auth.user.id,
username: auth.user.username,
avatar_url: auth.user.avatar_url,
role: auth.user.role,
is_disabled: auth.user.is_disabled,
created_at: auth.user.created_at,
}
profile.value = auth.profile
}
function clearAuth() {
@@ -65,19 +58,11 @@ export const useAuthStore = defineStore('auth', () => {
}
async function updateUsername(username: string) {
const updatedUser = await apiRequest<AuthUser>('/profiles/me', {
const updatedProfile = await apiRequest<Profile>('/profiles/me', {
method: 'PATCH',
body: { username },
})
user.value = updatedUser
profile.value = {
id: updatedUser.id,
username: updatedUser.username,
avatar_url: updatedUser.avatar_url,
role: updatedUser.role,
is_disabled: updatedUser.is_disabled,
created_at: updatedUser.created_at,
}
profile.value = updatedProfile
}
async function updatePassword(password: string) {
+2 -20
View File
@@ -2,7 +2,7 @@ import { ref } from 'vue'
import { defineStore } from 'pinia'
import { apiRequest } from '@/lib/api'
import { useAuthStore } from '@/stores/auth'
import type { ApiCloud, ApiPage, LikeStats, PublicProfile } from '@/types/api'
import type { ApiCloud, ApiPage, PublicProfile } from '@/types/api'
import type { CloudType, Profile } from '@/types/database'
export interface ProfileCloudItem {
@@ -21,8 +21,6 @@ export interface ProfileCloudItem {
is_hidden: boolean
cloudTypeName: string
cloudTypeRarity: CloudType['rarity']
favoriteCount: number
isFavorited: boolean
}
function toProfileCloud(row: ApiCloud): ProfileCloudItem {
@@ -42,8 +40,6 @@ function toProfileCloud(row: ApiCloud): ProfileCloudItem {
is_hidden: row.is_hidden,
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
cloudTypeRarity: row.cloud_type_rarity || 'common',
favoriteCount: row.favorite_count,
isFavorited: row.is_favorited,
}
}
@@ -59,7 +55,6 @@ export const useProfileStore = defineStore('profile-page', () => {
const authStore = useAuthStore()
const profilesById = ref<Record<string, Profile>>({})
const cloudsByKey = ref<Record<string, ProfileCloudItem[]>>({})
const likeStatsByUserId = ref<Record<string, LikeStats>>({})
const loadingKeys = ref<Record<string, boolean>>({})
const errorByKey = ref<Record<string, string>>({})
@@ -73,10 +68,6 @@ export const useProfileStore = defineStore('profile-page', () => {
return userId ? cloudsByKey.value[makeKey(userId, isOwnProfile)] ?? [] : []
}
function getLikeStats(userId: string | null) {
return userId ? likeStatsByUserId.value[userId] ?? null : null
}
function getError(userId: string | null, isOwnProfile: boolean) {
return userId ? errorByKey.value[makeKey(userId, isOwnProfile)] ?? '' : ''
}
@@ -108,17 +99,9 @@ export const useProfileStore = defineStore('profile-page', () => {
const profilePromise = isOwnProfile && authStore.profile?.id === userId
? Promise.resolve(authStore.profile)
: apiRequest<PublicProfile>(`/profiles/${userId}`, { auth: false }).then(toProfile)
const likeStatsPromise = isOwnProfile
? apiRequest<LikeStats>('/profiles/me/like-stats')
: Promise.resolve(null)
const [profile, cloudRows, likeStats] = await Promise.all([
profilePromise,
fetchAllClouds(userId, isOwnProfile),
likeStatsPromise,
])
const [profile, cloudRows] = await Promise.all([profilePromise, fetchAllClouds(userId, isOwnProfile)])
profilesById.value[userId] = profile
cloudsByKey.value[key] = cloudRows.map(toProfileCloud)
if (likeStats) likeStatsByUserId.value[userId] = likeStats
} catch (error) {
errorByKey.value[key] = error instanceof Error ? error.message : '个人主页加载失败'
cloudsByKey.value[key] = []
@@ -203,7 +186,6 @@ export const useProfileStore = defineStore('profile-page', () => {
return {
getProfile,
getClouds,
getLikeStats,
getError,
isLoaded,
isLoading,
+37 -355
View File
@@ -40,21 +40,6 @@
@apply py-10;
}
.oc-page-hero__split {
@apply grid gap-6;
}
@media (min-width: 900px) {
.oc-page-hero__split {
grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.9fr);
align-items: start;
}
.oc-page-hero__split--stretch {
align-items: stretch;
}
}
.oc-page-content {
@apply py-8;
}
@@ -71,11 +56,6 @@
@apply mt-4 max-w-2xl text-sm leading-7 text-slate-600;
}
.oc-brand-mark {
@apply flex h-10 w-10 items-center justify-center border border-sky-200 bg-sky-100;
box-shadow: var(--oc-shadow-sm);
}
/* Reusable text hierarchy */
.oc-section-label {
@apply text-xs uppercase tracking-[0.18em] text-slate-500;
@@ -119,14 +99,6 @@
box-shadow: var(--oc-shadow-md) !important;
}
.oc-panel-card-xl {
box-shadow: var(--oc-shadow-xl) !important;
}
.oc-floating-panel {
box-shadow: var(--oc-shadow-lg);
}
.oc-panel-card-soft {
box-shadow: 6px 6px 0 0 rgb(15 23 42 / 4%) !important;
}
@@ -140,293 +112,13 @@
box-shadow: var(--oc-shadow-md);
}
.oc-modal-shell {
@apply border border-slate-200 bg-white;
box-shadow: var(--oc-shadow-xl);
}
.oc-inset-panel {
@apply border border-slate-200 bg-slate-50 p-4;
}
.oc-map-button {
@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-card {
box-shadow: var(--oc-shadow-md);
}
.oc-interactive-card:hover {
box-shadow: var(--oc-shadow-lg);
}
.oc-interactive-card--selected {
@apply border-sky-500;
box-shadow: var(--oc-shadow-sky) !important;
}
.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);
@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;
}
.oc-system-page {
@@ -449,16 +141,6 @@
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: var(--oc-color-text-disabled) !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;
@@ -475,21 +157,21 @@
}
.oc-primary-button--teal.n-button {
border-color: var(--color-teal-200) !important;
background: white !important;
color: var(--color-teal-700) !important;
border-color: rgb(153 246 228) !important;
background: rgb(255 255 255) !important;
color: rgb(17 94 89) !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: var(--color-teal-300) !important;
background: var(--color-teal-50) !important;
color: var(--color-teal-800) !important;
border-color: rgb(94 234 212) !important;
background: rgb(234 250 245) !important;
color: rgb(19 78 74) !important;
}
.oc-primary-button--teal.n-button:not(.n-button--disabled):hover {
box-shadow: var(--oc-shadow-primary-hover) !important;
box-shadow: 5px 5px 0 0 rgb(20 184 166 / 14%) !important;
}
.oc-primary-button--sky.n-button {
@@ -511,27 +193,27 @@
}
.oc-panel-button.n-button {
--n-color: white !important;
--n-color-hover: var(--color-teal-50) !important;
--n-color-pressed: var(--color-teal-100) !important;
--n-color: rgb(255 255 255) !important;
--n-color-hover: rgb(240 253 250) !important;
--n-color-pressed: rgb(236 253 245) !important;
--n-border: 1px solid rgb(226 232 240) !important;
--n-border-hover: 1px solid var(--color-teal-200) !important;
--n-border-pressed: 1px solid var(--color-teal-300) !important;
--n-border-hover: 1px solid rgb(153 246 228) !important;
--n-border-pressed: 1px solid rgb(94 234 212) !important;
--n-text-color: rgb(71 85 105) !important;
--n-text-color-hover: var(--color-teal-800) !important;
--n-text-color-pressed: var(--color-teal-700) !important;
--n-text-color-hover: rgb(19 78 74) !important;
--n-text-color-pressed: rgb(17 94 89) !important;
border-color: rgb(226 232 240) !important;
background: white !important;
background: rgb(255 255 255) !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: var(--color-teal-200) !important;
background: var(--color-teal-50) !important;
color: var(--color-teal-800) !important;
box-shadow: var(--oc-shadow-primary) !important;
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;
}
.oc-panel-button--sky.n-button {
@@ -559,27 +241,27 @@
}
.oc-panel-button--teal.n-button {
--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;
--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;
box-shadow: var(--oc-shadow-teal) !important;
}
.oc-panel-button--teal.n-button:hover,
.oc-panel-button--teal.n-button:focus-visible {
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;
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;
}
.oc-panel-button--danger.n-button {
+8 -51
View File
@@ -7,33 +7,6 @@
: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;
@@ -43,34 +16,21 @@
--oc-color-text: #0f172a;
--oc-color-text-secondary: #334155;
--oc-color-text-muted: #64748b;
--oc-color-text-disabled: #94a3b8;
--oc-color-border: #d9e3ee;
--oc-color-primary: #34745d;
--oc-color-primary-hover: #2d5f4e;
--oc-color-primary-pressed: #274c40;
--oc-color-primary: #0f766e;
--oc-color-primary-hover: #115e59;
--oc-color-primary-pressed: #134e4a;
--oc-color-info: #0369a1;
--oc-color-success: #4d7f3b;
--oc-color-success: #0f766e;
--oc-color-warning: #d97706;
--oc-color-danger: #dc2626;
--oc-color-selection: rgb(52 116 93 / 20%);
/* Contribution heatmap: no activity + GitHub-style contribution quartiles. */
--oc-heatmap-empty: #f1f5f9;
--oc-heatmap-empty-border: #d9e3ee;
--oc-heatmap-level-1: #ddf2e6;
--oc-heatmap-level-1-border: #bce4cf;
--oc-heatmap-level-2: #8fceb0;
--oc-heatmap-level-2-border: #60b18d;
--oc-heatmap-level-3: #438f71;
--oc-heatmap-level-3-border: #34745d;
--oc-heatmap-level-4: #274c40;
--oc-heatmap-level-4-border: #213e35;
--oc-color-selection: rgb(15 118 110 / 18%);
--oc-gradient-canvas:
radial-gradient(circle at top left, rgb(52 116 93 / 11%), transparent 28%),
radial-gradient(circle at top left, rgb(12 148 136 / 12%), 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, #f2faf6 0%, #f8fafc 100%);
--oc-gradient-page-soft: linear-gradient(180deg, #f0fdfa 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);
@@ -81,10 +41,7 @@
--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-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-amber: 5px 5px 0 0 rgb(245 158 11 / 12%);
--oc-shadow-teal: var(--oc-shadow-primary);
--oc-shadow-teal: 4px 4px 0 0 rgb(20 184 166 / 10%);
--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, immediately?: boolean, duration?: number): void
setZoom(zoom: 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(digits?: number): number
getZoom(): number
getAllOverlays(type?: string): unknown[]
addOverlay(overlay: unknown): void
removeOverlay(overlay: unknown): void
+3 -25
View File
@@ -4,13 +4,6 @@ export interface AuthUser {
id: string
email: string
email_verified: boolean
username: string
avatar_url: string | null
role: Profile['role']
is_disabled: boolean
cloud_count: number
public_cloud_count: number
collection_count: number
created_at: string
}
@@ -19,10 +12,12 @@ export interface AuthResponse {
token_type: 'bearer'
expires_in: number
user: AuthUser
profile: Profile
}
export interface MeResponse {
user: AuthUser
profile: Profile
}
export interface PublicProfile {
@@ -59,24 +54,6 @@ export interface ApiCloud extends Cloud {
cloud_type_name: string | null
cloud_type_rarity: CloudType['rarity'] | null
username: string | null
favorite_count: number
is_favorited: boolean
}
export interface LikeStatsPhoto {
cloud_id: string
image_url: string
thumbnail_url: string
favorite_count: number
captured_at: string | null
created_at: string
}
export interface LikeStats {
user_id: string
username: string
total_likes: number
photos: LikeStatsPhoto[]
}
export interface ApiCollection extends UserCollection {
@@ -112,4 +89,5 @@ export interface AdminStats {
export interface AdminUserItem {
user: AuthUser
profile: Profile
}
+37 -76
View File
@@ -234,7 +234,7 @@ async function fetchStats() {
async function fetchUsers() {
const result = await apiRequest<ApiPage<AdminUserItem>>('/admin/users?page=1&page_size=100')
users.value = result.items.map(item => item.user)
users.value = result.items.map(item => item.profile)
}
async function fetchImages() {
@@ -435,7 +435,7 @@ async function updateUserRole(user: Profile, role: Profile['role']) {
method: 'PATCH',
body: { role },
})
users.value = users.value.map(item => item.id === user.id ? result.user : item)
users.value = users.value.map(item => item.id === user.id ? result.profile : item)
message.success('用户角色已更新')
} catch (error) {
const text = getErrorMessage(error, '用户角色更新失败')
@@ -461,7 +461,7 @@ async function toggleUserDisabled(user: Profile) {
method: 'PATCH',
body: { is_disabled: nextDisabled },
})
users.value = users.value.map(item => item.id === user.id ? result.user : item)
users.value = users.value.map(item => item.id === user.id ? result.profile : item)
message.success(nextDisabled ? '用户已禁用' : '用户已恢复')
} catch (error) {
const text = getErrorMessage(error, '用户状态更新失败')
@@ -502,7 +502,7 @@ onMounted(loadAdminData)
>
<div class="flex items-center">
<div class="flex min-w-0 items-center gap-3">
<span class="admin-brand-icon flex h-10 w-10 shrink-0 items-center justify-center border border-sky-200 bg-sky-50 text-sky-800">
<span class="flex h-10 w-10 shrink-0 items-center justify-center border border-sky-200 bg-sky-50 text-sky-800 shadow-[3px_3px_0_0_rgba(14,165,233,0.12)]">
<NIcon size="22"><Shield /></NIcon>
</span>
<span
@@ -519,7 +519,7 @@ onMounted(loadAdminData)
<button
type="button"
class="oc-icon-button oc-icon-button--small hidden lg:absolute lg:-right-4 lg:top-6 lg:z-20 lg:flex"
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"
: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
? 'admin-control-active border-teal-200 bg-teal-50 text-teal-900'
? 'border-teal-200 bg-teal-50 text-teal-900 shadow-[3px_3px_0_0_rgba(20,184,166,0.14)]'
: '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
? 'admin-control-active border-teal-200 bg-teal-50 text-teal-800'
? 'border-teal-200 bg-teal-50 text-teal-800 shadow-[3px_3px_0_0_rgba(20,184,166,0.12)]'
: '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"
@@ -611,13 +611,13 @@ onMounted(loadAdminData)
</template>
<div
class="oc-floating-panel w-64 border border-slate-200 bg-white"
class="w-64 border border-slate-200 bg-white shadow-[8px_8px_0_0_rgba(15,23,42,0.08)]"
role="menu"
aria-label="管理员账户"
>
<div class="border-b border-slate-200 bg-[linear-gradient(180deg,#f2faf6_0%,#ffffff_100%)] px-4 py-4">
<div class="border-b border-slate-200 bg-[linear-gradient(180deg,#f0fdfa_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,#eef9f3_0%,#d9efe3_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,#ecfeff_0%,#ccfbf1_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="oc-menu-item oc-menu-item--danger px-2"
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"
role="menuitem"
@click="handleLogout"
>
@@ -663,7 +663,7 @@ onMounted(loadAdminData)
</NPopover>
<RouterLink
to="/"
class="oc-panel-button oc-panel-button--neutral flex h-[46px] shrink-0 items-center justify-start gap-3 overflow-hidden px-3 font-semibold focus-visible:ring-2 focus-visible:ring-sky-600 lg:w-full"
class="flex h-[46px] shrink-0 items-center justify-start gap-3 overflow-hidden border border-slate-200 bg-white px-3 text-sm font-semibold text-slate-700 shadow-[3px_3px_0_0_rgba(15,23,42,0.06)] transition-colors hover:border-sky-200 hover:bg-sky-50 hover:text-sky-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-600 lg:w-full"
title="返回主页"
>
<NIcon size="20" class="shrink-0"><Home /></NIcon>
@@ -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="oc-panel-card border border-slate-200 bg-white p-5">
<div v-for="n in 8" :key="n" class="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
? 'admin-priority-card--pending border-amber-200 bg-[linear-gradient(135deg,#fffbeb_0%,#fff_52%,#f2faf6_100%)]'
: 'admin-priority-card--clear border-teal-200 bg-[linear-gradient(135deg,#f2faf6_0%,#fff_55%,#eff6ff_100%)]'"
? '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)]'"
>
<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="oc-panel-card grid grid-cols-2 border border-slate-200 bg-white">
<div class="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="oc-panel-card border border-slate-200 bg-white p-5 sm:p-6">
<div class="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="oc-panel-card border border-slate-200 bg-white p-5 sm:p-6">
<div class="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,29 +849,22 @@ onMounted(loadAdminData)
</div>
</div>
<div class="oc-panel-card border border-slate-200 bg-white">
<div class="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>
<NButton
size="small"
type="default"
secondary
strong
class="oc-panel-button oc-panel-button--neutral"
@click="activeTab = 'images'"
>
<button type="button" class="text-sm font-medium text-sky-700 hover:text-sky-900" @click="activeTab = 'images'">
查看全部图片
</NButton>
</button>
</div>
<div v-if="recentImages.length" class="divide-y divide-slate-100">
<button
v-for="item in recentImages"
:key="item.id"
type="button"
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]"
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]"
@click="selectedImage = item"
>
<img :src="item.thumbnail_url || item.image_url" :alt="item.cloudTypeName" class="h-11 w-13 object-cover" />
@@ -890,20 +883,13 @@ onMounted(loadAdminData)
</section>
<section v-else-if="activeTab === 'review'">
<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 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>
<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
v-if="pendingImages.length > 0"
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--neutral"
@click="toggleAllPendingSelection"
>
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="toggleAllPendingSelection">
{{ selectedReviewCount === pendingImages.length && pendingImages.length ? '取消全选' : '全选待审核' }}
</NButton>
<template v-if="selectedReviewCount">
@@ -932,7 +918,7 @@ onMounted(loadAdminData)
</div>
<div v-if="activeReviewImage" class="grid min-w-0 gap-4 xl:grid-cols-[220px_minmax(0,1fr)]">
<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)]">
<aside class="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>
@@ -953,7 +939,7 @@ onMounted(loadAdminData)
>
<button
type="button"
class="oc-interactive-row flex min-w-0 flex-1 items-center gap-3 text-left"
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"
:aria-pressed="activeReviewImage.id === item.id"
@click="activeReviewId = item.id"
>
@@ -979,7 +965,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="oc-panel-card border border-slate-200 bg-white">
<div class="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>
@@ -1009,7 +995,7 @@ onMounted(loadAdminData)
</div>
</div>
<div class="oc-panel-card border border-slate-200 bg-white p-5">
<div class="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">
@@ -1018,7 +1004,7 @@ onMounted(loadAdminData)
</div>
</div>
<aside class="oc-panel-card self-start border border-slate-200 bg-white lg:sticky lg:top-20">
<aside class="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>
@@ -1102,13 +1088,13 @@ onMounted(loadAdminData)
</div>
</div>
<div v-else class="oc-panel-card border border-dashed border-slate-300 bg-white p-10">
<div v-else class="border border-dashed border-slate-300 bg-white p-10">
<NEmpty description="当前没有待审核图片" />
</div>
</section>
<section v-else-if="activeTab === 'users'">
<div class="oc-panel-card overflow-hidden border border-slate-200 bg-white">
<div class="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>
@@ -1159,7 +1145,7 @@ onMounted(loadAdminData)
</section>
<section v-else>
<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 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>
<h2 class="text-xl font-bold text-slate-950">图片管理</h2>
<p class="oc-field-help">最近 {{ images.length }} 张图片,可调整审核状态、可见性或删除。</p>
@@ -1174,14 +1160,7 @@ onMounted(loadAdminData)
<option value="approved">已通过</option>
<option value="rejected">已拒绝</option>
</select>
<NButton
v-if="filteredImages.length > 0"
secondary
strong
type="default"
class="oc-panel-button oc-panel-button--neutral"
@click="toggleAllFilteredImageSelection"
>
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="toggleAllFilteredImageSelection">
{{ selectedManagedCount === filteredImages.length && filteredImages.length ? '取消全选' : '全选当前列表' }}
</NButton>
<template v-if="selectedManagedCount">
@@ -1240,8 +1219,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="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">
<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">
<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">
@@ -1284,7 +1263,7 @@ onMounted(loadAdminData)
</article>
</div>
<div v-else class="oc-panel-card border border-dashed border-slate-300 bg-white p-10">
<div v-else class="border border-dashed border-slate-300 bg-white p-10">
<NEmpty description="没有符合筛选条件的图片" />
</div>
</section>
@@ -1341,21 +1320,3 @@ onMounted(loadAdminData)
</ImageDetailModal>
</div>
</template>
<style scoped>
.admin-brand-icon {
box-shadow: var(--oc-shadow-sky);
}
.admin-control-active {
box-shadow: var(--oc-shadow-primary);
}
.admin-priority-card--pending {
box-shadow: var(--oc-shadow-amber);
}
.admin-priority-card--clear {
box-shadow: var(--oc-shadow-primary-hover);
}
</style>
+1 -1
View File
@@ -53,7 +53,7 @@ onUnmounted(() => {
<template>
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
<div class="mx-auto max-w-3xl">
<NCard class="oc-panel-card-xl">
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
<template v-if="state === 'success'">
<NResult status="success" title="邮箱认证成功" description="你的邮箱已确认,现在可以登录了。">
<template #footer>
+7 -12
View File
@@ -73,10 +73,10 @@ onUnmounted(() => {
<template>
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
<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="oc-panel-card-xl min-w-0 overflow-hidden border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_52%,#f2faf6_100%)] p-6 sm:p-8">
<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)]">
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Password Recovery</p>
<h1 class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl">
<h1 class="mt-4 max-w-xl text-5xl font-black leading-[1.05] text-slate-900">
重新拿回你的
<span class="block text-sky-700">OpenCloud 账号</span>
</h1>
@@ -97,7 +97,7 @@ onUnmounted(() => {
</div>
</section>
<NCard class="oc-panel-card-xl min-w-0">
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
<NResult
v-if="submitted"
status="success"
@@ -121,7 +121,7 @@ onUnmounted(() => {
{{ resendButtonLabel }}
</NButton>
<RouterLink :to="{ path: '/login', query: { email } }" class="no-underline">
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">返回登录</NButton>
<NButton type="default" class="oc-panel-button oc-panel-button--neutral">返回登录</NButton>
</RouterLink>
</div>
</div>
@@ -139,13 +139,8 @@ onUnmounted(() => {
<NFormItem label="邮箱">
<NInput
v-model:value="email"
type="text"
:input-props="{
autocomplete: 'email',
inputmode: 'email',
name: 'email',
required: true,
}"
required
autocomplete="email"
placeholder="your@email.com"
/>
</NFormItem>
+11 -19
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 min-w-0 max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-stretch">
<section class="oc-panel-card-xl 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 sm:p-8">
<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>
<p class="text-sm uppercase tracking-[0.26em] text-teal-700">Sky Log In</p>
<h1 class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl">
<h1 class="mt-4 max-w-xl text-5xl font-black leading-[1.05] text-slate-900">
登录后继续记录
<span class="block text-teal-700">你眼中的每一朵云</span>
</h1>
@@ -54,13 +54,13 @@ async function handleLogin() {
在地图、图鉴和画廊之间同步你的观测记录。上传新的云图后,图鉴会自动点亮,画廊也会按时间收纳你的作品。
</p> -->
</div>
<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="mt-8 flex gap-4">
<div class="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="min-w-0 border border-slate-200 bg-white px-4 py-3">
<div class="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="oc-panel-card-xl min-w-0 h-full">
<NCard class="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>
@@ -79,13 +79,8 @@ async function handleLogin() {
<NFormItem label="邮箱">
<NInput
v-model:value="email"
type="text"
:input-props="{
autocomplete: 'username',
inputmode: 'email',
name: 'username',
required: true,
}"
required
autocomplete="email"
placeholder="your@email.com"
/>
</NFormItem>
@@ -94,12 +89,9 @@ async function handleLogin() {
<NInput
v-model:value="password"
type="password"
required
show-password-on="click"
:input-props="{
autocomplete: 'current-password',
name: 'current-password',
required: true,
}"
autocomplete="current-password"
placeholder="输入密码"
/>
</NFormItem>
+15 -29
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 min-w-0 max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-stretch">
<section class="oc-panel-card-xl 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 sm:p-8">
<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>
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Observer Join</p>
<h1 class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl">
<h1 class="mt-4 max-w-xl text-5xl font-black leading-[1.05] text-slate-900">
加入天空探索者
<span class="block text-sky-700">建立你的云图档案</span>
</h1>
@@ -59,13 +59,13 @@ async function handleRegister() {
注册后即可上传云图、点亮图鉴、在社区画廊里按时间展示你的观测记录。所有页面都会围绕你的个人云层档案同步更新。
</p> -->
</div>
<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="mt-8 flex gap-4">
<div class="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="min-w-0 border border-slate-200 bg-white px-4 py-3">
<div class="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="oc-panel-card-xl min-w-0 h-full">
<NCard class="h-full shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
<NResult
v-if="emailSent"
status="success"
@@ -105,11 +105,8 @@ async function handleRegister() {
<NInput
v-model:value="username"
type="text"
:input-props="{
autocomplete: 'username',
name: 'username',
required: true,
}"
required
autocomplete="username"
placeholder="你的昵称"
maxlength="20"
/>
@@ -118,13 +115,8 @@ async function handleRegister() {
<NFormItem label="邮箱">
<NInput
v-model:value="email"
type="text"
:input-props="{
autocomplete: 'email',
inputmode: 'email',
name: 'email',
required: true,
}"
required
autocomplete="email"
placeholder="your@email.com"
/>
</NFormItem>
@@ -133,12 +125,9 @@ async function handleRegister() {
<NInput
v-model:value="password"
type="password"
required
show-password-on="click"
:input-props="{
autocomplete: 'new-password',
name: 'new-password',
required: true,
}"
autocomplete="new-password"
placeholder="至少8位"
/>
</NFormItem>
@@ -147,12 +136,9 @@ async function handleRegister() {
<NInput
v-model:value="confirmPassword"
type="password"
required
show-password-on="click"
:input-props="{
autocomplete: 'new-password',
name: 'confirm-new-password',
required: true,
}"
autocomplete="new-password"
placeholder="再次输入密码"
/>
</NFormItem>
+5 -19
View File
@@ -105,7 +105,7 @@ onUnmounted(() => {
<template>
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
<div class="mx-auto max-w-2xl">
<NCard class="oc-panel-card-xl">
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
<NResult
v-if="state === 'success'"
status="success"
@@ -142,24 +142,13 @@ onUnmounted(() => {
</div>
<NForm @submit.prevent="handleResetPassword">
<input
type="text"
name="username"
autocomplete="username"
class="sr-only"
tabindex="-1"
aria-label="账户用户名"
/>
<NFormItem label="新密码">
<NInput
v-model:value="password"
type="password"
required
show-password-on="click"
:input-props="{
autocomplete: 'new-password',
name: 'new-password',
required: true,
}"
autocomplete="new-password"
placeholder="至少 8 位"
/>
</NFormItem>
@@ -168,12 +157,9 @@ onUnmounted(() => {
<NInput
v-model:value="confirmPassword"
type="password"
required
show-password-on="click"
:input-props="{
autocomplete: 'new-password',
name: 'confirm-new-password',
required: true,
}"
autocomplete="new-password"
placeholder="再次输入新密码"
/>
</NFormItem>
+2 -2
View File
@@ -2,8 +2,8 @@
</script>
<template>
<div class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#f2faf6_0%,#f8fafc_100%)] px-4 py-16">
<section class="oc-panel-card-xl mx-auto max-w-4xl border border-slate-200 bg-white p-10">
<div class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#f0fdfa_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>
<p class="mt-5 max-w-2xl text-sm leading-7 text-slate-600">
+8 -8
View File
@@ -147,7 +147,7 @@ watch(() => route.params.id, () => {
<div v-if="loading" class="space-y-6">
<NSkeleton class="h-80 w-full" />
<div class="grid gap-4 lg:grid-cols-3">
<NCard v-for="n in 3" :key="n" class="oc-panel-card border border-slate-200">
<NCard v-for="n in 3" :key="n">
<NSkeleton class="h-4 w-1/3" />
<NSkeleton class="mt-4 h-7 w-1/2" />
</NCard>
@@ -165,7 +165,7 @@ watch(() => route.params.id, () => {
</NAlert>
<template v-else-if="cloudType">
<section class="oc-panel-card overflow-hidden border border-slate-200 bg-white">
<section class="overflow-hidden border border-slate-200 bg-white shadow-sm">
<div class="grid lg:grid-cols-[1.15fr_0.85fr]">
<div class="relative min-h-[360px] overflow-hidden">
<img
@@ -197,7 +197,7 @@ watch(() => route.params.id, () => {
</p>
</div>
<div class="oc-inset-panel mt-8">
<NCard class="mt-8 border border-slate-200">
<p class="text-sm text-slate-500">你的收集状态</p>
<template v-if="isUnlocked && collectionEntry">
<p class="oc-stat-value">已解锁</p>
@@ -207,21 +207,21 @@ watch(() => route.params.id, () => {
<p class="oc-stat-value">尚未解锁</p>
<p class="mt-2 text-sm text-slate-600">拍到并上传这种云朵后,这枚徽章就会被点亮。</p>
</template>
</div>
</NCard>
</div>
</div>
</section>
<section class="mt-6 grid gap-4 md:grid-cols-3">
<NCard class="oc-panel-card border border-slate-200">
<NCard class="border border-slate-200 shadow-sm">
<p class="text-sm text-slate-500">稀有度</p>
<p class="oc-stat-value">{{ rarityMeta[cloudType.rarity].label }}</p>
</NCard>
<NCard class="oc-panel-card border border-slate-200">
<NCard class="border border-slate-200 shadow-sm">
<p class="text-sm text-slate-500">公开云图</p>
<p class="oc-stat-value">{{ publicCount }}</p>
</NCard>
<NCard class="oc-panel-card border border-slate-200">
<NCard class="border border-slate-200 shadow-sm">
<p class="text-sm text-slate-500">首次解锁</p>
<p class="mt-2 text-xl font-bold text-slate-900">
{{ collectionEntry ? formatDate(collectionEntry.unlocked_at) : '等待记录' }}
@@ -242,7 +242,7 @@ watch(() => route.params.id, () => {
v-for="item in gallery"
:key="item.id"
type="button"
class="oc-interactive-card overflow-hidden border border-slate-200 bg-white text-left transition-transform duration-300 hover:-translate-y-1"
class="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" />
+47 -81
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { NAlert, NEmpty, NIcon, NSkeleton } from 'naive-ui'
import { NAlert, NCard, NEmpty, NIcon, NSkeleton } from 'naive-ui'
import { useRouter } from 'vue-router'
import { useAuthStore } from '@/stores/auth'
import { useEncyclopediaStore } from '@/stores/encyclopedia'
@@ -64,8 +64,8 @@ onMounted(async () => {
<div class="relative">
<div class="oc-page-hero">
<div class="oc-container oc-container--content oc-page-hero__inner">
<div class="oc-page-hero__split">
<div class="min-w-0">
<div class="grid gap-6 lg:grid-cols-[1.4fr_0.9fr] lg:items-start">
<div>
<p class="oc-page-eyebrow">Cloud Encyclopedia</p>
<h1 class="oc-page-title">云朵图鉴</h1>
<p class="oc-page-description">
@@ -76,6 +76,7 @@ onMounted(async () => {
<EncyclopediaProgressCard
:unlocked-count="authStore.isLoggedIn ? encyclopediaStore.unlockedCount : 0"
:total-count="encyclopediaStore.cloudTypes.length || 10"
:percent="encyclopediaStore.unlockPercent"
:is-logged-in="authStore.isLoggedIn"
/>
</div>
@@ -94,75 +95,81 @@ onMounted(async () => {
图鉴收藏数据暂时不可用:{{ encyclopediaStore.collectionError }}
</NAlert>
<div v-if="encyclopediaStore.loadingCloudTypes" class="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
<div v-for="n in 6" :key="n" class="oc-panel-card overflow-hidden border border-slate-200 bg-white">
<NSkeleton class="h-44 w-full border-b border-slate-200" />
<div class="p-5">
<NSkeleton class="h-3 w-1/3" />
<NSkeleton class="mt-3 h-6 w-2/5" />
<NSkeleton class="mt-2 h-3 w-1/2" />
<NSkeleton class="mt-5 h-4 w-full" :repeat="2" />
<NSkeleton class="mt-5 h-10 w-full" />
</div>
</div>
<div v-if="encyclopediaStore.loadingCloudTypes" class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<NCard v-for="n in 6" :key="n" class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
<NSkeleton class="h-44 w-full" />
<NSkeleton class="mt-5 h-5 w-2/5" />
<NSkeleton class="mt-3 h-4 w-1/3" />
<NSkeleton class="mt-6 h-4 w-full" :repeat="2" />
</NCard>
</div>
<div v-else-if="encyclopediaStore.cloudTypes.length" class="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
<div v-else-if="encyclopediaStore.cloudTypes.length" class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<button
v-for="cloudType in encyclopediaStore.cloudTypes"
:key="cloudType.id"
type="button"
@click="openCard(cloudType)"
class="encyclopedia-type-card oc-interactive-card oc-panel-card flex h-full flex-col overflow-hidden border bg-white text-left"
:class="isUnlocked(cloudType.id) ? 'border-sky-200' : 'border-slate-200'"
class="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 class="relative h-44 shrink-0 overflow-hidden border-b border-slate-200 bg-sky-50">
<div
class="relative h-44 overflow-hidden border-b"
:class="isUnlocked(cloudType.id) ? 'border-white/40' : 'border-slate-200'"
>
<img
v-if="getCollectionEntry(cloudType.id)?.firstCloud?.image_url"
:src="getCollectionEntry(cloudType.id)?.firstCloud?.thumbnail_url || getCollectionEntry(cloudType.id)?.firstCloud?.image_url || ''"
:alt="cloudType.name"
class="encyclopedia-type-card__image h-full w-full object-cover"
:class="isUnlocked(cloudType.id) ? '' : 'grayscale contrast-75'"
class="h-full w-full object-cover transition duration-500 group-hover:scale-105"
:class="isUnlocked(cloudType.id) ? '' : 'grayscale'"
/>
<div
v-else
class="flex h-full w-full items-center justify-center bg-gradient-to-br"
:class="isUnlocked(cloudType.id) ? rarityMeta[cloudType.rarity].glow : 'from-slate-200 to-slate-50'"
:class="isUnlocked(cloudType.id) ? rarityMeta[cloudType.rarity].glow : 'from-slate-300 to-slate-100'"
>
<span class="font-mono text-5xl font-bold text-slate-500/75">{{ cloudType.name.slice(0, 1) }}</span>
<span class="text-6xl font-bold text-slate-800/85">{{ cloudType.name.slice(0, 1) }}</span>
</div>
<div v-if="!isUnlocked(cloudType.id)" class="absolute inset-0 flex items-center justify-center bg-slate-950/12">
<span class="encyclopedia-lock-badge inline-flex items-center gap-2 border border-slate-300 bg-white/92 px-3 py-2 text-xs font-semibold text-slate-700 backdrop-blur">
<NIcon size="15"><Lock /></NIcon>
尚未收录
</span>
<div class="absolute inset-0 bg-gradient-to-t from-slate-950/70 via-slate-950/10 to-transparent"></div>
<div v-if="!isUnlocked(cloudType.id)" class="absolute inset-0 flex items-center justify-center bg-slate-950/22 backdrop-blur-[2px]">
<div class="border border-white/45 bg-white/15 px-4 py-2 text-sm font-medium text-white">
<span style="display: inline-flex; align-items: center; gap: 4px;">
<NIcon size="16" style="display: inline-flex;">
<Lock />
</NIcon>
<span>尚未解锁</span>
</span>
</div>
</div>
<div class="absolute left-4 top-4">
<span class="inline-flex border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.12em]" :class="rarityMeta[cloudType.rarity].chip">
<span class="inline-flex border px-3 py-1 text-xs font-medium" :class="rarityMeta[cloudType.rarity].chip">
{{ rarityMeta[cloudType.rarity].label }}
</span>
</div>
<div class="absolute bottom-4 left-4 right-4 text-white">
<p class="text-xl font-semibold">{{ cloudType.name }}</p>
<p class="mt-1 text-sm text-white/80">{{ cloudType.name_en }}</p>
</div>
</div>
<div class="flex flex-1 flex-col p-5">
<p class="oc-section-label">Cloud Genus</p>
<h2 class="mt-2 text-xl font-bold text-slate-900">{{ cloudType.name }}</h2>
<p class="mt-1 font-mono text-xs uppercase tracking-[0.12em] text-slate-500">{{ cloudType.name_en }}</p>
<p class="mt-4 line-clamp-2 min-h-[3.25rem] text-sm leading-6 text-slate-600">
<div class="p-5">
<p class="line-clamp-2 min-h-[3.25rem] text-sm leading-6 text-slate-600">
{{ cloudType.description || '云层特征和识别要点会显示在这里。' }}
</p>
<div class="mt-5 flex items-center justify-between gap-3 border-t border-slate-200 pt-4 text-xs">
<div class="mt-5 flex items-center justify-between text-sm">
<template v-if="isUnlocked(cloudType.id) && getCollectionEntry(cloudType.id)">
<span class="font-semibold uppercase tracking-[0.12em] text-sky-800">已收录</span>
<span class="text-right text-slate-500">首次记录 {{ formatUnlockedAt(getCollectionEntry(cloudType.id)!.unlocked_at) }}</span>
<span class="font-medium text-amber-700">已收录</span>
<span class="text-slate-500">首次记录于 {{ formatUnlockedAt(getCollectionEntry(cloudType.id)!.unlocked_at) }}</span>
</template>
<template v-else>
<span class="font-semibold uppercase tracking-[0.12em] text-slate-600">等待发现</span>
<span class="text-right text-slate-400">拍到后点亮</span>
<span class="font-medium text-slate-700">等待发现</span>
<span class="text-slate-400">拍到后点亮</span>
</template>
</div>
</div>
@@ -176,50 +183,9 @@ onMounted(async () => {
<div
v-if="lockHint"
class="encyclopedia-lock-hint fixed bottom-6 left-1/2 z-50 -translate-x-1/2 border border-slate-900 bg-slate-900 px-4 py-2 text-sm text-white"
class="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 border border-slate-900 bg-slate-900 px-4 py-2 text-sm text-white shadow-lg"
>
{{ lockHint }}
</div>
</div>
</template>
<style scoped>
.encyclopedia-lock-badge {
box-shadow: var(--oc-shadow-xs);
}
.encyclopedia-lock-hint {
box-shadow: var(--oc-shadow-lg);
}
.encyclopedia-type-card:hover {
border-color: rgb(125 211 252);
box-shadow: var(--oc-shadow-lg) !important;
transform: translate(-1px, -1px);
}
.encyclopedia-type-card:active {
box-shadow: var(--oc-shadow-md) !important;
transform: translate(0, 0);
}
.encyclopedia-type-card__image {
transition: transform 500ms ease;
}
.encyclopedia-type-card:hover .encyclopedia-type-card__image {
transform: scale(1.035);
}
@media (prefers-reduced-motion: reduce) {
.encyclopedia-type-card,
.encyclopedia-type-card__image {
transition-duration: 1ms;
}
.encyclopedia-type-card:hover,
.encyclopedia-type-card:hover .encyclopedia-type-card__image {
transform: none;
}
}
</style>
+6 -8
View File
@@ -342,7 +342,7 @@ onUnmounted(() => {
<template>
<div>
<section class="oc-page-hero">
<div class="oc-container oc-container--content oc-page-hero__inner">
<div class="oc-container oc-container--wide oc-page-hero__inner">
<p class="oc-page-eyebrow">Community Gallery</p>
<h1 class="oc-page-title">云图画廊</h1>
<p class="oc-page-description">
@@ -351,9 +351,9 @@ onUnmounted(() => {
</div>
</section>
<div class="oc-container oc-container--content oc-page-content">
<div class="oc-container oc-container--wide oc-page-content">
<section>
<div class="oc-panel-card mb-4 border border-slate-200 bg-white p-3">
<div class="mb-4 border border-slate-200 bg-white p-3 shadow-[6px_6px_0_0_rgba(15,23,42,0.05)]">
<div class="flex items-center gap-3">
<NIcon size="20" class="shrink-0 text-slate-400">
<Search />
@@ -367,7 +367,7 @@ onUnmounted(() => {
<button
v-if="searchQuery"
type="button"
class="oc-icon-button oc-icon-button--small"
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"
title="清空搜索"
aria-label="清空搜索"
@click="clearSearch"
@@ -430,7 +430,7 @@ onUnmounted(() => {
:key="cloud.id"
type="button"
@click="openDetail(cloud)"
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 transition-transform duration-300 hover:-translate-y-1"
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"
>
<img
:src="cloud.thumbnail_url || cloud.image_url"
@@ -482,7 +482,6 @@ onUnmounted(() => {
<NButton
secondary
strong
type="default"
:disabled="currentPage <= 1"
class="oc-panel-button oc-panel-button--neutral"
@click="goToPage(currentPage - 1)"
@@ -510,7 +509,6 @@ onUnmounted(() => {
<NButton
secondary
strong
type="default"
:disabled="currentPage >= totalPages"
class="oc-panel-button oc-panel-button--neutral"
@click="goToPage(currentPage + 1)"
@@ -578,7 +576,7 @@ onUnmounted(() => {
>
<button
type="button"
class="oc-icon-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"
title="图片管理"
aria-label="图片管理"
>
+26 -239
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, Minus, Plus, Refresh, Map, Satellite, X } from '@vicons/tabler'
import { Adjustments, Calendar, CloudUpload, Refresh, Map, Satellite, X } from '@vicons/tabler'
interface CloudMarkerData {
id: string
@@ -40,15 +40,10 @@ 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
@@ -58,18 +53,11 @@ 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',
uncommon: '#f59e0b',
rare: '#f43f5e',
uncommon: '#60a5fa',
rare: '#c084fc',
}
const rarityMeta = {
@@ -218,15 +206,15 @@ function hoverCardContent(cloud: CloudMarkerData): HTMLElement {
const name = document.createElement('span')
name.textContent = cloud.cloudTypeName
name.style.cssText = 'font-size:13px;font-weight:600;color:#1e293b'
name.style.cssText = 'font-size:13px;font-weight:600;color:#1f2937'
const rarity = document.createElement('span')
rarity.textContent = label
rarity.style.cssText = `font-size:10px;padding:1px 5px;border-radius:3px;color:${border};background:${border}22`
const meta = document.createElement('div')
meta.textContent = `${cloud.username} · ${timeAgo(cloud.capturedAt)}`
meta.style.cssText = 'font-size:11px;color:#94a3b8;margin-top:3px'
meta.textContent = `📷 ${cloud.username} · ${timeAgo(cloud.capturedAt)}`
meta.style.cssText = 'font-size:11px;color:#9ca3af;margin-top:3px'
title.append(name, rarity)
body.append(title, meta)
@@ -255,165 +243,6 @@ 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,
@@ -594,16 +423,14 @@ 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: currentZoom.value,
zooms: [MIN_MAP_ZOOM, MAX_MAP_ZOOM],
zoom: 5,
zooms: [4, 18],
center: [104.07, 30.67],
mapStyle: 'amap://styles/normal',
features: ['bg', 'road', 'building', 'point'],
@@ -613,13 +440,12 @@ 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) {
@@ -628,13 +454,10 @@ 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
@@ -645,45 +468,11 @@ 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="oc-map-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'"
:title="authStore.isLoggedIn ? '快速上传图片' : '请先登录后上传图片'"
aria-label="快速上传图片"
:disabled="authStore.loading || !authStore.isLoggedIn"
@@ -693,14 +482,14 @@ onUnmounted(() => {
<CloudUpload />
</NIcon>
</button>
<button type="button" @click="refresh" class="oc-map-button" title="刷新" aria-label="刷新地图">
<button @click="refresh" class="oc-map-button" title="刷新">
<span class="text-lg">
<NIcon>
<Refresh/>
</NIcon>
</span>
</button>
<button type="button" @click="toggleSat" class="oc-map-button" :title="satelliteOn ? '切换普通视图' : '切换卫星视图'" :aria-label="satelliteOn ? '切换普通视图' : '切换卫星视图'">
<button @click="toggleSat" class="oc-map-button" :title="satelliteOn ? '切换普通视图' : '切换卫星视图'">
<NIcon size="20" style="display: inline-flex; vertical-align: middle;">
<Map v-if="satelliteOn" />
<Satellite v-else />
@@ -727,7 +516,7 @@ onUnmounted(() => {
<div class="relative w-[min(42vw,420px)] min-w-[220px]">
<div
v-if="sliderDragging"
class="oc-panel-card absolute bottom-8 border border-slate-200 bg-white/95 px-3 py-1.5 text-xs font-medium text-slate-800 backdrop-blur"
class="absolute bottom-8 border border-slate-200 bg-white/95 px-3 py-1.5 text-xs font-medium text-slate-800 shadow-md backdrop-blur"
:style="{ left: `${selectedMinutePercent}%`, transform: 'translateX(-50%)' }"
>
{{ selectedTimeLabel }}
@@ -772,29 +561,27 @@ onUnmounted(() => {
<div
v-if="archivePanelOpen"
class="oc-panel-card absolute bottom-16 right-0 w-72 border border-slate-200 bg-white p-4"
class="absolute bottom-16 right-0 w-72 border border-slate-200 bg-white p-4 shadow-[8px_8px_0_0_rgba(15,23,42,0.08)]"
>
<div class="flex items-center justify-between gap-2">
<p class="text-sm font-semibold text-slate-900">历史位置</p>
<button type="button" class="oc-text-button text-xs" @click="returnRealtime">
<button type="button" class="text-xs font-medium text-sky-700 hover:text-sky-900" @click="returnRealtime">
返回实时
</button>
</div>
<div class="mt-4 grid grid-cols-2 gap-2">
<button
type="button"
class="oc-choice-button"
:class="archiveKind === 'day' ? 'oc-choice-button--active' : 'oc-choice-button--inactive'"
:aria-pressed="archiveKind === 'day'"
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'"
@click="archiveKind = 'day'"
>
某天
</button>
<button
type="button"
class="oc-choice-button"
:class="archiveKind === 'month' ? 'oc-choice-button--active' : 'oc-choice-button--inactive'"
:aria-pressed="archiveKind === 'month'"
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'"
@click="archiveKind = 'month'"
>
某月
@@ -816,7 +603,7 @@ onUnmounted(() => {
<button
type="button"
class="oc-panel-button oc-panel-button--sky mt-3 w-full"
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"
@click="applyArchiveFilter"
>
查看拍摄位置
@@ -841,19 +628,19 @@ onUnmounted(() => {
@close="previewCloud = null"
>
<div class="grid gap-4 sm:grid-cols-2">
<div class="oc-inset-panel">
<div class="rounded-2xl bg-slate-50 p-4">
<p class="oc-section-label">上传时间</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(previewCloud.createdAt) }}</p>
</div>
<div class="oc-inset-panel">
<div class="rounded-2xl bg-slate-50 p-4">
<p class="oc-section-label">拍摄时间</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(previewCloud.capturedAt) }}</p>
</div>
<div class="oc-inset-panel">
<div class="rounded-2xl bg-slate-50 p-4">
<p class="oc-section-label">位置</p>
<p class="mt-2 text-sm font-medium text-slate-900">{{ previewCloud.locationName || '未填写位置名称' }}</p>
</div>
<div class="oc-inset-panel">
<div class="rounded-2xl bg-slate-50 p-4">
<p class="oc-section-label">模糊化经纬度</p>
<p class="mt-2 text-sm font-medium text-slate-900">
纬度 {{ formatCoordinate(previewCloud.latitude) }} / 经度 {{ formatCoordinate(previewCloud.longitude) }}
@@ -861,7 +648,7 @@ onUnmounted(() => {
</div>
</div>
<div class="mt-5 border border-slate-200 bg-white p-5">
<div class="mt-5 rounded-[28px] border border-slate-200 bg-white p-5">
<p class="oc-section-label">图片说明</p>
<p class="mt-3 text-sm leading-7 text-slate-700">
{{ previewCloud.description || '上传者没有留下额外说明。' }}
+11 -30
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { NAlert, NButton, NCard, NForm, NInput, useMessage } from 'naive-ui'
import { NAlert, NButton, NCard, NInput, useMessage } from 'naive-ui'
import { useAuthStore } from '@/stores/auth'
const authStore = useAuthStore()
@@ -79,8 +79,8 @@ async function savePassword() {
</script>
<template>
<div class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)] py-10">
<div class="oc-container oc-container--content">
<div class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)] px-4 py-10">
<div class="mx-auto max-w-4xl">
<RouterLink to="/profile">
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">← 返回个人主页</NButton>
</RouterLink>
@@ -111,7 +111,7 @@ async function savePassword() {
{{ success }}
</NAlert>
<NCard class="oc-panel-card border border-slate-200 bg-white" :bordered="false">
<NCard class="oc-panel-card border border-slate-200 bg-white shadow-sm" :bordered="false">
<div class="grid gap-5 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
<div>
<h2 class="text-2xl font-bold text-slate-900">公开昵称</h2>
@@ -140,7 +140,7 @@ async function savePassword() {
</div>
</NCard>
<NCard class="oc-panel-card border border-slate-200 bg-white" :bordered="false">
<NCard class="oc-panel-card border border-slate-200 bg-white shadow-sm" :bordered="false">
<div class="grid gap-5 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
<div>
<h2 class="text-2xl font-bold text-slate-900">登录密码</h2>
@@ -148,52 +148,33 @@ async function savePassword() {
修改后下次登录需要使用新密码。建议使用至少 8 位且不易猜测的密码。
</p>
</div>
<NForm class="grid gap-3" @submit.prevent="savePassword">
<input
:value="authStore.user?.email || ''"
type="text"
name="username"
autocomplete="username"
class="sr-only"
tabindex="-1"
aria-label="账户邮箱"
readonly
/>
<div class="grid gap-3">
<NInput
v-model:value="newPassword"
type="password"
show-password-on="click"
:input-props="{
autocomplete: 'new-password',
name: 'new-password',
required: true,
}"
autocomplete="new-password"
placeholder="新密码"
/>
<NInput
v-model:value="confirmNewPassword"
type="password"
show-password-on="click"
:input-props="{
autocomplete: 'new-password',
name: 'confirm-new-password',
required: true,
}"
autocomplete="new-password"
placeholder="确认新密码"
/>
<div class="flex justify-end">
<NButton
secondary
strong
type="default"
attr-type="submit"
class="oc-panel-button oc-panel-button--teal"
class="oc-panel-button oc-panel-button--neutral"
:loading="passwordSaving"
@click="savePassword"
>
保存密码
</NButton>
</div>
</NForm>
</div>
</div>
</NCard>
</div>
+156 -105
View File
@@ -2,6 +2,7 @@
import { computed, nextTick, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { NAlert, NButton, NCard, NDropdown, NEmpty, NIcon, NSkeleton, useMessage } from 'naive-ui'
import EncyclopediaProgressCard from '@/components/cloud/EncyclopediaProgressCard.vue'
import { Settings } from '@vicons/tabler'
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
@@ -9,6 +10,7 @@ import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
import ContributionHeatmap from '@/components/profile/ContributionHeatmap.vue'
import { useAuthStore } from '@/stores/auth'
import { useCloudsStore } from '@/stores/clouds'
import { useEncyclopediaStore } from '@/stores/encyclopedia'
import { useProfileStore, type ProfileCloudItem } from '@/stores/profile'
import type { CloudType, Profile } from '@/types/database'
@@ -21,6 +23,7 @@ interface TimelineGroup {
const route = useRoute()
const authStore = useAuthStore()
const cloudsStore = useCloudsStore()
const encyclopediaStore = useEncyclopediaStore()
const profileStore = useProfileStore()
const message = useMessage()
@@ -62,7 +65,6 @@ const errorMsg = computed(() => {
})
const profileData = computed<Profile | null>(() => profileStore.getProfile(viewedUserId.value))
const clouds = computed<ProfileCloudItem[]>(() => profileStore.getClouds(viewedUserId.value, isOwnProfile.value))
const receivedLikes = computed(() => profileStore.getLikeStats(viewedUserId.value)?.total_likes || 0)
const pageTitle = computed(() => {
return isOwnProfile.value ? '我的天空日志' : `${profileData.value?.username || '这位用户'}的天空日志`
@@ -81,6 +83,10 @@ const approvedShots = computed(() => {
return clouds.value.filter(item => item.status === 'approved').length
})
const pendingShots = computed(() => {
return clouds.value.filter(item => item.status === 'pending').length
})
const selectedCloudCount = computed(() => selectedCloudIds.value.size)
const cloudActionOptions = computed(() => {
@@ -178,6 +184,44 @@ const activeUploadDays = computed(() => {
return contributionCells.value.filter(cell => cell.count > 0).length
})
const currentUploadStreak = computed(() => {
let streak = 0
const cursor = startOfDay(new Date())
while (true) {
const key = toLocalDayKey(cursor)
if ((uploadDayCounts.value.get(key) || 0) <= 0) break
streak += 1
cursor.setDate(cursor.getDate() - 1)
}
return streak
})
const longestUploadStreak = computed(() => {
let best = 0
let current = 0
let previousDate: Date | null = null
for (const cell of contributionCells.value) {
if (cell.count <= 0) continue
const currentDate = startOfDay(new Date(cell.date))
if (!previousDate) {
current = 1
} else {
const diffDays = Math.round((currentDate.getTime() - previousDate.getTime()) / 86400000)
current = diffDays === 1 ? current + 1 : 1
}
if (current > best) best = current
previousDate = currentDate
}
return best
})
function toDayKey(iso: string) {
const date = new Date(iso)
return toLocalDayKey(date)
@@ -189,6 +233,10 @@ function toLocalDayKey(date: Date) {
return `${date.getFullYear()}-${month}-${day}`
}
function startOfDay(date: Date) {
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
}
function formatDate(iso: string | null) {
if (!iso) return '未知时间'
return new Date(iso).toLocaleDateString('zh-CN', {
@@ -400,6 +448,8 @@ async function loadProfilePage(force = false) {
if (isOwnProfile.value) {
await cloudsStore.fetchCloudTypes()
await encyclopediaStore.fetchCloudTypes(force)
await encyclopediaStore.fetchMyCollection(force)
}
}
@@ -425,98 +475,77 @@ watch(selectedUploadDate, async newValue => {
<div class="relative">
<div class="oc-page-hero">
<div class="oc-container oc-container--content oc-page-hero__inner">
<div
v-if="loading"
class="oc-page-hero__split"
>
<NCard class="oc-panel-card h-full border border-slate-200" :bordered="false">
<div v-if="loading" class="grid gap-6 lg:grid-cols-[1.25fr_0.95fr]">
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
<NSkeleton class="h-6 w-1/3" />
<NSkeleton class="mt-4 h-10 w-2/3" />
<NSkeleton class="mt-5 h-4 w-full" :repeat="2" />
</NCard>
<div v-if="isOwnProfile" class="grid grid-cols-2 gap-3 min-[900px]:h-full min-[900px]:grid-rows-2">
<NCard v-for="n in 4" :key="n" class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur" :bordered="false">
<NSkeleton class="h-3 w-2/3" />
<NSkeleton class="mt-3 h-7 w-1/2" />
</NCard>
</div>
<NCard v-else class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur" :bordered="false">
<NCard class="oc-panel-card-soft border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
<NSkeleton class="h-5 w-1/3" />
<NSkeleton class="mt-4 h-8 w-1/2" />
<NSkeleton class="mt-5 h-3 w-full" />
</NCard>
</div>
<div
v-else
class="oc-page-hero__split oc-page-hero__split--stretch"
>
<NCard class="oc-panel-card h-full border border-slate-200 bg-white/90 backdrop-blur" :bordered="false">
<div class="flex gap-5">
<div
class="profile-avatar 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"
>
<img
v-if="profileData?.avatar_url"
:src="profileData.avatar_url"
:alt="profileData.username"
class="h-full w-full object-cover"
/>
<template v-else>
{{ getProfileInitial(profileData) }}
</template>
</div>
<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)]"
>
<img
v-if="profileData?.avatar_url"
:src="profileData.avatar_url"
:alt="profileData.username"
class="h-full w-full object-cover"
/>
<template v-else>
{{ getProfileInitial(profileData) }}
</template>
</div>
<div class="min-w-0">
<p class="oc-page-eyebrow">Sky Journal</p>
<h1 class="oc-page-title">
{{ pageTitle }}
</h1>
<div class="mt-3 flex flex-wrap items-center gap-3">
<p class="text-lg font-medium text-slate-700">
@{{ profileData?.username || '未知用户' }}
</p>
<RouterLink v-if="isOwnProfile" to="/profile/settings">
<button
type="button"
class="oc-icon-button oc-icon-button--small"
title="个人资料设置"
aria-label="个人资料设置"
>
<NIcon size="17">
<Settings />
</NIcon>
</button>
</RouterLink>
</div>
<div class="mt-5 flex flex-wrap items-center gap-3 text-sm text-slate-500">
<span>注册于 {{ formatDate(profileData?.created_at || null) }}</span>
</div>
<div class="min-w-0">
<p class="oc-page-eyebrow">Sky Journal</p>
<h1 class="oc-page-title">
{{ pageTitle }}
</h1>
<div class="mt-3 flex flex-wrap items-center gap-3">
<p class="text-lg font-medium text-slate-700">
@{{ profileData?.username || '未知用户' }}
</p>
<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"
title="个人资料设置"
aria-label="个人资料设置"
>
<NIcon size="17">
<Settings />
</NIcon>
</button>
</RouterLink>
</div>
<!-- <p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
{{ profileSubtitle }}
</p> -->
<div class="mt-5 flex flex-wrap items-center gap-3 text-sm text-slate-500">
<span>注册于 {{ formatDate(profileData?.created_at || null) }}</span>
<!-- <NTag v-if="isOwnProfile" type="primary" :bordered="false">你的主页</NTag>
<NTag v-else :bordered="false">公开展示</NTag> -->
</div>
</div>
</NCard>
<div v-if="isOwnProfile" class="grid grid-cols-2 gap-3 min-[900px]:h-full min-[900px]:grid-rows-2">
<NCard class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur" :bordered="false">
<p class="oc-stat-label">总拍摄数</p>
<p class="oc-stat-value">{{ totalShots }}</p>
</NCard>
<NCard class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur" :bordered="false">
<p class="oc-stat-label">最常拍云型</p>
<p class="mt-2 truncate text-xl font-bold text-slate-900">{{ mostCommonCloudType.name }}</p>
</NCard>
<NCard class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur" :bordered="false">
<p class="oc-stat-label">拍摄天数</p>
<p class="oc-stat-value">{{ shootingDays }}</p>
</NCard>
<NCard class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur" :bordered="false">
<p class="oc-stat-label">被点赞数</p>
<p class="oc-stat-value">{{ receivedLikes }}</p>
</NCard>
</div>
<NCard v-if="!isOwnProfile" class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur" :bordered="false">
<EncyclopediaProgressCard
v-if="isOwnProfile"
:unlocked-count="encyclopediaStore.unlockedCount"
:total-count="encyclopediaStore.cloudTypes.length || 10"
:percent="encyclopediaStore.unlockPercent"
:is-logged-in="true"
/>
<NCard v-else class="oc-panel-card-soft border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
<p class="text-sm text-slate-500">公开贡献</p>
<p class="mt-3 text-3xl font-bold text-slate-900">{{ approvedShots }}</p>
<p class="mt-2 text-sm text-slate-500">当前公开可见的云图数量</p>
@@ -548,12 +577,50 @@ watch(selectedUploadDate, async newValue => {
</NAlert>
<template v-else>
<section ref="timelineSectionRef">
<div v-if="loading" class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<NCard v-for="n in 4" :key="n">
<NSkeleton class="h-4 w-1/3" />
<NSkeleton class="mt-4 h-8 w-2/3" />
</NCard>
</div>
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
<p class="text-sm text-slate-500">总拍摄数</p>
<p class="mt-2 text-3xl font-bold text-slate-900">{{ totalShots }}</p>
</NCard>
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
<p class="text-sm text-slate-500">最常拍云型</p>
<p class="oc-stat-value">{{ mostCommonCloudType.name }}</p>
<p class="mt-2 text-sm text-slate-500" v-if="mostCommonCloudType.count">
共 {{ mostCommonCloudType.count }} 张
</p>
</NCard>
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
<p class="text-sm text-slate-500">拍摄天数</p>
<p class="mt-2 text-3xl font-bold text-slate-900">{{ shootingDays }}</p>
</NCard>
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
<p class="text-sm text-slate-500">{{ isOwnProfile ? '已通过 / 待审核' : '公开通过数' }}</p>
<p class="oc-stat-value">
<template v-if="isOwnProfile">{{ approvedShots }} / {{ pendingShots }}</template>
<template v-else>{{ approvedShots }}</template>
</p>
</NCard>
</div>
<section ref="timelineSectionRef" class="mt-8">
<ContributionHeatmap
:cells="contributionCells"
:total-count="totalShots"
:active-days="activeUploadDays"
:current-streak="currentUploadStreak"
:best-streak="longestUploadStreak"
:active-date="selectedUploadDate"
:subtitle="isOwnProfile ? '按上传日期统计' : '按公开上传日期统计'"
:title="isOwnProfile ? '上传活跃度' : '公开活跃度'"
@select-date="toggleSelectedUploadDate"
/>
@@ -582,31 +649,25 @@ 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 type="default" class="oc-panel-button oc-panel-button--neutral" @click="loadProfilePage(true)">
<NButton secondary strong 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"
>
清除筛选
</NButton>
<RouterLink
v-if="isOwnProfile"
to="/upload"
class="oc-panel-button oc-panel-button--sky"
>
继续上传
<RouterLink v-if="isOwnProfile" to="/upload">
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky">继续上传</NButton>
</RouterLink>
</div>
</div>
@@ -623,7 +684,7 @@ watch(selectedUploadDate, async newValue => {
<div v-for="n in 2" :key="n">
<NSkeleton class="h-6 w-40" />
<div class="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
<NCard v-for="m in 3" :key="m" class="oc-panel-card border border-slate-200" :bordered="false">
<NCard v-for="m in 3" :key="m" class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
<NSkeleton class="h-52 w-full" />
<NSkeleton class="mt-4 h-5 w-2/3" />
<NSkeleton class="mt-3 h-4 w-1/2" />
@@ -645,8 +706,8 @@ watch(selectedUploadDate, async newValue => {
:key="item.id"
type="button"
@click="selectedCloud = item"
class="oc-interactive-card group overflow-hidden border bg-white text-left transition-all hover:-translate-y-1"
:class="selectedUploadDate && toDayKey(item.created_at) === selectedUploadDate ? 'oc-interactive-card--selected' : 'border-slate-200'"
class="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">
<img
@@ -709,14 +770,10 @@ watch(selectedUploadDate, async newValue => {
<div v-else class="oc-empty-card border border-dashed border-slate-300 bg-white p-10">
<NEmpty :description="selectedUploadDate ? '这一天没有上传记录' : (isOwnProfile ? '还没有上传任何云图' : '这位用户还没有公开云图')">
<template #extra>
<RouterLink
v-if="isOwnProfile && !selectedUploadDate"
to="/upload"
class="oc-panel-button oc-panel-button--sky"
>
去上传第一张
<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 type="default" class="oc-panel-button oc-panel-button--neutral" @click="clearSelectedUploadDate">返回全部记录</NButton>
<NButton v-else-if="selectedUploadDate" secondary strong class="oc-panel-button oc-panel-button--neutral" @click="clearSelectedUploadDate">返回全部记录</NButton>
</template>
</NEmpty>
</div>
@@ -788,7 +845,7 @@ watch(selectedUploadDate, async newValue => {
>
<button
type="button"
class="oc-icon-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"
title="图片管理"
aria-label="图片管理"
>
@@ -815,9 +872,3 @@ watch(selectedUploadDate, async newValue => {
/>
</div>
</template>
<style scoped>
.profile-avatar {
box-shadow: var(--oc-shadow-sm);
}
</style>
+2 -3
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue'
import { NButton, NIcon } from 'naive-ui'
import { Lock } from '@vicons/tabler'
import { NButton } from 'naive-ui'
import { RouterLink, useRoute } from 'vue-router'
const route = useRoute()
@@ -31,7 +30,7 @@ const description = computed(() => {
<template>
<div class="oc-system-page">
<div class="oc-system-card">
<NIcon size="72" class="text-sky-600"><Lock /></NIcon>
<div class="text-7xl leading-none">🔐</div>
<p class="mt-6 text-sm font-medium uppercase tracking-[0.24em] text-sky-600">401 Login Required</p>
<h1 class="oc-page-title">{{ title }}</h1>
<p class="oc-page-description mx-auto max-w-xl">
+2 -3
View File
@@ -1,13 +1,12 @@
<script setup lang="ts">
import { NButton, NIcon } from 'naive-ui'
import { HandStop } from '@vicons/tabler'
import { NButton } from 'naive-ui'
import { RouterLink } from 'vue-router'
</script>
<template>
<div class="oc-system-page">
<div class="oc-system-card">
<NIcon size="72" class="text-rose-600"><HandStop /></NIcon>
<div class="text-7xl leading-none">🖐️</div>
<p class="mt-6 text-sm font-medium uppercase tracking-[0.24em] text-rose-600">403 Forbidden</p>
<h1 class="oc-page-title">这里不让进</h1>
<p class="oc-page-description mx-auto max-w-xl">
+2 -3
View File
@@ -1,13 +1,12 @@
<script setup lang="ts">
import { NButton, NIcon } from 'naive-ui'
import { Help } from '@vicons/tabler'
import { NButton } from 'naive-ui'
import { RouterLink } from 'vue-router'
</script>
<template>
<div class="oc-system-page">
<div class="oc-system-card">
<NIcon size="72" class="text-amber-600"><Help /></NIcon>
<div class="text-7xl leading-none">🤔</div>
<p class="mt-6 text-sm font-medium uppercase tracking-[0.24em] text-amber-600">404 Not Found</p>
<h1 class="oc-page-title">我也没想明白</h1>
<p class="oc-page-description mx-auto max-w-xl">
+20 -25
View File
@@ -1,7 +1,6 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
import { NAlert, NButton, NCard, NIcon, NProgress, NResult, NTag } from 'naive-ui'
import { CloudUpload, Map as MapIcon, X } from '@vicons/tabler'
import { NAlert, NButton, NCard, NProgress, NResult, NTag } from 'naive-ui'
import { useRouter } from 'vue-router'
import { useCloudsStore } from '@/stores/clouds'
import { useAuthStore } from '@/stores/auth'
@@ -225,9 +224,9 @@ onUnmounted(() => {
<NCard
v-for="badge in unlockedBadges"
:key="badge.cloudTypeId"
class="oc-panel-card border border-amber-200 bg-[linear-gradient(180deg,#fffbeb_0%,#ffffff_100%)]"
class="border border-amber-200 bg-[linear-gradient(180deg,#fffbeb_0%,#ffffff_100%)] shadow-sm"
>
<div class="flex h-16 w-16 items-center justify-center border border-amber-300 bg-amber-400 text-3xl text-white">
<div class="flex h-16 w-16 items-center justify-center border border-amber-300 bg-amber-400 text-3xl text-white shadow-sm">
{{ badge.cloudName.slice(0, 1) }}
</div>
<div class="mt-5">
@@ -257,7 +256,7 @@ onUnmounted(() => {
</div>
<template v-else>
<div v-if="uploading" class="oc-panel-card mb-6 border border-slate-200 bg-white p-5">
<div v-if="uploading" class="mb-6 border border-slate-200 bg-white p-5 shadow-sm">
<div class="flex items-center justify-between mb-2">
<span class="text-sm font-medium text-slate-700">正在上传 {{ currentItemIndex }} / {{ totalItems }}...</span>
<span class="text-sm font-medium text-sky-600">{{ overallProgress }}%</span>
@@ -266,8 +265,8 @@ onUnmounted(() => {
type="line"
:show-indicator="false"
:percentage="overallProgress"
color="var(--oc-color-info)"
rail-color="var(--oc-color-border)"
color="#0ea5e9"
rail-color="#dbe4ee"
:height="10"
/>
</div>
@@ -278,19 +277,19 @@ onUnmounted(() => {
@dragleave.prevent="dragOver = false"
@drop.prevent="handleDrop"
@click="fileInput?.click()"
class="oc-panel-card flex cursor-pointer flex-col items-center justify-center border-2 border-dashed bg-white py-20 transition-colors"
class="flex flex-col items-center justify-center border-2 border-dashed py-20 cursor-pointer transition-colors bg-white shadow-sm"
:class="dragOver ? 'border-sky-400 bg-sky-50' : 'border-slate-300 hover:border-sky-400 hover:bg-slate-50'"
>
<NIcon size="48" class="mb-4 text-sky-600"><CloudUpload /></NIcon>
<span class="text-5xl mb-4">☁️</span>
<p class="mb-1 text-lg font-medium text-slate-700">点击或拖拽图片到此处</p>
<p class="text-sm text-slate-400">支持 JPG、PNG 格式,可一次选择多张</p>
</div>
<template v-else>
<div class="flex min-w-0 flex-col gap-6 lg:flex-row">
<div class="w-full min-w-0 lg:w-[480px] lg:flex-shrink-0">
<div class="flex gap-6">
<div class="flex-shrink-0 w-[480px]">
<div v-if="activeItem" class="mb-3 overflow-hidden border border-slate-900 bg-slate-900">
<img :src="activeItem.preview" alt="预览" class="h-64 w-full object-contain sm:h-[360px]" />
<img :src="activeItem.preview" alt="预览" class="w-full h-[360px] object-contain" />
</div>
<div class="flex gap-2 overflow-x-auto pb-2">
<div
@@ -303,14 +302,10 @@ 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="oc-icon-button oc-icon-button--danger oc-icon-button--tiny absolute -right-1 -top-1"
title="移除图片"
aria-label="移除图片"
>
<NIcon size="12"><X /></NIcon>
</button>
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"
>✕</button>
</div>
<div
@click="fileInput?.click()"
@@ -322,7 +317,7 @@ onUnmounted(() => {
</div>
<div class="flex-1 min-w-0" v-if="activeItem">
<NCard class="oc-panel-card border border-slate-200">
<NCard class="border border-slate-200 shadow-sm">
<div class="space-y-5">
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-slate-900">图片信息</h2>
@@ -379,7 +374,7 @@ onUnmounted(() => {
v-if="activeItem.latitude !== null || activeItem.longitude !== null"
type="button"
@click="clearCoordinates"
class="oc-text-button oc-text-button--muted text-xs"
class="text-xs font-medium text-slate-400 transition-colors hover:text-slate-600"
>
清空
</button>
@@ -413,9 +408,9 @@ onUnmounted(() => {
@click="openMapPicker"
title="地图选点"
aria-label="地图选点"
class="oc-icon-button oc-icon-button--sky"
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"
>
<NIcon size="20"><MapIcon /></NIcon>
<span class="text-lg leading-none">🗺️</span>
</button>
</div>
</div>
@@ -465,9 +460,9 @@ onUnmounted(() => {
</div>
</div>
<div class="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="mt-6 flex items-center justify-between">
<p class="text-sm text-slate-500">共 {{ items.length }} 张图片</p>
<div class="flex flex-wrap gap-3">
<div class="flex gap-3">
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="clearAll()" :disabled="uploading">清空</NButton>
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky" @click="handleSubmit" :disabled="uploading">
{{ uploading ? '上传中...' : '提交上传' }}