后端迁移到fastapi #3
@@ -1,64 +0,0 @@
|
||||
---
|
||||
name: supabase-postgres-best-practices
|
||||
description: Postgres performance optimization and best practices from Supabase. Use this skill when writing, reviewing, or optimizing Postgres queries, schema designs, or database configurations.
|
||||
license: MIT
|
||||
metadata:
|
||||
author: supabase
|
||||
version: "1.1.1"
|
||||
organization: Supabase
|
||||
date: January 2026
|
||||
abstract: Comprehensive Postgres performance optimization guide for developers using Supabase and Postgres. Contains performance rules across 8 categories, prioritized by impact from critical (query performance, connection management) to incremental (advanced features). Each rule includes detailed explanations, incorrect vs. correct SQL examples, query plan analysis, and specific performance metrics to guide automated optimization and code generation.
|
||||
---
|
||||
|
||||
# Supabase Postgres Best Practices
|
||||
|
||||
Comprehensive performance optimization guide for Postgres, maintained by Supabase. Contains rules across 8 categories, prioritized by impact to guide automated query optimization and schema design.
|
||||
|
||||
## When to Apply
|
||||
|
||||
Reference these guidelines when:
|
||||
- Writing SQL queries or designing schemas
|
||||
- Implementing indexes or query optimization
|
||||
- Reviewing database performance issues
|
||||
- Configuring connection pooling or scaling
|
||||
- Optimizing for Postgres-specific features
|
||||
- Working with Row-Level Security (RLS)
|
||||
|
||||
## Rule Categories by Priority
|
||||
|
||||
| Priority | Category | Impact | Prefix |
|
||||
|----------|----------|--------|--------|
|
||||
| 1 | Query Performance | CRITICAL | `query-` |
|
||||
| 2 | Connection Management | CRITICAL | `conn-` |
|
||||
| 3 | Security & RLS | CRITICAL | `security-` |
|
||||
| 4 | Schema Design | HIGH | `schema-` |
|
||||
| 5 | Concurrency & Locking | MEDIUM-HIGH | `lock-` |
|
||||
| 6 | Data Access Patterns | MEDIUM | `data-` |
|
||||
| 7 | Monitoring & Diagnostics | LOW-MEDIUM | `monitor-` |
|
||||
| 8 | Advanced Features | LOW | `advanced-` |
|
||||
|
||||
## How to Use
|
||||
|
||||
Read individual rule files for detailed explanations and SQL examples:
|
||||
|
||||
```
|
||||
references/query-missing-indexes.md
|
||||
references/query-partial-indexes.md
|
||||
references/_sections.md
|
||||
```
|
||||
|
||||
Each rule file contains:
|
||||
- Brief explanation of why it matters
|
||||
- Incorrect SQL example with explanation
|
||||
- Correct SQL example with explanation
|
||||
- Optional EXPLAIN output or metrics
|
||||
- Additional context and references
|
||||
- Supabase-specific notes (when applicable)
|
||||
|
||||
## References
|
||||
|
||||
- https://www.postgresql.org/docs/current/
|
||||
- https://supabase.com/docs
|
||||
- https://wiki.postgresql.org/wiki/Performance_Optimization
|
||||
- https://supabase.com/docs/guides/database/overview
|
||||
- https://supabase.com/docs/guides/auth/row-level-security
|
||||
@@ -1,170 +0,0 @@
|
||||
# Writing Guidelines for Postgres References
|
||||
|
||||
This document provides guidelines for creating effective Postgres best
|
||||
practice references that work well with AI agents and LLMs.
|
||||
|
||||
## Key Principles
|
||||
|
||||
### 1. Concrete Transformation Patterns
|
||||
|
||||
Show exact SQL rewrites. Avoid philosophical advice.
|
||||
|
||||
**Good:** "Use `WHERE id = ANY(ARRAY[...])` instead of
|
||||
`WHERE id IN (SELECT ...)`" **Bad:** "Design good schemas"
|
||||
|
||||
### 2. Error-First Structure
|
||||
|
||||
Always show the problematic pattern first, then the solution. This trains agents
|
||||
to recognize anti-patterns.
|
||||
|
||||
```markdown
|
||||
**Incorrect (sequential queries):** [bad example]
|
||||
|
||||
**Correct (batched query):** [good example]
|
||||
```
|
||||
|
||||
### 3. Quantified Impact
|
||||
|
||||
Include specific metrics. Helps agents prioritize fixes.
|
||||
|
||||
**Good:** "10x faster queries", "50% smaller index", "Eliminates N+1"
|
||||
**Bad:** "Faster", "Better", "More efficient"
|
||||
|
||||
### 4. Self-Contained Examples
|
||||
|
||||
Examples should be complete and runnable (or close to it). Include `CREATE TABLE`
|
||||
if context is needed.
|
||||
|
||||
```sql
|
||||
-- Include table definition when needed for clarity
|
||||
CREATE TABLE users (
|
||||
id bigint PRIMARY KEY,
|
||||
email text NOT NULL,
|
||||
deleted_at timestamptz
|
||||
);
|
||||
|
||||
-- Now show the index
|
||||
CREATE INDEX users_active_email_idx ON users(email) WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
### 5. Semantic Naming
|
||||
|
||||
Use meaningful table/column names. Names carry intent for LLMs.
|
||||
|
||||
**Good:** `users`, `email`, `created_at`, `is_active`
|
||||
**Bad:** `table1`, `col1`, `field`, `flag`
|
||||
|
||||
---
|
||||
|
||||
## Code Example Standards
|
||||
|
||||
### SQL Formatting
|
||||
|
||||
```sql
|
||||
-- Use lowercase keywords, clear formatting
|
||||
CREATE INDEX CONCURRENTLY users_email_idx
|
||||
ON users(email)
|
||||
WHERE deleted_at IS NULL;
|
||||
|
||||
-- Not cramped or ALL CAPS
|
||||
CREATE INDEX CONCURRENTLY USERS_EMAIL_IDX ON USERS(EMAIL) WHERE DELETED_AT IS NULL;
|
||||
```
|
||||
|
||||
### Comments
|
||||
|
||||
- Explain _why_, not _what_
|
||||
- Highlight performance implications
|
||||
- Point out common pitfalls
|
||||
|
||||
### Language Tags
|
||||
|
||||
- `sql` - Standard SQL queries
|
||||
- `plpgsql` - Stored procedures/functions
|
||||
- `typescript` - Application code (when needed)
|
||||
- `python` - Application code (when needed)
|
||||
|
||||
---
|
||||
|
||||
## When to Include Application Code
|
||||
|
||||
**Default: SQL Only**
|
||||
|
||||
Most references should focus on pure SQL patterns. This keeps examples portable.
|
||||
|
||||
**Include Application Code When:**
|
||||
|
||||
- Connection pooling configuration
|
||||
- Transaction management in application context
|
||||
- ORM anti-patterns (N+1 in Prisma/TypeORM)
|
||||
- Prepared statement usage
|
||||
|
||||
**Format for Mixed Examples:**
|
||||
|
||||
````markdown
|
||||
**Incorrect (N+1 in application):**
|
||||
|
||||
```typescript
|
||||
for (const user of users) {
|
||||
const posts = await db.query("SELECT * FROM posts WHERE user_id = $1", [
|
||||
user.id,
|
||||
]);
|
||||
}
|
||||
```
|
||||
````
|
||||
|
||||
**Correct (batch query):**
|
||||
|
||||
```typescript
|
||||
const posts = await db.query("SELECT * FROM posts WHERE user_id = ANY($1)", [
|
||||
userIds,
|
||||
]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Impact Level Guidelines
|
||||
|
||||
| Level | Improvement | Use When |
|
||||
|-------|-------------|----------|
|
||||
| **CRITICAL** | 10-100x | Missing indexes, connection exhaustion, sequential scans on large tables |
|
||||
| **HIGH** | 5-20x | Wrong index types, poor partitioning, missing covering indexes |
|
||||
| **MEDIUM-HIGH** | 2-5x | N+1 queries, inefficient pagination, RLS optimization |
|
||||
| **MEDIUM** | 1.5-3x | Redundant indexes, query plan instability |
|
||||
| **LOW-MEDIUM** | 1.2-2x | VACUUM tuning, configuration tweaks |
|
||||
| **LOW** | Incremental | Advanced patterns, edge cases |
|
||||
|
||||
---
|
||||
|
||||
## Reference Standards
|
||||
|
||||
**Primary Sources:**
|
||||
|
||||
- Official Postgres documentation
|
||||
- Supabase documentation
|
||||
- Postgres wiki
|
||||
- Established blogs (2ndQuadrant, Crunchy Data)
|
||||
|
||||
**Format:**
|
||||
|
||||
```markdown
|
||||
Reference:
|
||||
[Postgres Indexes](https://www.postgresql.org/docs/current/indexes.html)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Review Checklist
|
||||
|
||||
Before submitting a reference:
|
||||
|
||||
- [ ] Title is clear and action-oriented
|
||||
- [ ] Impact level matches the performance gain
|
||||
- [ ] impactDescription includes quantification
|
||||
- [ ] Explanation is concise (1-2 sentences)
|
||||
- [ ] Has at least 1 **Incorrect** SQL example
|
||||
- [ ] Has at least 1 **Correct** SQL example
|
||||
- [ ] SQL uses semantic naming
|
||||
- [ ] Comments explain _why_, not _what_
|
||||
- [ ] Trade-offs mentioned if applicable
|
||||
- [ ] Reference links included
|
||||
- [ ] `pnpm test` passes
|
||||
@@ -1,39 +0,0 @@
|
||||
# Section Definitions
|
||||
|
||||
This file defines the rule categories for Postgres best practices. Rules are automatically assigned to sections based on their filename prefix.
|
||||
|
||||
Take the examples below as pure demonstrative. Replace each section with the actual rule categories for Postgres best practices.
|
||||
|
||||
---
|
||||
|
||||
## 1. Query Performance (query)
|
||||
**Impact:** CRITICAL
|
||||
**Description:** Slow queries, missing indexes, inefficient query plans. The most common source of Postgres performance issues.
|
||||
|
||||
## 2. Connection Management (conn)
|
||||
**Impact:** CRITICAL
|
||||
**Description:** Connection pooling, limits, and serverless strategies. Critical for applications with high concurrency or serverless deployments.
|
||||
|
||||
## 3. Security & RLS (security)
|
||||
**Impact:** CRITICAL
|
||||
**Description:** Row-Level Security policies, privilege management, and authentication patterns.
|
||||
|
||||
## 4. Schema Design (schema)
|
||||
**Impact:** HIGH
|
||||
**Description:** Table design, index strategies, partitioning, and data type selection. Foundation for long-term performance.
|
||||
|
||||
## 5. Concurrency & Locking (lock)
|
||||
**Impact:** MEDIUM-HIGH
|
||||
**Description:** Transaction management, isolation levels, deadlock prevention, and lock contention patterns.
|
||||
|
||||
## 6. Data Access Patterns (data)
|
||||
**Impact:** MEDIUM
|
||||
**Description:** N+1 query elimination, batch operations, cursor-based pagination, and efficient data fetching.
|
||||
|
||||
## 7. Monitoring & Diagnostics (monitor)
|
||||
**Impact:** LOW-MEDIUM
|
||||
**Description:** Using pg_stat_statements, EXPLAIN ANALYZE, metrics collection, and performance diagnostics.
|
||||
|
||||
## 8. Advanced Features (advanced)
|
||||
**Impact:** LOW
|
||||
**Description:** Full-text search, JSONB optimization, PostGIS, extensions, and advanced Postgres features.
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
title: Clear, Action-Oriented Title (e.g., "Use Partial Indexes for Filtered Queries")
|
||||
impact: MEDIUM
|
||||
impactDescription: 5-20x query speedup for filtered queries
|
||||
tags: indexes, query-optimization, performance
|
||||
---
|
||||
|
||||
## [Rule Title]
|
||||
|
||||
[1-2 sentence explanation of the problem and why it matters. Focus on performance impact.]
|
||||
|
||||
**Incorrect (describe the problem):**
|
||||
|
||||
```sql
|
||||
-- Comment explaining what makes this slow/problematic
|
||||
CREATE INDEX users_email_idx ON users(email);
|
||||
|
||||
SELECT * FROM users WHERE email = 'user@example.com' AND deleted_at IS NULL;
|
||||
-- This scans deleted records unnecessarily
|
||||
```
|
||||
|
||||
**Correct (describe the solution):**
|
||||
|
||||
```sql
|
||||
-- Comment explaining why this is better
|
||||
CREATE INDEX users_active_email_idx ON users(email) WHERE deleted_at IS NULL;
|
||||
|
||||
SELECT * FROM users WHERE email = 'user@example.com' AND deleted_at IS NULL;
|
||||
-- Only indexes active users, 10x smaller index, faster queries
|
||||
```
|
||||
|
||||
[Optional: Additional context, edge cases, or trade-offs]
|
||||
|
||||
Reference: [Postgres Docs](https://www.postgresql.org/docs/current/)
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
---
|
||||
title: Use tsvector for Full-Text Search
|
||||
impact: MEDIUM
|
||||
impactDescription: 100x faster than LIKE, with ranking support
|
||||
tags: full-text-search, tsvector, gin, search
|
||||
---
|
||||
|
||||
## Use tsvector for Full-Text Search
|
||||
|
||||
LIKE with wildcards can't use indexes. Full-text search with tsvector is orders of magnitude faster.
|
||||
|
||||
**Incorrect (LIKE pattern matching):**
|
||||
|
||||
```sql
|
||||
-- Cannot use index, scans all rows
|
||||
select * from articles where content like '%postgresql%';
|
||||
|
||||
-- Case-insensitive makes it worse
|
||||
select * from articles where lower(content) like '%postgresql%';
|
||||
```
|
||||
|
||||
**Correct (full-text search with tsvector):**
|
||||
|
||||
```sql
|
||||
-- Add tsvector column and index
|
||||
alter table articles add column search_vector tsvector
|
||||
generated always as (to_tsvector('english', coalesce(title,'') || ' ' || coalesce(content,''))) stored;
|
||||
|
||||
create index articles_search_idx on articles using gin (search_vector);
|
||||
|
||||
-- Fast full-text search
|
||||
select * from articles
|
||||
where search_vector @@ to_tsquery('english', 'postgresql & performance');
|
||||
|
||||
-- With ranking
|
||||
select *, ts_rank(search_vector, query) as rank
|
||||
from articles, to_tsquery('english', 'postgresql') query
|
||||
where search_vector @@ query
|
||||
order by rank desc;
|
||||
```
|
||||
|
||||
Search multiple terms:
|
||||
|
||||
```sql
|
||||
-- AND: both terms required
|
||||
to_tsquery('postgresql & performance')
|
||||
|
||||
-- OR: either term
|
||||
to_tsquery('postgresql | mysql')
|
||||
|
||||
-- Prefix matching
|
||||
to_tsquery('post:*')
|
||||
```
|
||||
|
||||
Reference: [Full Text Search](https://supabase.com/docs/guides/database/full-text-search)
|
||||
@@ -1,49 +0,0 @@
|
||||
---
|
||||
title: Index JSONB Columns for Efficient Querying
|
||||
impact: MEDIUM
|
||||
impactDescription: 10-100x faster JSONB queries with proper indexing
|
||||
tags: jsonb, gin, indexes, json
|
||||
---
|
||||
|
||||
## Index JSONB Columns for Efficient Querying
|
||||
|
||||
JSONB queries without indexes scan the entire table. Use GIN indexes for containment queries.
|
||||
|
||||
**Incorrect (no index on JSONB):**
|
||||
|
||||
```sql
|
||||
create table products (
|
||||
id bigint primary key,
|
||||
attributes jsonb
|
||||
);
|
||||
|
||||
-- Full table scan for every query
|
||||
select * from products where attributes @> '{"color": "red"}';
|
||||
select * from products where attributes->>'brand' = 'Nike';
|
||||
```
|
||||
|
||||
**Correct (GIN index for JSONB):**
|
||||
|
||||
```sql
|
||||
-- GIN index for containment operators (@>, ?, ?&, ?|)
|
||||
create index products_attrs_gin on products using gin (attributes);
|
||||
|
||||
-- Now containment queries use the index
|
||||
select * from products where attributes @> '{"color": "red"}';
|
||||
|
||||
-- For specific key lookups, use expression index
|
||||
create index products_brand_idx on products ((attributes->>'brand'));
|
||||
select * from products where attributes->>'brand' = 'Nike';
|
||||
```
|
||||
|
||||
Choose the right operator class:
|
||||
|
||||
```sql
|
||||
-- jsonb_ops (default): supports all operators, larger index
|
||||
create index idx1 on products using gin (attributes);
|
||||
|
||||
-- jsonb_path_ops: only @> operator, but 2-3x smaller index
|
||||
create index idx2 on products using gin (attributes jsonb_path_ops);
|
||||
```
|
||||
|
||||
Reference: [JSONB Indexes](https://www.postgresql.org/docs/current/datatype-json.html#JSON-INDEXING)
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
title: Configure Idle Connection Timeouts
|
||||
impact: HIGH
|
||||
impactDescription: Reclaim 30-50% of connection slots from idle clients
|
||||
tags: connections, timeout, idle, resource-management
|
||||
---
|
||||
|
||||
## Configure Idle Connection Timeouts
|
||||
|
||||
Idle connections waste resources. Configure timeouts to automatically reclaim them.
|
||||
|
||||
**Incorrect (connections held indefinitely):**
|
||||
|
||||
```sql
|
||||
-- No timeout configured
|
||||
show idle_in_transaction_session_timeout; -- 0 (disabled)
|
||||
|
||||
-- Connections stay open forever, even when idle
|
||||
select pid, state, state_change, query
|
||||
from pg_stat_activity
|
||||
where state = 'idle in transaction';
|
||||
-- Shows transactions idle for hours, holding locks
|
||||
```
|
||||
|
||||
**Correct (automatic cleanup of idle connections):**
|
||||
|
||||
```sql
|
||||
-- Terminate connections idle in transaction after 30 seconds
|
||||
alter system set idle_in_transaction_session_timeout = '30s';
|
||||
|
||||
-- Terminate completely idle connections after 10 minutes
|
||||
alter system set idle_session_timeout = '10min';
|
||||
|
||||
-- Reload configuration
|
||||
select pg_reload_conf();
|
||||
```
|
||||
|
||||
For pooled connections, configure at the pooler level:
|
||||
|
||||
```ini
|
||||
# pgbouncer.ini
|
||||
server_idle_timeout = 60
|
||||
client_idle_timeout = 300
|
||||
```
|
||||
|
||||
Reference: [Connection Timeouts](https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-IDLE-IN-TRANSACTION-SESSION-TIMEOUT)
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
title: Set Appropriate Connection Limits
|
||||
impact: CRITICAL
|
||||
impactDescription: Prevent database crashes and memory exhaustion
|
||||
tags: connections, max-connections, limits, stability
|
||||
---
|
||||
|
||||
## Set Appropriate Connection Limits
|
||||
|
||||
Too many connections exhaust memory and degrade performance. Set limits based on available resources.
|
||||
|
||||
**Incorrect (unlimited or excessive connections):**
|
||||
|
||||
```sql
|
||||
-- Default max_connections = 100, but often increased blindly
|
||||
show max_connections; -- 500 (way too high for 4GB RAM)
|
||||
|
||||
-- Each connection uses 1-3MB RAM
|
||||
-- 500 connections * 2MB = 1GB just for connections!
|
||||
-- Out of memory errors under load
|
||||
```
|
||||
|
||||
**Correct (calculate based on resources):**
|
||||
|
||||
```sql
|
||||
-- Formula: max_connections = (RAM in MB / 5MB per connection) - reserved
|
||||
-- For 4GB RAM: (4096 / 5) - 10 = ~800 theoretical max
|
||||
-- But practically, 100-200 is better for query performance
|
||||
|
||||
-- Recommended settings for 4GB RAM
|
||||
alter system set max_connections = 100;
|
||||
|
||||
-- Also set work_mem appropriately
|
||||
-- work_mem * max_connections should not exceed 25% of RAM
|
||||
alter system set work_mem = '8MB'; -- 8MB * 100 = 800MB max
|
||||
```
|
||||
|
||||
Monitor connection usage:
|
||||
|
||||
```sql
|
||||
select count(*), state from pg_stat_activity group by state;
|
||||
```
|
||||
|
||||
Reference: [Database Connections](https://supabase.com/docs/guides/platform/performance#connection-management)
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
title: Use Connection Pooling for All Applications
|
||||
impact: CRITICAL
|
||||
impactDescription: Handle 10-100x more concurrent users
|
||||
tags: connection-pooling, pgbouncer, performance, scalability
|
||||
---
|
||||
|
||||
## Use Connection Pooling for All Applications
|
||||
|
||||
Postgres connections are expensive (1-3MB RAM each). Without pooling, applications exhaust connections under load.
|
||||
|
||||
**Incorrect (new connection per request):**
|
||||
|
||||
```sql
|
||||
-- Each request creates a new connection
|
||||
-- Application code: db.connect() per request
|
||||
-- Result: 500 concurrent users = 500 connections = crashed database
|
||||
|
||||
-- Check current connections
|
||||
select count(*) from pg_stat_activity; -- 487 connections!
|
||||
```
|
||||
|
||||
**Correct (connection pooling):**
|
||||
|
||||
```sql
|
||||
-- Use a pooler like PgBouncer between app and database
|
||||
-- Application connects to pooler, pooler reuses a small pool to Postgres
|
||||
|
||||
-- Configure pool_size based on: (CPU cores * 2) + spindle_count
|
||||
-- Example for 4 cores: pool_size = 10
|
||||
|
||||
-- Result: 500 concurrent users share 10 actual connections
|
||||
select count(*) from pg_stat_activity; -- 10 connections
|
||||
```
|
||||
|
||||
Pool modes:
|
||||
|
||||
- **Transaction mode**: connection returned after each transaction (best for most apps)
|
||||
- **Session mode**: connection held for entire session (needed for prepared statements, temp tables)
|
||||
|
||||
Reference: [Connection Pooling](https://supabase.com/docs/guides/database/connecting-to-postgres#connection-pooler)
|
||||
-46
@@ -1,46 +0,0 @@
|
||||
---
|
||||
title: Use Prepared Statements Correctly with Pooling
|
||||
impact: HIGH
|
||||
impactDescription: Avoid prepared statement conflicts in pooled environments
|
||||
tags: prepared-statements, connection-pooling, transaction-mode
|
||||
---
|
||||
|
||||
## Use Prepared Statements Correctly with Pooling
|
||||
|
||||
Prepared statements are tied to individual database connections. In transaction-mode pooling, connections are shared, causing conflicts.
|
||||
|
||||
**Incorrect (named prepared statements with transaction pooling):**
|
||||
|
||||
```sql
|
||||
-- Named prepared statement
|
||||
prepare get_user as select * from users where id = $1;
|
||||
|
||||
-- In transaction mode pooling, next request may get different connection
|
||||
execute get_user(123);
|
||||
-- ERROR: prepared statement "get_user" does not exist
|
||||
```
|
||||
|
||||
**Correct (use unnamed statements or session mode):**
|
||||
|
||||
```sql
|
||||
-- Option 1: Use unnamed prepared statements (most ORMs do this automatically)
|
||||
-- The query is prepared and executed in a single protocol message
|
||||
|
||||
-- Option 2: Deallocate after use in transaction mode
|
||||
prepare get_user as select * from users where id = $1;
|
||||
execute get_user(123);
|
||||
deallocate get_user;
|
||||
|
||||
-- Option 3: Use session mode pooling (port 5432 vs 6543)
|
||||
-- Connection is held for entire session, prepared statements persist
|
||||
```
|
||||
|
||||
Check your driver settings:
|
||||
|
||||
```sql
|
||||
-- Many drivers use prepared statements by default
|
||||
-- Node.js pg: { prepare: false } to disable
|
||||
-- JDBC: prepareThreshold=0 to disable
|
||||
```
|
||||
|
||||
Reference: [Prepared Statements with Pooling](https://supabase.com/docs/guides/database/connecting-to-postgres#connection-pool-modes)
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
title: Batch INSERT Statements for Bulk Data
|
||||
impact: MEDIUM
|
||||
impactDescription: 10-50x faster bulk inserts
|
||||
tags: batch, insert, bulk, performance, copy
|
||||
---
|
||||
|
||||
## Batch INSERT Statements for Bulk Data
|
||||
|
||||
Individual INSERT statements have high overhead. Batch multiple rows in single statements or use COPY.
|
||||
|
||||
**Incorrect (individual inserts):**
|
||||
|
||||
```sql
|
||||
-- Each insert is a separate transaction and round trip
|
||||
insert into events (user_id, action) values (1, 'click');
|
||||
insert into events (user_id, action) values (1, 'view');
|
||||
insert into events (user_id, action) values (2, 'click');
|
||||
-- ... 1000 more individual inserts
|
||||
|
||||
-- 1000 inserts = 1000 round trips = slow
|
||||
```
|
||||
|
||||
**Correct (batch insert):**
|
||||
|
||||
```sql
|
||||
-- Multiple rows in single statement
|
||||
insert into events (user_id, action) values
|
||||
(1, 'click'),
|
||||
(1, 'view'),
|
||||
(2, 'click'),
|
||||
-- ... up to ~1000 rows per batch
|
||||
(999, 'view');
|
||||
|
||||
-- One round trip for 1000 rows
|
||||
```
|
||||
|
||||
For large imports, use COPY:
|
||||
|
||||
```sql
|
||||
-- COPY is fastest for bulk loading
|
||||
copy events (user_id, action, created_at)
|
||||
from '/path/to/data.csv'
|
||||
with (format csv, header true);
|
||||
|
||||
-- Or from stdin in application
|
||||
copy events (user_id, action) from stdin with (format csv);
|
||||
1,click
|
||||
1,view
|
||||
2,click
|
||||
\.
|
||||
```
|
||||
|
||||
Reference: [COPY](https://www.postgresql.org/docs/current/sql-copy.html)
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
title: Eliminate N+1 Queries with Batch Loading
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: 10-100x fewer database round trips
|
||||
tags: n-plus-one, batch, performance, queries
|
||||
---
|
||||
|
||||
## Eliminate N+1 Queries with Batch Loading
|
||||
|
||||
N+1 queries execute one query per item in a loop. Batch them into a single query using arrays or JOINs.
|
||||
|
||||
**Incorrect (N+1 queries):**
|
||||
|
||||
```sql
|
||||
-- First query: get all users
|
||||
select id from users where active = true; -- Returns 100 IDs
|
||||
|
||||
-- Then N queries, one per user
|
||||
select * from orders where user_id = 1;
|
||||
select * from orders where user_id = 2;
|
||||
select * from orders where user_id = 3;
|
||||
-- ... 97 more queries!
|
||||
|
||||
-- Total: 101 round trips to database
|
||||
```
|
||||
|
||||
**Correct (single batch query):**
|
||||
|
||||
```sql
|
||||
-- Collect IDs and query once with ANY
|
||||
select * from orders where user_id = any(array[1, 2, 3, ...]);
|
||||
|
||||
-- Or use JOIN instead of loop
|
||||
select u.id, u.name, o.*
|
||||
from users u
|
||||
left join orders o on o.user_id = u.id
|
||||
where u.active = true;
|
||||
|
||||
-- Total: 1 round trip
|
||||
```
|
||||
|
||||
Application pattern:
|
||||
|
||||
```sql
|
||||
-- Instead of looping in application code:
|
||||
-- for user in users: db.query("SELECT * FROM orders WHERE user_id = $1", user.id)
|
||||
|
||||
-- Pass array parameter:
|
||||
select * from orders where user_id = any($1::bigint[]);
|
||||
-- Application passes: [1, 2, 3, 4, 5, ...]
|
||||
```
|
||||
|
||||
Reference: [N+1 Query Problem](https://supabase.com/docs/guides/database/query-optimization)
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
title: Use Cursor-Based Pagination Instead of OFFSET
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: Consistent O(1) performance regardless of page depth
|
||||
tags: pagination, cursor, keyset, offset, performance
|
||||
---
|
||||
|
||||
## Use Cursor-Based Pagination Instead of OFFSET
|
||||
|
||||
OFFSET-based pagination scans all skipped rows, getting slower on deeper pages. Cursor pagination is O(1).
|
||||
|
||||
**Incorrect (OFFSET pagination):**
|
||||
|
||||
```sql
|
||||
-- Page 1: scans 20 rows
|
||||
select * from products order by id limit 20 offset 0;
|
||||
|
||||
-- Page 100: scans 2000 rows to skip 1980
|
||||
select * from products order by id limit 20 offset 1980;
|
||||
|
||||
-- Page 10000: scans 200,000 rows!
|
||||
select * from products order by id limit 20 offset 199980;
|
||||
```
|
||||
|
||||
**Correct (cursor/keyset pagination):**
|
||||
|
||||
```sql
|
||||
-- Page 1: get first 20
|
||||
select * from products order by id limit 20;
|
||||
-- Application stores last_id = 20
|
||||
|
||||
-- Page 2: start after last ID
|
||||
select * from products where id > 20 order by id limit 20;
|
||||
-- Uses index, always fast regardless of page depth
|
||||
|
||||
-- Page 10000: same speed as page 1
|
||||
select * from products where id > 199980 order by id limit 20;
|
||||
```
|
||||
|
||||
For multi-column sorting:
|
||||
|
||||
```sql
|
||||
-- Cursor must include all sort columns
|
||||
select * from products
|
||||
where (created_at, id) > ('2024-01-15 10:00:00', 12345)
|
||||
order by created_at, id
|
||||
limit 20;
|
||||
```
|
||||
|
||||
Reference: [Pagination](https://supabase.com/docs/guides/database/pagination)
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
title: Use UPSERT for Insert-or-Update Operations
|
||||
impact: MEDIUM
|
||||
impactDescription: Atomic operation, eliminates race conditions
|
||||
tags: upsert, on-conflict, insert, update
|
||||
---
|
||||
|
||||
## Use UPSERT for Insert-or-Update Operations
|
||||
|
||||
Using separate SELECT-then-INSERT/UPDATE creates race conditions. Use INSERT ... ON CONFLICT for atomic upserts.
|
||||
|
||||
**Incorrect (check-then-insert race condition):**
|
||||
|
||||
```sql
|
||||
-- Race condition: two requests check simultaneously
|
||||
select * from settings where user_id = 123 and key = 'theme';
|
||||
-- Both find nothing
|
||||
|
||||
-- Both try to insert
|
||||
insert into settings (user_id, key, value) values (123, 'theme', 'dark');
|
||||
-- One succeeds, one fails with duplicate key error!
|
||||
```
|
||||
|
||||
**Correct (atomic UPSERT):**
|
||||
|
||||
```sql
|
||||
-- Single atomic operation
|
||||
insert into settings (user_id, key, value)
|
||||
values (123, 'theme', 'dark')
|
||||
on conflict (user_id, key)
|
||||
do update set value = excluded.value, updated_at = now();
|
||||
|
||||
-- Returns the inserted/updated row
|
||||
insert into settings (user_id, key, value)
|
||||
values (123, 'theme', 'dark')
|
||||
on conflict (user_id, key)
|
||||
do update set value = excluded.value
|
||||
returning *;
|
||||
```
|
||||
|
||||
Insert-or-ignore pattern:
|
||||
|
||||
```sql
|
||||
-- Insert only if not exists (no update)
|
||||
insert into page_views (page_id, user_id)
|
||||
values (1, 123)
|
||||
on conflict (page_id, user_id) do nothing;
|
||||
```
|
||||
|
||||
Reference: [INSERT ON CONFLICT](https://www.postgresql.org/docs/current/sql-insert.html#SQL-ON-CONFLICT)
|
||||
@@ -1,56 +0,0 @@
|
||||
---
|
||||
title: Use Advisory Locks for Application-Level Locking
|
||||
impact: MEDIUM
|
||||
impactDescription: Efficient coordination without row-level lock overhead
|
||||
tags: advisory-locks, coordination, application-locks
|
||||
---
|
||||
|
||||
## Use Advisory Locks for Application-Level Locking
|
||||
|
||||
Advisory locks provide application-level coordination without requiring database rows to lock.
|
||||
|
||||
**Incorrect (creating rows just for locking):**
|
||||
|
||||
```sql
|
||||
-- Creating dummy rows to lock on
|
||||
create table resource_locks (
|
||||
resource_name text primary key
|
||||
);
|
||||
|
||||
insert into resource_locks values ('report_generator');
|
||||
|
||||
-- Lock by selecting the row
|
||||
select * from resource_locks where resource_name = 'report_generator' for update;
|
||||
```
|
||||
|
||||
**Correct (advisory locks):**
|
||||
|
||||
```sql
|
||||
-- Session-level advisory lock (released on disconnect or unlock)
|
||||
select pg_advisory_lock(hashtext('report_generator'));
|
||||
-- ... do exclusive work ...
|
||||
select pg_advisory_unlock(hashtext('report_generator'));
|
||||
|
||||
-- Transaction-level lock (released on commit/rollback)
|
||||
begin;
|
||||
select pg_advisory_xact_lock(hashtext('daily_report'));
|
||||
-- ... do work ...
|
||||
commit; -- Lock automatically released
|
||||
```
|
||||
|
||||
Try-lock for non-blocking operations:
|
||||
|
||||
```sql
|
||||
-- Returns immediately with true/false instead of waiting
|
||||
select pg_try_advisory_lock(hashtext('resource_name'));
|
||||
|
||||
-- Use in application
|
||||
if (acquired) {
|
||||
-- Do work
|
||||
select pg_advisory_unlock(hashtext('resource_name'));
|
||||
} else {
|
||||
-- Skip or retry later
|
||||
}
|
||||
```
|
||||
|
||||
Reference: [Advisory Locks](https://www.postgresql.org/docs/current/explicit-locking.html#ADVISORY-LOCKS)
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
---
|
||||
title: Prevent Deadlocks with Consistent Lock Ordering
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: Eliminate deadlock errors, improve reliability
|
||||
tags: deadlocks, locking, transactions, ordering
|
||||
---
|
||||
|
||||
## Prevent Deadlocks with Consistent Lock Ordering
|
||||
|
||||
Deadlocks occur when transactions lock resources in different orders. Always
|
||||
acquire locks in a consistent order.
|
||||
|
||||
**Incorrect (inconsistent lock ordering):**
|
||||
|
||||
```sql
|
||||
-- Transaction A -- Transaction B
|
||||
begin; begin;
|
||||
update accounts update accounts
|
||||
set balance = balance - 100 set balance = balance - 50
|
||||
where id = 1; where id = 2; -- B locks row 2
|
||||
|
||||
update accounts update accounts
|
||||
set balance = balance + 100 set balance = balance + 50
|
||||
where id = 2; -- A waits for B where id = 1; -- B waits for A
|
||||
|
||||
-- DEADLOCK! Both waiting for each other
|
||||
```
|
||||
|
||||
**Correct (lock rows in consistent order first):**
|
||||
|
||||
```sql
|
||||
-- Explicitly acquire locks in ID order before updating
|
||||
begin;
|
||||
select * from accounts where id in (1, 2) order by id for update;
|
||||
|
||||
-- Now perform updates in any order - locks already held
|
||||
update accounts set balance = balance - 100 where id = 1;
|
||||
update accounts set balance = balance + 100 where id = 2;
|
||||
commit;
|
||||
```
|
||||
|
||||
Alternative: use a single statement to update atomically:
|
||||
|
||||
```sql
|
||||
-- Single statement acquires all locks atomically
|
||||
begin;
|
||||
update accounts
|
||||
set balance = balance + case id
|
||||
when 1 then -100
|
||||
when 2 then 100
|
||||
end
|
||||
where id in (1, 2);
|
||||
commit;
|
||||
```
|
||||
|
||||
Detect deadlocks in logs:
|
||||
|
||||
```sql
|
||||
-- Check for recent deadlocks
|
||||
select * from pg_stat_database where deadlocks > 0;
|
||||
|
||||
-- Enable deadlock logging
|
||||
set log_lock_waits = on;
|
||||
set deadlock_timeout = '1s';
|
||||
```
|
||||
|
||||
Reference:
|
||||
[Deadlocks](https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-DEADLOCKS)
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
title: Keep Transactions Short to Reduce Lock Contention
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: 3-5x throughput improvement, fewer deadlocks
|
||||
tags: transactions, locking, contention, performance
|
||||
---
|
||||
|
||||
## Keep Transactions Short to Reduce Lock Contention
|
||||
|
||||
Long-running transactions hold locks that block other queries. Keep transactions as short as possible.
|
||||
|
||||
**Incorrect (long transaction with external calls):**
|
||||
|
||||
```sql
|
||||
begin;
|
||||
select * from orders where id = 1 for update; -- Lock acquired
|
||||
|
||||
-- Application makes HTTP call to payment API (2-5 seconds)
|
||||
-- Other queries on this row are blocked!
|
||||
|
||||
update orders set status = 'paid' where id = 1;
|
||||
commit; -- Lock held for entire duration
|
||||
```
|
||||
|
||||
**Correct (minimal transaction scope):**
|
||||
|
||||
```sql
|
||||
-- Validate data and call APIs outside transaction
|
||||
-- Application: response = await paymentAPI.charge(...)
|
||||
|
||||
-- Only hold lock for the actual update
|
||||
begin;
|
||||
update orders
|
||||
set status = 'paid', payment_id = $1
|
||||
where id = $2 and status = 'pending'
|
||||
returning *;
|
||||
commit; -- Lock held for milliseconds
|
||||
```
|
||||
|
||||
Use `statement_timeout` to prevent runaway transactions:
|
||||
|
||||
```sql
|
||||
-- Abort queries running longer than 30 seconds
|
||||
set statement_timeout = '30s';
|
||||
|
||||
-- Or per-session
|
||||
set local statement_timeout = '5s';
|
||||
```
|
||||
|
||||
Reference: [Transaction Management](https://www.postgresql.org/docs/current/tutorial-transactions.html)
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
title: Use SKIP LOCKED for Non-Blocking Queue Processing
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: 10x throughput for worker queues
|
||||
tags: skip-locked, queue, workers, concurrency
|
||||
---
|
||||
|
||||
## Use SKIP LOCKED for Non-Blocking Queue Processing
|
||||
|
||||
When multiple workers process a queue, SKIP LOCKED allows workers to process different rows without waiting.
|
||||
|
||||
**Incorrect (workers block each other):**
|
||||
|
||||
```sql
|
||||
-- Worker 1 and Worker 2 both try to get next job
|
||||
begin;
|
||||
select * from jobs where status = 'pending' order by created_at limit 1 for update;
|
||||
-- Worker 2 waits for Worker 1's lock to release!
|
||||
```
|
||||
|
||||
**Correct (SKIP LOCKED for parallel processing):**
|
||||
|
||||
```sql
|
||||
-- Each worker skips locked rows and gets the next available
|
||||
begin;
|
||||
select * from jobs
|
||||
where status = 'pending'
|
||||
order by created_at
|
||||
limit 1
|
||||
for update skip locked;
|
||||
|
||||
-- Worker 1 gets job 1, Worker 2 gets job 2 (no waiting)
|
||||
|
||||
update jobs set status = 'processing' where id = $1;
|
||||
commit;
|
||||
```
|
||||
|
||||
Complete queue pattern:
|
||||
|
||||
```sql
|
||||
-- Atomic claim-and-update in one statement
|
||||
update jobs
|
||||
set status = 'processing', worker_id = $1, started_at = now()
|
||||
where id = (
|
||||
select id from jobs
|
||||
where status = 'pending'
|
||||
order by created_at
|
||||
limit 1
|
||||
for update skip locked
|
||||
)
|
||||
returning *;
|
||||
```
|
||||
|
||||
Reference: [SELECT FOR UPDATE SKIP LOCKED](https://www.postgresql.org/docs/current/sql-select.html#SQL-FOR-UPDATE-SHARE)
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
title: Use EXPLAIN ANALYZE to Diagnose Slow Queries
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: Identify exact bottlenecks in query execution
|
||||
tags: explain, analyze, diagnostics, query-plan
|
||||
---
|
||||
|
||||
## Use EXPLAIN ANALYZE to Diagnose Slow Queries
|
||||
|
||||
EXPLAIN ANALYZE executes the query and shows actual timings, revealing the true performance bottlenecks.
|
||||
|
||||
**Incorrect (guessing at performance issues):**
|
||||
|
||||
```sql
|
||||
-- Query is slow, but why?
|
||||
select * from orders where customer_id = 123 and status = 'pending';
|
||||
-- "It must be missing an index" - but which one?
|
||||
```
|
||||
|
||||
**Correct (use EXPLAIN ANALYZE):**
|
||||
|
||||
```sql
|
||||
explain (analyze, buffers, format text)
|
||||
select * from orders where customer_id = 123 and status = 'pending';
|
||||
|
||||
-- Output reveals the issue:
|
||||
-- Seq Scan on orders (cost=0.00..25000.00 rows=50 width=100) (actual time=0.015..450.123 rows=50 loops=1)
|
||||
-- Filter: ((customer_id = 123) AND (status = 'pending'::text))
|
||||
-- Rows Removed by Filter: 999950
|
||||
-- Buffers: shared hit=5000 read=15000
|
||||
-- Planning Time: 0.150 ms
|
||||
-- Execution Time: 450.500 ms
|
||||
```
|
||||
|
||||
Key things to look for:
|
||||
|
||||
```sql
|
||||
-- Seq Scan on large tables = missing index
|
||||
-- Rows Removed by Filter = poor selectivity or missing index
|
||||
-- Buffers: read >> hit = data not cached, needs more memory
|
||||
-- Nested Loop with high loops = consider different join strategy
|
||||
-- Sort Method: external merge = work_mem too low
|
||||
```
|
||||
|
||||
Reference: [EXPLAIN](https://supabase.com/docs/guides/database/inspect)
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
---
|
||||
title: Enable pg_stat_statements for Query Analysis
|
||||
impact: LOW-MEDIUM
|
||||
impactDescription: Identify top resource-consuming queries
|
||||
tags: pg-stat-statements, monitoring, statistics, performance
|
||||
---
|
||||
|
||||
## Enable pg_stat_statements for Query Analysis
|
||||
|
||||
pg_stat_statements tracks execution statistics for all queries, helping identify slow and frequent queries.
|
||||
|
||||
**Incorrect (no visibility into query patterns):**
|
||||
|
||||
```sql
|
||||
-- Database is slow, but which queries are the problem?
|
||||
-- No way to know without pg_stat_statements
|
||||
```
|
||||
|
||||
**Correct (enable and query pg_stat_statements):**
|
||||
|
||||
```sql
|
||||
-- Enable the extension
|
||||
create extension if not exists pg_stat_statements;
|
||||
|
||||
-- Find slowest queries by total time
|
||||
select
|
||||
calls,
|
||||
round(total_exec_time::numeric, 2) as total_time_ms,
|
||||
round(mean_exec_time::numeric, 2) as mean_time_ms,
|
||||
query
|
||||
from pg_stat_statements
|
||||
order by total_exec_time desc
|
||||
limit 10;
|
||||
|
||||
-- Find most frequent queries
|
||||
select calls, query
|
||||
from pg_stat_statements
|
||||
order by calls desc
|
||||
limit 10;
|
||||
|
||||
-- Reset statistics after optimization
|
||||
select pg_stat_statements_reset();
|
||||
```
|
||||
|
||||
Key metrics to monitor:
|
||||
|
||||
```sql
|
||||
-- Queries with high mean time (candidates for optimization)
|
||||
select query, mean_exec_time, calls
|
||||
from pg_stat_statements
|
||||
where mean_exec_time > 100 -- > 100ms average
|
||||
order by mean_exec_time desc;
|
||||
```
|
||||
|
||||
Reference: [pg_stat_statements](https://supabase.com/docs/guides/database/extensions/pg_stat_statements)
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
title: Maintain Table Statistics with VACUUM and ANALYZE
|
||||
impact: MEDIUM
|
||||
impactDescription: 2-10x better query plans with accurate statistics
|
||||
tags: vacuum, analyze, statistics, maintenance, autovacuum
|
||||
---
|
||||
|
||||
## Maintain Table Statistics with VACUUM and ANALYZE
|
||||
|
||||
Outdated statistics cause the query planner to make poor decisions. VACUUM reclaims space, ANALYZE updates statistics.
|
||||
|
||||
**Incorrect (stale statistics):**
|
||||
|
||||
```sql
|
||||
-- Table has 1M rows but stats say 1000
|
||||
-- Query planner chooses wrong strategy
|
||||
explain select * from orders where status = 'pending';
|
||||
-- Shows: Seq Scan (because stats show small table)
|
||||
-- Actually: Index Scan would be much faster
|
||||
```
|
||||
|
||||
**Correct (maintain fresh statistics):**
|
||||
|
||||
```sql
|
||||
-- Manually analyze after large data changes
|
||||
analyze orders;
|
||||
|
||||
-- Analyze specific columns used in WHERE clauses
|
||||
analyze orders (status, created_at);
|
||||
|
||||
-- Check when tables were last analyzed
|
||||
select
|
||||
relname,
|
||||
last_vacuum,
|
||||
last_autovacuum,
|
||||
last_analyze,
|
||||
last_autoanalyze
|
||||
from pg_stat_user_tables
|
||||
order by last_analyze nulls first;
|
||||
```
|
||||
|
||||
Autovacuum tuning for busy tables:
|
||||
|
||||
```sql
|
||||
-- Increase frequency for high-churn tables
|
||||
alter table orders set (
|
||||
autovacuum_vacuum_scale_factor = 0.05, -- Vacuum at 5% dead tuples (default 20%)
|
||||
autovacuum_analyze_scale_factor = 0.02 -- Analyze at 2% changes (default 10%)
|
||||
);
|
||||
|
||||
-- Check autovacuum status
|
||||
select * from pg_stat_progress_vacuum;
|
||||
```
|
||||
|
||||
Reference: [VACUUM](https://supabase.com/docs/guides/database/database-size#vacuum-operations)
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
title: Create Composite Indexes for Multi-Column Queries
|
||||
impact: HIGH
|
||||
impactDescription: 5-10x faster multi-column queries
|
||||
tags: indexes, composite-index, multi-column, query-optimization
|
||||
---
|
||||
|
||||
## Create Composite Indexes for Multi-Column Queries
|
||||
|
||||
When queries filter on multiple columns, a composite index is more efficient than separate single-column indexes.
|
||||
|
||||
**Incorrect (separate indexes require bitmap scan):**
|
||||
|
||||
```sql
|
||||
-- Two separate indexes
|
||||
create index orders_status_idx on orders (status);
|
||||
create index orders_created_idx on orders (created_at);
|
||||
|
||||
-- Query must combine both indexes (slower)
|
||||
select * from orders where status = 'pending' and created_at > '2024-01-01';
|
||||
```
|
||||
|
||||
**Correct (composite index):**
|
||||
|
||||
```sql
|
||||
-- Single composite index (leftmost column first for equality checks)
|
||||
create index orders_status_created_idx on orders (status, created_at);
|
||||
|
||||
-- Query uses one efficient index scan
|
||||
select * from orders where status = 'pending' and created_at > '2024-01-01';
|
||||
```
|
||||
|
||||
**Column order matters** - place equality columns first, range columns last:
|
||||
|
||||
```sql
|
||||
-- Good: status (=) before created_at (>)
|
||||
create index idx on orders (status, created_at);
|
||||
|
||||
-- Works for: WHERE status = 'pending'
|
||||
-- Works for: WHERE status = 'pending' AND created_at > '2024-01-01'
|
||||
-- Does NOT work for: WHERE created_at > '2024-01-01' (leftmost prefix rule)
|
||||
```
|
||||
|
||||
Reference: [Multicolumn Indexes](https://www.postgresql.org/docs/current/indexes-multicolumn.html)
|
||||
@@ -1,40 +0,0 @@
|
||||
---
|
||||
title: Use Covering Indexes to Avoid Table Lookups
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: 2-5x faster queries by eliminating heap fetches
|
||||
tags: indexes, covering-index, include, index-only-scan
|
||||
---
|
||||
|
||||
## Use Covering Indexes to Avoid Table Lookups
|
||||
|
||||
Covering indexes include all columns needed by a query, enabling index-only scans that skip the table entirely.
|
||||
|
||||
**Incorrect (index scan + heap fetch):**
|
||||
|
||||
```sql
|
||||
create index users_email_idx on users (email);
|
||||
|
||||
-- Must fetch name and created_at from table heap
|
||||
select email, name, created_at from users where email = 'user@example.com';
|
||||
```
|
||||
|
||||
**Correct (index-only scan with INCLUDE):**
|
||||
|
||||
```sql
|
||||
-- Include non-searchable columns in the index
|
||||
create index users_email_idx on users (email) include (name, created_at);
|
||||
|
||||
-- All columns served from index, no table access needed
|
||||
select email, name, created_at from users where email = 'user@example.com';
|
||||
```
|
||||
|
||||
Use INCLUDE for columns you SELECT but don't filter on:
|
||||
|
||||
```sql
|
||||
-- Searching by status, but also need customer_id and total
|
||||
create index orders_status_idx on orders (status) include (customer_id, total);
|
||||
|
||||
select status, customer_id, total from orders where status = 'shipped';
|
||||
```
|
||||
|
||||
Reference: [Index-Only Scans](https://www.postgresql.org/docs/current/indexes-index-only-scans.html)
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
title: Choose the Right Index Type for Your Data
|
||||
impact: HIGH
|
||||
impactDescription: 10-100x improvement with correct index type
|
||||
tags: indexes, btree, gin, gist, brin, hash, index-types
|
||||
---
|
||||
|
||||
## Choose the Right Index Type for Your Data
|
||||
|
||||
Different index types excel at different query patterns. The default B-tree isn't always optimal.
|
||||
|
||||
**Incorrect (B-tree for JSONB containment):**
|
||||
|
||||
```sql
|
||||
-- B-tree cannot optimize containment operators
|
||||
create index products_attrs_idx on products (attributes);
|
||||
select * from products where attributes @> '{"color": "red"}';
|
||||
-- Full table scan - B-tree doesn't support @> operator
|
||||
```
|
||||
|
||||
**Correct (GIN for JSONB):**
|
||||
|
||||
```sql
|
||||
-- GIN supports @>, ?, ?&, ?| operators
|
||||
create index products_attrs_idx on products using gin (attributes);
|
||||
select * from products where attributes @> '{"color": "red"}';
|
||||
```
|
||||
|
||||
Index type guide:
|
||||
|
||||
```sql
|
||||
-- B-tree (default): =, <, >, BETWEEN, IN, IS NULL
|
||||
create index users_created_idx on users (created_at);
|
||||
|
||||
-- GIN: arrays, JSONB, full-text search
|
||||
create index posts_tags_idx on posts using gin (tags);
|
||||
|
||||
-- GiST: geometric data, range types, nearest-neighbor (KNN) queries
|
||||
create index locations_idx on places using gist (location);
|
||||
|
||||
-- BRIN: large time-series tables (10-100x smaller)
|
||||
create index events_time_idx on events using brin (created_at);
|
||||
|
||||
-- Hash: equality-only (slightly faster than B-tree for =)
|
||||
create index sessions_token_idx on sessions using hash (token);
|
||||
```
|
||||
|
||||
Reference: [Index Types](https://www.postgresql.org/docs/current/indexes-types.html)
|
||||
@@ -1,43 +0,0 @@
|
||||
---
|
||||
title: Add Indexes on WHERE and JOIN Columns
|
||||
impact: CRITICAL
|
||||
impactDescription: 100-1000x faster queries on large tables
|
||||
tags: indexes, performance, sequential-scan, query-optimization
|
||||
---
|
||||
|
||||
## Add Indexes on WHERE and JOIN Columns
|
||||
|
||||
Queries filtering or joining on unindexed columns cause full table scans, which become exponentially slower as tables grow.
|
||||
|
||||
**Incorrect (sequential scan on large table):**
|
||||
|
||||
```sql
|
||||
-- No index on customer_id causes full table scan
|
||||
select * from orders where customer_id = 123;
|
||||
|
||||
-- EXPLAIN shows: Seq Scan on orders (cost=0.00..25000.00 rows=100 width=85)
|
||||
```
|
||||
|
||||
**Correct (index scan):**
|
||||
|
||||
```sql
|
||||
-- Create index on frequently filtered column
|
||||
create index orders_customer_id_idx on orders (customer_id);
|
||||
|
||||
select * from orders where customer_id = 123;
|
||||
|
||||
-- EXPLAIN shows: Index Scan using orders_customer_id_idx (cost=0.42..8.44 rows=100 width=85)
|
||||
```
|
||||
|
||||
For JOIN columns, always index the foreign key side:
|
||||
|
||||
```sql
|
||||
-- Index the referencing column
|
||||
create index orders_customer_id_idx on orders (customer_id);
|
||||
|
||||
select c.name, o.total
|
||||
from customers c
|
||||
join orders o on o.customer_id = c.id;
|
||||
```
|
||||
|
||||
Reference: [Query Optimization](https://supabase.com/docs/guides/database/query-optimization)
|
||||
@@ -1,45 +0,0 @@
|
||||
---
|
||||
title: Use Partial Indexes for Filtered Queries
|
||||
impact: HIGH
|
||||
impactDescription: 5-20x smaller indexes, faster writes and queries
|
||||
tags: indexes, partial-index, query-optimization, storage
|
||||
---
|
||||
|
||||
## Use Partial Indexes for Filtered Queries
|
||||
|
||||
Partial indexes only include rows matching a WHERE condition, making them smaller and faster when queries consistently filter on the same condition.
|
||||
|
||||
**Incorrect (full index includes irrelevant rows):**
|
||||
|
||||
```sql
|
||||
-- Index includes all rows, even soft-deleted ones
|
||||
create index users_email_idx on users (email);
|
||||
|
||||
-- Query always filters active users
|
||||
select * from users where email = 'user@example.com' and deleted_at is null;
|
||||
```
|
||||
|
||||
**Correct (partial index matches query filter):**
|
||||
|
||||
```sql
|
||||
-- Index only includes active users
|
||||
create index users_active_email_idx on users (email)
|
||||
where deleted_at is null;
|
||||
|
||||
-- Query uses the smaller, faster index
|
||||
select * from users where email = 'user@example.com' and deleted_at is null;
|
||||
```
|
||||
|
||||
Common use cases for partial indexes:
|
||||
|
||||
```sql
|
||||
-- Only pending orders (status rarely changes once completed)
|
||||
create index orders_pending_idx on orders (created_at)
|
||||
where status = 'pending';
|
||||
|
||||
-- Only non-null values
|
||||
create index products_sku_idx on products (sku)
|
||||
where sku is not null;
|
||||
```
|
||||
|
||||
Reference: [Partial Indexes](https://www.postgresql.org/docs/current/indexes-partial.html)
|
||||
@@ -1,80 +0,0 @@
|
||||
---
|
||||
title: Add Constraints Safely in Migrations
|
||||
impact: HIGH
|
||||
impactDescription: Prevents migration failures and enables idempotent schema changes
|
||||
tags: constraints, migrations, schema, alter-table
|
||||
---
|
||||
|
||||
## Add Constraints Safely in Migrations
|
||||
|
||||
PostgreSQL does not support `ADD CONSTRAINT IF NOT EXISTS`. Migrations using this syntax will fail.
|
||||
|
||||
**Incorrect (causes syntax error):**
|
||||
|
||||
```sql
|
||||
-- ERROR: syntax error at or near "not" (SQLSTATE 42601)
|
||||
alter table public.profiles
|
||||
add constraint if not exists profiles_birthchart_id_unique unique (birthchart_id);
|
||||
```
|
||||
|
||||
**Correct (idempotent constraint creation):**
|
||||
|
||||
```sql
|
||||
-- Use DO block to check before adding
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1 from pg_constraint
|
||||
where conname = 'profiles_birthchart_id_unique'
|
||||
and conrelid = 'public.profiles'::regclass
|
||||
) then
|
||||
alter table public.profiles
|
||||
add constraint profiles_birthchart_id_unique unique (birthchart_id);
|
||||
end if;
|
||||
end $$;
|
||||
```
|
||||
|
||||
For all constraint types:
|
||||
|
||||
```sql
|
||||
-- Check constraints
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1 from pg_constraint
|
||||
where conname = 'check_age_positive'
|
||||
) then
|
||||
alter table users add constraint check_age_positive check (age > 0);
|
||||
end if;
|
||||
end $$;
|
||||
|
||||
-- Foreign keys
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1 from pg_constraint
|
||||
where conname = 'profiles_birthchart_id_fkey'
|
||||
) then
|
||||
alter table profiles
|
||||
add constraint profiles_birthchart_id_fkey
|
||||
foreign key (birthchart_id) references birthcharts(id);
|
||||
end if;
|
||||
end $$;
|
||||
```
|
||||
|
||||
Check if constraint exists:
|
||||
|
||||
```sql
|
||||
-- Query to check constraint existence
|
||||
select conname, contype, pg_get_constraintdef(oid)
|
||||
from pg_constraint
|
||||
where conrelid = 'public.profiles'::regclass;
|
||||
|
||||
-- contype values:
|
||||
-- 'p' = PRIMARY KEY
|
||||
-- 'f' = FOREIGN KEY
|
||||
-- 'u' = UNIQUE
|
||||
-- 'c' = CHECK
|
||||
```
|
||||
|
||||
Reference: [Constraints](https://www.postgresql.org/docs/current/ddl-constraints.html)
|
||||
@@ -1,46 +0,0 @@
|
||||
---
|
||||
title: Choose Appropriate Data Types
|
||||
impact: HIGH
|
||||
impactDescription: 50% storage reduction, faster comparisons
|
||||
tags: data-types, schema, storage, performance
|
||||
---
|
||||
|
||||
## Choose Appropriate Data Types
|
||||
|
||||
Using the right data types reduces storage, improves query performance, and prevents bugs.
|
||||
|
||||
**Incorrect (wrong data types):**
|
||||
|
||||
```sql
|
||||
create table users (
|
||||
id int, -- Will overflow at 2.1 billion
|
||||
email varchar(255), -- Unnecessary length limit
|
||||
created_at timestamp, -- Missing timezone info
|
||||
is_active varchar(5), -- String for boolean
|
||||
price varchar(20) -- String for numeric
|
||||
);
|
||||
```
|
||||
|
||||
**Correct (appropriate data types):**
|
||||
|
||||
```sql
|
||||
create table users (
|
||||
id bigint generated always as identity primary key, -- 9 quintillion max
|
||||
email text, -- No artificial limit, same performance as varchar
|
||||
created_at timestamptz, -- Always store timezone-aware timestamps
|
||||
is_active boolean default true, -- 1 byte vs variable string length
|
||||
price numeric(10,2) -- Exact decimal arithmetic
|
||||
);
|
||||
```
|
||||
|
||||
Key guidelines:
|
||||
|
||||
```sql
|
||||
-- IDs: use bigint, not int (future-proofing)
|
||||
-- Strings: use text, not varchar(n) unless constraint needed
|
||||
-- Time: use timestamptz, not timestamp
|
||||
-- Money: use numeric, not float (precision matters)
|
||||
-- Enums: use text with check constraint or create enum type
|
||||
```
|
||||
|
||||
Reference: [Data Types](https://www.postgresql.org/docs/current/datatype.html)
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
---
|
||||
title: Index Foreign Key Columns
|
||||
impact: HIGH
|
||||
impactDescription: 10-100x faster JOINs and CASCADE operations
|
||||
tags: foreign-key, indexes, joins, schema
|
||||
---
|
||||
|
||||
## Index Foreign Key Columns
|
||||
|
||||
Postgres does not automatically index foreign key columns. Missing indexes cause slow JOINs and CASCADE operations.
|
||||
|
||||
**Incorrect (unindexed foreign key):**
|
||||
|
||||
```sql
|
||||
create table orders (
|
||||
id bigint generated always as identity primary key,
|
||||
customer_id bigint references customers(id) on delete cascade,
|
||||
total numeric(10,2)
|
||||
);
|
||||
|
||||
-- No index on customer_id!
|
||||
-- JOINs and ON DELETE CASCADE both require full table scan
|
||||
select * from orders where customer_id = 123; -- Seq Scan
|
||||
delete from customers where id = 123; -- Locks table, scans all orders
|
||||
```
|
||||
|
||||
**Correct (indexed foreign key):**
|
||||
|
||||
```sql
|
||||
create table orders (
|
||||
id bigint generated always as identity primary key,
|
||||
customer_id bigint references customers(id) on delete cascade,
|
||||
total numeric(10,2)
|
||||
);
|
||||
|
||||
-- Always index the FK column
|
||||
create index orders_customer_id_idx on orders (customer_id);
|
||||
|
||||
-- Now JOINs and cascades are fast
|
||||
select * from orders where customer_id = 123; -- Index Scan
|
||||
delete from customers where id = 123; -- Uses index, fast cascade
|
||||
```
|
||||
|
||||
Find missing FK indexes:
|
||||
|
||||
```sql
|
||||
select
|
||||
conrelid::regclass as table_name,
|
||||
a.attname as fk_column
|
||||
from pg_constraint c
|
||||
join pg_attribute a on a.attrelid = c.conrelid and a.attnum = any(c.conkey)
|
||||
where c.contype = 'f'
|
||||
and not exists (
|
||||
select 1 from pg_index i
|
||||
where i.indrelid = c.conrelid and a.attnum = any(i.indkey)
|
||||
);
|
||||
```
|
||||
|
||||
Reference: [Foreign Keys](https://www.postgresql.org/docs/current/ddl-constraints.html#DDL-CONSTRAINTS-FK)
|
||||
-55
@@ -1,55 +0,0 @@
|
||||
---
|
||||
title: Use Lowercase Identifiers for Compatibility
|
||||
impact: MEDIUM
|
||||
impactDescription: Avoid case-sensitivity bugs with tools, ORMs, and AI assistants
|
||||
tags: naming, identifiers, case-sensitivity, schema, conventions
|
||||
---
|
||||
|
||||
## Use Lowercase Identifiers for Compatibility
|
||||
|
||||
PostgreSQL folds unquoted identifiers to lowercase. Quoted mixed-case identifiers require quotes forever and cause issues with tools, ORMs, and AI assistants that may not recognize them.
|
||||
|
||||
**Incorrect (mixed-case identifiers):**
|
||||
|
||||
```sql
|
||||
-- Quoted identifiers preserve case but require quotes everywhere
|
||||
CREATE TABLE "Users" (
|
||||
"userId" bigint PRIMARY KEY,
|
||||
"firstName" text,
|
||||
"lastName" text
|
||||
);
|
||||
|
||||
-- Must always quote or queries fail
|
||||
SELECT "firstName" FROM "Users" WHERE "userId" = 1;
|
||||
|
||||
-- This fails - Users becomes users without quotes
|
||||
SELECT firstName FROM Users;
|
||||
-- ERROR: relation "users" does not exist
|
||||
```
|
||||
|
||||
**Correct (lowercase snake_case):**
|
||||
|
||||
```sql
|
||||
-- Unquoted lowercase identifiers are portable and tool-friendly
|
||||
CREATE TABLE users (
|
||||
user_id bigint PRIMARY KEY,
|
||||
first_name text,
|
||||
last_name text
|
||||
);
|
||||
|
||||
-- Works without quotes, recognized by all tools
|
||||
SELECT first_name FROM users WHERE user_id = 1;
|
||||
```
|
||||
|
||||
Common sources of mixed-case identifiers:
|
||||
|
||||
```sql
|
||||
-- ORMs often generate quoted camelCase - configure them to use snake_case
|
||||
-- Migrations from other databases may preserve original casing
|
||||
-- Some GUI tools quote identifiers by default - disable this
|
||||
|
||||
-- If stuck with mixed-case, create views as a compatibility layer
|
||||
CREATE VIEW users AS SELECT "userId" AS user_id, "firstName" AS first_name FROM "Users";
|
||||
```
|
||||
|
||||
Reference: [Identifiers and Key Words](https://www.postgresql.org/docs/current/sql-syntax-lexical.html#SQL-SYNTAX-IDENTIFIERS)
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
title: Partition Large Tables for Better Performance
|
||||
impact: MEDIUM-HIGH
|
||||
impactDescription: 5-20x faster queries and maintenance on large tables
|
||||
tags: partitioning, large-tables, time-series, performance
|
||||
---
|
||||
|
||||
## Partition Large Tables for Better Performance
|
||||
|
||||
Partitioning splits a large table into smaller pieces, improving query performance and maintenance operations.
|
||||
|
||||
**Incorrect (single large table):**
|
||||
|
||||
```sql
|
||||
create table events (
|
||||
id bigint generated always as identity,
|
||||
created_at timestamptz,
|
||||
data jsonb
|
||||
);
|
||||
|
||||
-- 500M rows, queries scan everything
|
||||
select * from events where created_at > '2024-01-01'; -- Slow
|
||||
vacuum events; -- Takes hours, locks table
|
||||
```
|
||||
|
||||
**Correct (partitioned by time range):**
|
||||
|
||||
```sql
|
||||
create table events (
|
||||
id bigint generated always as identity,
|
||||
created_at timestamptz not null,
|
||||
data jsonb
|
||||
) partition by range (created_at);
|
||||
|
||||
-- Create partitions for each month
|
||||
create table events_2024_01 partition of events
|
||||
for values from ('2024-01-01') to ('2024-02-01');
|
||||
|
||||
create table events_2024_02 partition of events
|
||||
for values from ('2024-02-01') to ('2024-03-01');
|
||||
|
||||
-- Queries only scan relevant partitions
|
||||
select * from events where created_at > '2024-01-15'; -- Only scans events_2024_01+
|
||||
|
||||
-- Drop old data instantly
|
||||
drop table events_2023_01; -- Instant vs DELETE taking hours
|
||||
```
|
||||
|
||||
When to partition:
|
||||
|
||||
- Tables > 100M rows
|
||||
- Time-series data with date-based queries
|
||||
- Need to efficiently drop old data
|
||||
|
||||
Reference: [Table Partitioning](https://www.postgresql.org/docs/current/ddl-partitioning.html)
|
||||
@@ -1,61 +0,0 @@
|
||||
---
|
||||
title: Select Optimal Primary Key Strategy
|
||||
impact: HIGH
|
||||
impactDescription: Better index locality, reduced fragmentation
|
||||
tags: primary-key, identity, uuid, serial, schema
|
||||
---
|
||||
|
||||
## Select Optimal Primary Key Strategy
|
||||
|
||||
Primary key choice affects insert performance, index size, and replication
|
||||
efficiency.
|
||||
|
||||
**Incorrect (problematic PK choices):**
|
||||
|
||||
```sql
|
||||
-- identity is the SQL-standard approach
|
||||
create table users (
|
||||
id serial primary key -- Works, but IDENTITY is recommended
|
||||
);
|
||||
|
||||
-- Random UUIDs (v4) cause index fragmentation
|
||||
create table orders (
|
||||
id uuid default gen_random_uuid() primary key -- UUIDv4 = random = scattered inserts
|
||||
);
|
||||
```
|
||||
|
||||
**Correct (optimal PK strategies):**
|
||||
|
||||
```sql
|
||||
-- Use IDENTITY for sequential IDs (SQL-standard, best for most cases)
|
||||
create table users (
|
||||
id bigint generated always as identity primary key
|
||||
);
|
||||
|
||||
-- For distributed systems needing UUIDs, use UUIDv7 (time-ordered)
|
||||
-- Requires pg_uuidv7 extension: create extension pg_uuidv7;
|
||||
create table orders (
|
||||
id uuid default uuid_generate_v7() primary key -- Time-ordered, no fragmentation
|
||||
);
|
||||
|
||||
-- Alternative: time-prefixed IDs for sortable, distributed IDs (no extension needed)
|
||||
create table events (
|
||||
id text default concat(
|
||||
to_char(now() at time zone 'utc', 'YYYYMMDDHH24MISSMS'),
|
||||
gen_random_uuid()::text
|
||||
) primary key
|
||||
);
|
||||
```
|
||||
|
||||
Guidelines:
|
||||
|
||||
- Single database: `bigint identity` (sequential, 8 bytes, SQL-standard)
|
||||
- Distributed/exposed IDs: UUIDv7 (requires pg_uuidv7) or ULID (time-ordered, no
|
||||
fragmentation)
|
||||
- `serial` works but `identity` is SQL-standard and preferred for new
|
||||
applications
|
||||
- Avoid random UUIDs (v4) as primary keys on large tables (causes index
|
||||
fragmentation)
|
||||
|
||||
Reference:
|
||||
[Identity Columns](https://www.postgresql.org/docs/current/sql-createtable.html#SQL-CREATETABLE-PARMS-GENERATED-IDENTITY)
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
title: Apply Principle of Least Privilege
|
||||
impact: MEDIUM
|
||||
impactDescription: Reduced attack surface, better audit trail
|
||||
tags: privileges, security, roles, permissions
|
||||
---
|
||||
|
||||
## Apply Principle of Least Privilege
|
||||
|
||||
Grant only the minimum permissions required. Never use superuser for application queries.
|
||||
|
||||
**Incorrect (overly broad permissions):**
|
||||
|
||||
```sql
|
||||
-- Application uses superuser connection
|
||||
-- Or grants ALL to application role
|
||||
grant all privileges on all tables in schema public to app_user;
|
||||
grant all privileges on all sequences in schema public to app_user;
|
||||
|
||||
-- Any SQL injection becomes catastrophic
|
||||
-- drop table users; cascades to everything
|
||||
```
|
||||
|
||||
**Correct (minimal, specific grants):**
|
||||
|
||||
```sql
|
||||
-- Create role with no default privileges
|
||||
create role app_readonly nologin;
|
||||
|
||||
-- Grant only SELECT on specific tables
|
||||
grant usage on schema public to app_readonly;
|
||||
grant select on public.products, public.categories to app_readonly;
|
||||
|
||||
-- Create role for writes with limited scope
|
||||
create role app_writer nologin;
|
||||
grant usage on schema public to app_writer;
|
||||
grant select, insert, update on public.orders to app_writer;
|
||||
grant usage on sequence orders_id_seq to app_writer;
|
||||
-- No DELETE permission
|
||||
|
||||
-- Login role inherits from these
|
||||
create role app_user login password 'xxx';
|
||||
grant app_writer to app_user;
|
||||
```
|
||||
|
||||
Revoke public defaults:
|
||||
|
||||
```sql
|
||||
-- Revoke default public access
|
||||
revoke all on schema public from public;
|
||||
revoke all on all tables in schema public from public;
|
||||
```
|
||||
|
||||
Reference: [Roles and Privileges](https://supabase.com/blog/postgres-roles-and-privileges)
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
title: Enable Row Level Security for Multi-Tenant Data
|
||||
impact: CRITICAL
|
||||
impactDescription: Database-enforced tenant isolation, prevent data leaks
|
||||
tags: rls, row-level-security, multi-tenant, security
|
||||
---
|
||||
|
||||
## Enable Row Level Security for Multi-Tenant Data
|
||||
|
||||
Row Level Security (RLS) enforces data access at the database level, ensuring users only see their own data.
|
||||
|
||||
**Incorrect (application-level filtering only):**
|
||||
|
||||
```sql
|
||||
-- Relying only on application to filter
|
||||
select * from orders where user_id = $current_user_id;
|
||||
|
||||
-- Bug or bypass means all data is exposed!
|
||||
select * from orders; -- Returns ALL orders
|
||||
```
|
||||
|
||||
**Correct (database-enforced RLS):**
|
||||
|
||||
```sql
|
||||
-- Enable RLS on the table
|
||||
alter table orders enable row level security;
|
||||
|
||||
-- Create policy for users to see only their orders
|
||||
create policy orders_user_policy on orders
|
||||
for all
|
||||
using (user_id = current_setting('app.current_user_id')::bigint);
|
||||
|
||||
-- Force RLS even for table owners
|
||||
alter table orders force row level security;
|
||||
|
||||
-- Set user context and query
|
||||
set app.current_user_id = '123';
|
||||
select * from orders; -- Only returns orders for user 123
|
||||
```
|
||||
|
||||
Policy for authenticated role:
|
||||
|
||||
```sql
|
||||
create policy orders_user_policy on orders
|
||||
for all
|
||||
to authenticated
|
||||
using (user_id = auth.uid());
|
||||
```
|
||||
|
||||
Reference: [Row Level Security](https://supabase.com/docs/guides/database/postgres/row-level-security)
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
---
|
||||
title: Optimize RLS Policies for Performance
|
||||
impact: HIGH
|
||||
impactDescription: 5-10x faster RLS queries with proper patterns
|
||||
tags: rls, performance, security, optimization
|
||||
---
|
||||
|
||||
## Optimize RLS Policies for Performance
|
||||
|
||||
Poorly written RLS policies can cause severe performance issues. Use subqueries and indexes strategically.
|
||||
|
||||
**Incorrect (function called for every row):**
|
||||
|
||||
```sql
|
||||
create policy orders_policy on orders
|
||||
using (auth.uid() = user_id); -- auth.uid() called per row!
|
||||
|
||||
-- With 1M rows, auth.uid() is called 1M times
|
||||
```
|
||||
|
||||
**Correct (wrap functions in SELECT):**
|
||||
|
||||
```sql
|
||||
create policy orders_policy on orders
|
||||
using ((select auth.uid()) = user_id); -- Called once, cached
|
||||
|
||||
-- 100x+ faster on large tables
|
||||
```
|
||||
|
||||
Use security definer functions for complex checks:
|
||||
|
||||
`SECURITY DEFINER` functions run with the creator's privileges and bypass RLS on any tables they touch — which is what makes them useful for internal lookups, but also what makes them dangerous if misused. Always include an explicit `auth.uid()` check inside the function body, keep them in a non-exposed schema, and revoke `EXECUTE` from any role that shouldn't call them directly.
|
||||
|
||||
```sql
|
||||
-- Create helper function in a private schema
|
||||
create or replace function private.is_team_member(team_id bigint)
|
||||
returns boolean
|
||||
language sql
|
||||
security definer
|
||||
set search_path = ''
|
||||
as $$
|
||||
select exists (
|
||||
select 1 from public.team_members
|
||||
-- always check the calling user's identity inside the function
|
||||
where team_id = $1 and user_id = (select auth.uid())
|
||||
);
|
||||
$$;
|
||||
|
||||
-- Revoke direct execution from public roles
|
||||
revoke execute on function private.is_team_member(bigint) from PUBLIC, anon, authenticated, service_role;
|
||||
|
||||
-- Use in policy (indexed lookup, not per-row check)
|
||||
create policy team_orders_policy on orders
|
||||
using ((select private.is_team_member(team_id)));
|
||||
```
|
||||
|
||||
Always add indexes on columns used in RLS policies:
|
||||
|
||||
```sql
|
||||
create index orders_user_id_idx on orders (user_id);
|
||||
```
|
||||
|
||||
Reference: [RLS Performance](https://supabase.com/docs/guides/database/postgres/row-level-security#rls-performance-recommendations)
|
||||
@@ -1,132 +0,0 @@
|
||||
---
|
||||
name: supabase
|
||||
description: "Use when doing ANY task involving Supabase. Triggers: Supabase products (Database, Auth, Edge Functions, Realtime, Storage, Vectors, Cron, Queues); client libraries and SSR integrations (supabase-js, @supabase/ssr) in Next.js, React, SvelteKit, Astro, Remix; auth issues (login, logout, sessions, JWT, cookies, getSession, getUser, getClaims, RLS); Supabase CLI or MCP server; schema changes, migrations, security audits, Postgres extensions (pg_graphql, pg_cron, pg_vector)."
|
||||
metadata:
|
||||
author: supabase
|
||||
version: "0.1.2"
|
||||
---
|
||||
|
||||
# Supabase
|
||||
|
||||
## Core Principles
|
||||
|
||||
**1. Supabase changes frequently — verify against changelog and current docs before implementing.**
|
||||
Do not rely on training data for Supabase features. Function signatures, config.toml settings, and API conventions change between versions.
|
||||
|
||||
First, fetch `https://supabase.com/changelog.md` (a lightweight summary index — not a heavy pull), scan for `breaking-change` tags relevant to your task, and follow the linked page for any that apply. Then look up the relevant topic using the documentation access methods below.
|
||||
|
||||
**2. Verify your work.**
|
||||
After implementing any fix, run a test query to confirm the change works. A fix without verification is incomplete.
|
||||
|
||||
**3. Recover from errors, don't loop.**
|
||||
If an approach fails after 2-3 attempts, stop and reconsider. Try a different method, check documentation, inspect the error more carefully, and review relevant logs when available. Supabase issues are not always solved by retrying the same command, and the answer is not always in the logs, but logs are often worth checking before proceeding.
|
||||
|
||||
**4. Exposing tables to the Data API:** Depending on the user's [Data API settings](https://supabase.com/dashboard/project/<ref>/integrations/data_api/settings), newly created tables may not be automatically exposed via the Data (REST) API. If this is the case, `anon` and `authenticated` roles will need to be explicitly granted access.
|
||||
|
||||
> Note that this is separate from RLS, which controls which _rows_ are visible once a table is accessible, not whether the table is accessible at all.
|
||||
|
||||
When a user reports a SQL-created table is unexpectedly inaccessible, check their Data API settings and whether the roles have been granted access via explicit `GRANT` SQL. When granting public (`anon`/`authenticated`) access, always enable RLS too. See [Exposing a Table to the Data API](https://supabase.com/docs/guides/api/securing-your-api.md) for the full setup workflow.
|
||||
|
||||
**5. RLS in exposed schemas.**
|
||||
Enable RLS on every table in any exposed schema, which includes `public` by default. This is critical in Supabase because tables in exposed schemas can be reachable through the Data API when the `anon`/`authenticated` roles have access (see [Exposing a Table to the Data API](https://supabase.com/docs/guides/api/securing-your-api.md)). For private schemas, prefer RLS as defense in depth. After enabling RLS, create policies that match the actual access model rather than defaulting every table to the same `auth.uid()` pattern.
|
||||
|
||||
**6. Security checklist.**
|
||||
When working on any Supabase task that touches auth, RLS, views, storage, or user data, run through this checklist. These are Supabase-specific security traps that silently create vulnerabilities:
|
||||
|
||||
- **Auth and session security**
|
||||
- **Never use `user_metadata` claims in JWT-based authorization decisions.** In Supabase, `raw_user_meta_data` is user-editable and can appear in `auth.jwt()`, so it is unsafe for RLS policies or any other authorization logic. Store authorization data in `raw_app_meta_data` / `app_metadata` instead.
|
||||
- **Deleting a user does not invalidate existing access tokens.** Sign out or revoke sessions first, keep JWT expiry short for sensitive apps, and for strict guarantees validate `session_id` against `auth.sessions` on sensitive operations.
|
||||
- **If you use `app_metadata` or `auth.jwt()` for authorization, remember JWT claims are not always fresh until the user's token is refreshed.**
|
||||
|
||||
- **API key and client exposure**
|
||||
- **Never expose the `service_role` or secret key in public clients.** Prefer publishable keys for frontend code. Legacy `anon` keys are only for compatibility. In Next.js, any `NEXT_PUBLIC_` env var is sent to the browser.
|
||||
|
||||
- **RLS, views, and privileged database code**
|
||||
- **Views bypass RLS by default.** In Postgres 15 and above, use `CREATE VIEW ... WITH (security_invoker = true)`. In older versions of Postgres, protect your views by revoking access from the `anon` and `authenticated` roles, or by putting them in an unexposed schema.
|
||||
- **UPDATE requires a SELECT policy.** In Postgres RLS, an UPDATE needs to first SELECT the row. Without a SELECT policy, updates silently return 0 rows — no error, just no change.
|
||||
- **`auth.role()` is deprecated — use the `TO` clause instead.** Supabase has deprecated `auth.role()` in favour of specifying the target role directly on the policy with `TO authenticated` or `TO anon`. Beyond deprecation, `auth.role() = 'authenticated'` breaks silently when anonymous sign-ins are enabled, because anonymous users carry the `authenticated` Postgres role and pass the check regardless of whether the user is genuinely signed in.
|
||||
```sql
|
||||
-- Deprecated (do not use)
|
||||
create policy "example" on table_name for select
|
||||
using ( auth.role() = 'authenticated' );
|
||||
```
|
||||
- **`TO authenticated` alone is authentication without authorization (BOLA / IDOR).** Using `TO authenticated` only checks the role — it does not restrict which rows a user can access. The correct pattern combines `TO authenticated` with an ownership predicate in `USING`:
|
||||
```sql
|
||||
create policy "example" on table_name for select
|
||||
to authenticated
|
||||
using ( (select auth.uid()) = user_id );
|
||||
```
|
||||
- **UPDATE policies require both `USING` and `WITH CHECK`.** Without `WITH CHECK`, a user can reassign a row's `user_id` to another user:
|
||||
```sql
|
||||
create policy "example" on table_name for update
|
||||
to authenticated
|
||||
using ( (select auth.uid()) = user_id )
|
||||
with check ( (select auth.uid()) = user_id );
|
||||
```
|
||||
- **`SECURITY DEFINER` functions bypass RLS.** A `SECURITY DEFINER` function runs with its creator's privileges — typically a role with `bypassrls` (e.g., `postgres`). Never add `SECURITY DEFINER` to resolve a permission error; it silently removes access control without fixing the underlying cause. Prefer `SECURITY INVOKER`.
|
||||
- **`SECURITY DEFINER` functions in `public` are callable by all roles.** Postgres grants `EXECUTE` to `PUBLIC` by default for every new function, so any `SECURITY DEFINER` function in `public` is a public API endpoint callable by `anon` and `authenticated` (which inherit from `PUBLIC`) without any additional grant. When `SECURITY DEFINER` is genuinely needed (e.g., bypassing RLS on an internal lookup table), keep the function in a non-exposed schema, always include an `auth.uid()` check in the function body, and run `supabase db advisors` after making changes.
|
||||
|
||||
- **Storage access control**
|
||||
- **Storage upsert requires INSERT + SELECT + UPDATE.** Granting only INSERT allows new uploads but file replacement (upsert) silently fails. You need all three.
|
||||
|
||||
For any security concern not covered above, fetch the Supabase product security index: `https://supabase.com/docs/guides/security/product-security.md`
|
||||
|
||||
## Supabase CLI
|
||||
|
||||
Always discover commands via `--help` — never guess. The CLI structure changes between versions.
|
||||
|
||||
```bash
|
||||
supabase --help # All top-level commands
|
||||
supabase <group> --help # Subcommands (e.g., supabase db --help)
|
||||
supabase <group> <command> --help # Flags for a specific command
|
||||
```
|
||||
|
||||
**Supabase CLI Known gotchas:**
|
||||
|
||||
- `supabase db query` requires **CLI v2.79.0+** → use MCP `execute_sql` or `psql` as fallback
|
||||
- `supabase db advisors` requires **CLI v2.81.3+** → use MCP `get_advisors` as fallback
|
||||
- When you need a new migration SQL file, **always** create it with `supabase migration new <name>` first. Never invent a migration filename or rely on memory for the expected format.
|
||||
|
||||
**Version check and upgrade:** Run `supabase --version` to check. For CLI changelogs and version-specific features, consult the [CLI documentation](https://supabase.com/docs/reference/cli/introduction) or [GitHub releases](https://github.com/supabase/cli/releases).
|
||||
|
||||
## Supabase MCP Server
|
||||
|
||||
For setup instructions, server URL, and configuration, see the [MCP setup guide](https://supabase.com/docs/guides/getting-started/mcp).
|
||||
|
||||
**Troubleshooting connection issues** — follow these steps in order:
|
||||
|
||||
1. **Check if the server is reachable:**
|
||||
`curl -so /dev/null -w "%{http_code}" https://mcp.supabase.com/mcp`
|
||||
A `401` is expected (no token) and means the server is up. Timeout or "connection refused" means it may be down.
|
||||
|
||||
2. **Check `.mcp.json` configuration:**
|
||||
Verify the project root has a valid `.mcp.json` with the correct server URL. If missing, create one pointing to `https://mcp.supabase.com/mcp`.
|
||||
|
||||
3. **Authenticate the MCP server:**
|
||||
If the server is reachable and `.mcp.json` is correct but tools aren't visible, the user needs to authenticate. The Supabase MCP server uses OAuth 2.1 — tell the user to trigger the auth flow in their agent, complete it in the browser, and reload the session.
|
||||
|
||||
## Supabase Documentation
|
||||
|
||||
Before implementing any Supabase feature, find the relevant documentation. Use these methods in priority order:
|
||||
|
||||
1. **MCP `search_docs` tool** (preferred — returns relevant snippets directly)
|
||||
2. **Fetch docs pages as markdown** — any docs page can be fetched by appending `.md` to the URL path.
|
||||
3. **Web search** for Supabase-specific topics when you don't know which page to look at.
|
||||
|
||||
## Making and Committing Schema Changes
|
||||
|
||||
**To make schema changes, use `execute_sql` (MCP) or `supabase db query` (CLI).** These run SQL directly on the database without creating migration history entries, so you can iterate freely and generate a clean migration when ready.
|
||||
|
||||
Do NOT use `apply_migration` to change a local database schema — it writes a migration history entry on every call, which means you can't iterate, and `supabase db diff` / `supabase db pull` will produce empty or conflicting diffs. If you use it, you'll be stuck with whatever SQL you passed on the first try.
|
||||
|
||||
**When ready to commit** your changes to a migration file:
|
||||
|
||||
1. **Run advisors** → `supabase db advisors` (CLI v2.81.3+) or MCP `get_advisors`. Fix any issues.
|
||||
2. **Review the Security Checklist above** if your changes involve views, functions, triggers, or storage.
|
||||
3. **Generate the migration** → `supabase db pull <descriptive-name> --local --yes`
|
||||
4. **Verify** → `supabase migration list --local`
|
||||
|
||||
## Reference Guides
|
||||
|
||||
- **Skill Feedback** → [references/skill-feedback.md](references/skill-feedback.md)
|
||||
**MUST read when** the user reports that this skill gave incorrect guidance or is missing information.
|
||||
@@ -1,17 +0,0 @@
|
||||
## What happened
|
||||
|
||||
**Task:** <!-- e.g., "Set up MFA on patient records" -->
|
||||
|
||||
**Skill said:** <!-- e.g., "Use auth.jwt()->'app_metadata' in the RLS policy" -->
|
||||
|
||||
**Expected:** <!-- e.g., "The function also needs SECURITY DEFINER + grant to supabase_auth_admin" -->
|
||||
|
||||
## Source
|
||||
|
||||
**File:** <!-- e.g., references/security-model.md -->
|
||||
|
||||
**Section:** <!-- e.g., "Trust Boundaries > user_metadata vs app_metadata" -->
|
||||
|
||||
## Fix suggestion
|
||||
|
||||
<!-- Leave blank if unsure -->
|
||||
@@ -1,17 +0,0 @@
|
||||
# Skill Feedback
|
||||
|
||||
Use this when the user reports that the skill gave incorrect guidance, is missing information, or could be improved. This is about the skill (agent instructions), not about Supabase the product.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Ask permission** — Ask the user if they'd like to submit feedback to the skill maintainers. If they decline, move on.
|
||||
|
||||
2. **Draft the issue** — Use the template at [assets/feedback-issue-template.md](../assets/feedback-issue-template.md) to structure the feedback. Fill in the fields based on the conversation. Always identify which specific reference file and section caused the problem.
|
||||
|
||||
3. **Submit** — Create a GitHub Issue on the `supabase/agent-skills` repository using the draft as the issue body. The title must follow this format: `user-feedback: <summary of the problem>`.
|
||||
|
||||
4. **Share the result** — Share the issue URL with the user after submission. If submission fails, give the user this link to create the issue manually:
|
||||
|
||||
```
|
||||
https://github.com/supabase/agent-skills/issues/new
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
VITE_API_URL=http://localhost:8000/api/v1
|
||||
VITE_AMAP_KEY=
|
||||
VITE_SITE_URL=http://localhost:5173
|
||||
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -9,36 +9,36 @@
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Vue 3 + Vite + Tailwind CSS + Vue Router + Pinia + Supabase + AMap (高德地图) + Naive UI**
|
||||
- **Vue 3 + Vite + Tailwind CSS + Vue Router + Pinia + FastAPI + AMap (高德地图) + Naive UI**
|
||||
- Path alias `@/` → `src/` (configured in both `vite.config.ts` and `tsconfig.app.json`)
|
||||
- Supabase client singleton at `src/lib/supabase.ts` — throws at import if env vars missing
|
||||
- FastAPI client at `src/lib/api.ts`; API base URL comes from `VITE_API_URL`
|
||||
- AMap loaded lazily via `src/lib/amap.ts` with type declarations in `src/types/amap.d.ts`
|
||||
- UI language is Chinese (zh-CN)
|
||||
- All DB operations are direct `supabase.from(...)` calls from the browser — security depends on Supabase RLS policies
|
||||
- All persistence, storage, authorization, and admin operations go through the FastAPI backend
|
||||
|
||||
## Directory Map
|
||||
|
||||
| Path | Purpose |
|
||||
| --- | --- |
|
||||
| `src/lib/` | Singletons and utilities: supabase, amap, canvas patch, cloudTypes constants, cloudBadges canvas renderer, SEO meta builder |
|
||||
| `src/lib/` | Singletons and utilities: FastAPI client, amap, canvas patch, cloudTypes constants, cloudBadges canvas renderer, SEO meta builder |
|
||||
| `src/stores/` | Pinia stores: auth, clouds (cloud_types cache), encyclopedia (collection + unlock tracking), profile (user pages + cloud CRUD) |
|
||||
| `src/composables/` | Vue composables: `useUpload` (batch upload with thumbnail generation, EXIF extraction, badge unlocking) |
|
||||
| `src/composables/` | Vue composables: `useUpload` (batch multipart upload, EXIF extraction, badge result handling) |
|
||||
| `src/components/cloud/` | Cloud-related modals and widgets: ImageDetailModal, CloudEditModal, MapPickerModal, QuickUploadModal, MiniLocationMap |
|
||||
| `src/components/layout/` | AppHeader (top nav bar with auth state) |
|
||||
| `src/components/profile/` | ContributionHeatmap |
|
||||
| `src/style.css` | Global visual system hooks: shared button/card utility classes, base typography, body background |
|
||||
| `src/views/` | Route-level page components (see Routes below) |
|
||||
| `src/types/` | TypeScript types: database models (`database.ts`), AMap declarations (`amap.d.ts`), router meta (`router.d.ts`) |
|
||||
| `src/types/` | TypeScript types: domain models (`database.ts`), API DTOs (`api.ts`), AMap declarations (`amap.d.ts`), router meta (`router.d.ts`) |
|
||||
|
||||
## Routes
|
||||
|
||||
| Path | View | Auth Required |
|
||||
| --- | --- | :---: |
|
||||
| `/` | MapView | No |
|
||||
| `/login`, `/register`, `/auth/confirm`, `/auth/reset-password` | Auth views | No |
|
||||
| `/login`, `/register`, `/forgot-password`, `/auth/confirm`, `/auth/reset-password` | Auth views | No |
|
||||
| `/upload` | UploadView | Yes |
|
||||
| `/encyclopedia` | EncyclopediaView | No |
|
||||
| `/encyclopedia/:id` | CloudTypeView | No |
|
||||
| `/encyclopedia` | EncyclopediaView | Yes |
|
||||
| `/encyclopedia/:id` | CloudTypeView | Yes |
|
||||
| `/gallery` | GalleryView | No |
|
||||
| `/community` | CommunityView | No |
|
||||
| `/profile` | ProfileView (own) | Yes |
|
||||
@@ -46,39 +46,52 @@
|
||||
| `/profile/:id` | ProfileView (public) | No |
|
||||
| `/admin` | AdminView | Yes (admin) |
|
||||
| `/403` | ForbiddenView | No |
|
||||
| `/401` | AuthRequiredView | No |
|
||||
| `/:pathMatch(.*)*` | NotFoundView | No |
|
||||
|
||||
- Route guards in `router/index.ts`: `requiresAuth` redirects to `/login`, `requiresAdmin` redirects to `/403`
|
||||
- Route guards in `router/index.ts`: `requiresAuth` redirects to `/401` with the intended URL in the `redirect` query; `requiresAdmin` redirects to `/403`
|
||||
- SEO meta tags applied per-route via `lib/seo.ts` in `router.afterEach`
|
||||
|
||||
## Auth Flow
|
||||
|
||||
- **`main.ts` initializes auth before mounting**: `authStore.initialize()` is called, which calls `getSession()` and subscribes to `onAuthStateChange`. The app only mounts after the initial session check completes.
|
||||
- **Login sets `user.value` explicitly**: `login()` extracts `data.user` from `signInWithPassword` and assigns it to the store directly, rather than relying on `onAuthStateChange`.
|
||||
- **Profile is auto-created by DB trigger** (`handle_new_user` on `auth.users`), not by the frontend.
|
||||
- Auth error messages are translated to Chinese in the store.
|
||||
- `register()` checks username uniqueness before calling `signUp()` — username is passed via `options.data.username`.
|
||||
- **`main.ts` initializes auth before mounting**: `authStore.initialize()` exchanges the HttpOnly refresh cookie for an access token. The app only mounts after this initial check.
|
||||
- Access tokens live in memory only. Refresh cookies are managed by the backend and sent with `credentials: 'include'`.
|
||||
- `src/lib/api.ts` performs one automatic refresh-and-retry after an authenticated request receives `401`.
|
||||
- API auth state changes are synchronized back to the Pinia store through `opencloud:auth-updated` and `opencloud:auth-expired` window events.
|
||||
- Email confirmation and password reset pages consume the opaque `token` query parameter generated by the backend.
|
||||
- Passwords must contain at least 8 characters in registration, settings, and reset flows.
|
||||
- Registration, email confirmation, login, password reset, profile creation, and uniqueness checks are backend responsibilities.
|
||||
|
||||
## Stores
|
||||
|
||||
- **auth** — User session, profile, login/register/logout, username/password update, password reset. `initialize()` must be called before app mounts.
|
||||
- **clouds** — Simple cache of `cloud_types` table. Fetched once, shared across views.
|
||||
- **clouds** — Simple cache of cloud types returned by `GET /cloud-types`. Fetched once and shared across views.
|
||||
- **encyclopedia** — Cloud types + user's collection (unlock state). Tracks `unlockPercent` for progress display. Depends on `authStore`.
|
||||
- **profile** — User profile pages. Fetches profile + cloud list per-user. Supports update/delete/visibility-toggle with optimistic cache patching. `deleteClouds` also removes from Supabase Storage.
|
||||
- **profile** — User profile pages. Fetches profile + cloud list per-user. Supports update/delete/visibility-toggle with optimistic cache patching; backend deletion also removes stored files.
|
||||
|
||||
## Supabase
|
||||
## FastAPI Backend
|
||||
|
||||
- Env var is `VITE_SUPABASE_PUBLISHABLE_KEY` (not `VITE_SUPABASE_ANON_KEY`), using Supabase's `sb_publishable_` key format.
|
||||
- All tables have RLS enabled. Check `plan.md` section 10 for the full schema and RLS policies.
|
||||
- Storage bucket `clouds` is public read, authenticated upload.
|
||||
- Profile `role` field (`user`/`admin`) controls admin access — checked in route guard, not in JWT metadata.
|
||||
- `user_collections` tracks encyclopedia unlocks with `first_cloud_id` FK to `clouds`.
|
||||
- Backend project lives in the sibling directory `../opencloud-backend`.
|
||||
- Default development API URL is `http://localhost:8000/api/v1`; set `VITE_API_URL` for deployed environments.
|
||||
- Frontend route guards are for navigation UX only. The backend enforces ownership, authentication, admin roles, and disabled-account rules.
|
||||
- Images are uploaded as multipart form data. The backend stores originals, creates thumbnails, blurs coordinates, writes database records, and atomically unlocks badges.
|
||||
|
||||
## API Conventions
|
||||
|
||||
- All requests must go through `src/lib/api.ts`; do not call `fetch` directly from views, stores, or composables.
|
||||
- API paths passed to `apiRequest()` are relative to `VITE_API_URL`, for example `/clouds` rather than `/api/v1/clouds`.
|
||||
- Public requests explicitly use `{ auth: false }`. Authenticated requests use the default and receive a Bearer access token.
|
||||
- JSON request bodies are plain objects. Image uploads use `FormData`; do not set the multipart `Content-Type` header manually.
|
||||
- FastAPI errors use `detail`; `ApiError` converts string and validation-array details into a user-facing message.
|
||||
- Backend DTO fields use `snake_case`. Page/store adapters may expose display-only camelCase fields such as `cloudTypeName`.
|
||||
- Paginated endpoints return `items`, `page`, `page_size`, `total`, and `total_pages`.
|
||||
- Cloud batch mutation endpoints accept at most 100 IDs. Profile and admin code split larger selections into chunks of 100.
|
||||
|
||||
## Gallery Pagination
|
||||
|
||||
- Page-based navigation (50 items/page), not infinite scroll.
|
||||
- `resolveSearchFilters()` pre-fetches user IDs or cloud type IDs before the main query.
|
||||
- `buildFilteredQuery()` returns a chainable query builder; `loadPage()` forks it into a `count` query and a `data` query that run in parallel via `Promise.all`.
|
||||
- The backend resolves cloud-type/custom-type searches and `@username` searches.
|
||||
- Each response includes `items`, `total`, `page`, `page_size`, and `total_pages`.
|
||||
- Search debounced at 250ms.
|
||||
|
||||
## Map Timeline
|
||||
@@ -89,33 +102,29 @@
|
||||
|
||||
## Upload Flow
|
||||
|
||||
- `useUpload` composable handles: file selection (drag/drop/click), client-side thumbnail generation (JPEG, max 640px edge, 0.72 quality), EXIF date extraction, coordinate blurring (2 decimal places), Supabase Storage upload (original + thumbnail), DB insert with `status: 'pending'`, badge unlock detection via `user_collections` upsert.
|
||||
- `useUpload` handles file selection, previews, EXIF date extraction, validation, sequential multipart uploads, progress, and badge results. Thumbnail generation, coordinate blurring, storage, database insertion, and badge unlocking run on the backend.
|
||||
- Batch items upload sequentially. If a later item fails, earlier successful uploads remain saved; there is no transactional rollback or resumable upload in the frontend.
|
||||
- `UploadView` is the full-page batch uploader. `QuickUploadModal` is a single-image shortcut from the map page.
|
||||
|
||||
## Build & Deploy
|
||||
|
||||
- **Vercel** with `vercel.json`: SPA rewrites, security headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy). No CSP or HSTS configured.
|
||||
- **SEO plugin** in `vite.config.ts`: generates `robots.txt` and `sitemap.xml` at build time. Disallows `/admin`, `/auth/`, `/login`, `/register`, `/upload`, `/profile/settings`.
|
||||
- Vite dev and preview servers both use strict port `5173`, matching the backend's default `CORS_ORIGINS` and `FRONTEND_URL`.
|
||||
- **SEO plugin** in `vite.config.ts`: generates `robots.txt` and `sitemap.xml` at build time. Auth-only routes, including `/encyclopedia`, are excluded from the sitemap and disallowed in `robots.txt`.
|
||||
- **`lib/canvas.ts`**: patches `HTMLCanvasElement.getContext('2d')` to always pass `willReadFrequently: true` — needed for the badge card renderer in `lib/cloudBadges.ts`.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Required (app won't start without):
|
||||
|
||||
- `VITE_SUPABASE_URL`
|
||||
- `VITE_SUPABASE_PUBLISHABLE_KEY`
|
||||
|
||||
Required for map features:
|
||||
|
||||
- `VITE_AMAP_KEY`
|
||||
|
||||
Optional:
|
||||
|
||||
- `VITE_SITE_URL` — canonical URL for SEO meta and sitemap (falls back to Vercel env or hardcoded default)
|
||||
- `VITE_API_URL` — FastAPI base URL including `/api/v1` (defaults to `http://localhost:8000/api/v1`)
|
||||
- `VITE_SITE_URL` — canonical frontend URL used by route SEO and generated sitemap; set it explicitly in deployed environments
|
||||
|
||||
Future (not in `.env`):
|
||||
|
||||
- `OPENAI_API_KEY`, `OPENWEATHERMAP_API_KEY`
|
||||
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.
|
||||
|
||||
## Naive UI
|
||||
|
||||
@@ -144,9 +153,9 @@ Future (not in `.env`):
|
||||
- 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.
|
||||
|
||||
## MVP Constraints (from plan.md)
|
||||
## MVP Constraints
|
||||
|
||||
- No Supabase Realtime — refresh-based loading
|
||||
- No realtime updates — refresh-based loading
|
||||
- No OAuth — email/password only, email confirmation required
|
||||
- No AI cloud identification — manual type selection
|
||||
- AMap only (China-focused), no Mapbox fallback yet
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
# Vue 3 + TypeScript + Vite
|
||||
# OpenCloud 前端
|
||||
|
||||
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
|
||||
Vue 3 + TypeScript + Vite 前端,通过 FastAPI 提供认证、云图、图鉴、个人主页和管理后台数据。
|
||||
|
||||
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
|
||||
## 本地开发
|
||||
|
||||
1. 在相邻的 `opencloud-backend` 项目中启动 FastAPI 服务。
|
||||
2. 复制 `.env.example` 为 `.env`,配置高德地图 Key;如果后端不在默认地址,修改 `VITE_API_URL`。
|
||||
3. 安装依赖并启动前端:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
默认后端地址为 `http://localhost:8000/api/v1`。
|
||||
开发服务器和本地 Preview 都固定使用 `http://localhost:5173`,与后端默认 CORS 配置保持一致。运行 Preview 前请先停止开发服务器。
|
||||
|
||||
## 检查与构建
|
||||
|
||||
```bash
|
||||
npx vue-tsc -b
|
||||
npm run build
|
||||
```
|
||||
|
||||
Generated
+84
-176
@@ -9,7 +9,6 @@
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||
"@supabase/supabase-js": "^2.106.1",
|
||||
"@vercel/speed-insights": "^2.0.0",
|
||||
"naive-ui": "^2.44.1",
|
||||
"pinia": "^3.0.4",
|
||||
@@ -17,14 +16,14 @@
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^24.12.3",
|
||||
"@vercel/analytics": "^2.0.1",
|
||||
"@vicons/tabler": "^0.13.0",
|
||||
"@vicons/utils": "^0.1.4",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.12",
|
||||
"vue-tsc": "^3.2.8"
|
||||
@@ -142,7 +141,7 @@
|
||||
},
|
||||
"node_modules/@jridgewell/gen-mapping": {
|
||||
"version": "0.3.13",
|
||||
"resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
|
||||
"integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -153,7 +152,7 @@
|
||||
},
|
||||
"node_modules/@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -164,7 +163,7 @@
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -180,7 +179,7 @@
|
||||
},
|
||||
"node_modules/@jridgewell/trace-mapping": {
|
||||
"version": "0.3.31",
|
||||
"resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
|
||||
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -506,134 +505,50 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@supabase/auth-js": {
|
||||
"version": "2.106.1",
|
||||
"resolved": "https://registry.npmmirror.com/@supabase/auth-js/-/auth-js-2.106.1.tgz",
|
||||
"integrity": "sha512-7eyheXfAGwkB9bZewJPs+N3UYt6kra2JG6mIxNEgbkvcO15PLD1e75PTIUEYYl3zrifm3GrpShVl7QZxKrXO/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/functions-js": {
|
||||
"version": "2.106.1",
|
||||
"resolved": "https://registry.npmmirror.com/@supabase/functions-js/-/functions-js-2.106.1.tgz",
|
||||
"integrity": "sha512-XbOPnR2mW7jp/EcW447xmGwCa+/Wc00Hkw8t4tUIJjRsHQ4xAESsLKcyLRhRJjJoUnJVXUlC+w0wUxUCM7CG2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/phoenix": {
|
||||
"version": "0.4.2",
|
||||
"resolved": "https://registry.npmmirror.com/@supabase/phoenix/-/phoenix-0.4.2.tgz",
|
||||
"integrity": "sha512-YSAGnmDAfuleFCVt3CeurQZAhxRfXWeZIIkwp7NhYzQ1UwW6ePSnzsFAiUm/mbCkfoCf70QQHKW/K6RKh52a4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@supabase/postgrest-js": {
|
||||
"version": "2.106.1",
|
||||
"resolved": "https://registry.npmmirror.com/@supabase/postgrest-js/-/postgrest-js-2.106.1.tgz",
|
||||
"integrity": "sha512-Qbn6d2lqiqeaBX1Uko0e/hL90dtQGRN6CG2wMVQtJpRFstlVW45qmUTyTOsiB8dYUWu1fWYo4YzJuDbokGv3tQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/realtime-js": {
|
||||
"version": "2.106.1",
|
||||
"resolved": "https://registry.npmmirror.com/@supabase/realtime-js/-/realtime-js-2.106.1.tgz",
|
||||
"integrity": "sha512-eQCYri5E8KsjpDgC7g28cOOS2britjUWdNSJluFMainqrMRepzjOnaxqXc3RoAz7H0dxmBrfLUNF6NGP8C+YaA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/phoenix": "^0.4.2",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/storage-js": {
|
||||
"version": "2.106.1",
|
||||
"resolved": "https://registry.npmmirror.com/@supabase/storage-js/-/storage-js-2.106.1.tgz",
|
||||
"integrity": "sha512-HWcLIhqinhWKpOQ3WzglR2unjW0eh9J7yOu3IZrZNIEkraK4La/HDvTqndljGsNw0itPtyHhuKBxRoPG1VUARw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iceberg-js": "^0.8.1",
|
||||
"tslib": "2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@supabase/supabase-js": {
|
||||
"version": "2.106.1",
|
||||
"resolved": "https://registry.npmmirror.com/@supabase/supabase-js/-/supabase-js-2.106.1.tgz",
|
||||
"integrity": "sha512-gP4HurGkGu7Z3xoOCjtAI17BKKp7jpsmwY0Ssbsks9XQRzJ7ZhK7LxfLdBSYgUdgZCQgjRK+Mr7+cl4Gxrk0Rw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@supabase/auth-js": "2.106.1",
|
||||
"@supabase/functions-js": "2.106.1",
|
||||
"@supabase/postgrest-js": "2.106.1",
|
||||
"@supabase/realtime-js": "2.106.1",
|
||||
"@supabase/storage-js": "2.106.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/@tailwindcss/node/-/node-4.3.0.tgz",
|
||||
"integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
|
||||
"integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"enhanced-resolve": "^5.21.0",
|
||||
"jiti": "^2.6.1",
|
||||
"enhanced-resolve": "^5.24.1",
|
||||
"jiti": "^2.7.0",
|
||||
"lightningcss": "1.32.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"source-map-js": "^1.2.1",
|
||||
"tailwindcss": "4.3.0"
|
||||
"tailwindcss": "4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide/-/oxide-4.3.0.tgz",
|
||||
"integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
|
||||
"integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tailwindcss/oxide-android-arm64": "4.3.0",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.3.0",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.3.0",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.3.0",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.0",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.3.0",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.3.0",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.3.0",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.3.0",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.0",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.3.0"
|
||||
"@tailwindcss/oxide-android-arm64": "4.3.3",
|
||||
"@tailwindcss/oxide-darwin-arm64": "4.3.3",
|
||||
"@tailwindcss/oxide-darwin-x64": "4.3.3",
|
||||
"@tailwindcss/oxide-freebsd-x64": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
|
||||
"@tailwindcss/oxide-linux-x64-musl": "4.3.3",
|
||||
"@tailwindcss/oxide-wasm32-wasi": "4.3.3",
|
||||
"@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
|
||||
"@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-android-arm64": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz",
|
||||
"integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
|
||||
"integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -648,9 +563,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-arm64": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz",
|
||||
"integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
|
||||
"integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -665,9 +580,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-darwin-x64": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz",
|
||||
"integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
|
||||
"integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -682,9 +597,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-freebsd-x64": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz",
|
||||
"integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
|
||||
"integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -699,9 +614,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz",
|
||||
"integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
|
||||
"integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -716,9 +631,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz",
|
||||
"integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
|
||||
"integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -736,9 +651,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-arm64-musl": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz",
|
||||
"integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
|
||||
"integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -756,9 +671,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-gnu": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz",
|
||||
"integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
|
||||
"integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -776,9 +691,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-linux-x64-musl": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz",
|
||||
"integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
|
||||
"integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -796,9 +711,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz",
|
||||
"integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
|
||||
"integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
|
||||
"bundleDependencies": [
|
||||
"@napi-rs/wasm-runtime",
|
||||
"@emnapi/core",
|
||||
@@ -814,11 +729,11 @@
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.10.0",
|
||||
"@emnapi/runtime": "^1.10.0",
|
||||
"@emnapi/wasi-threads": "^1.2.1",
|
||||
"@emnapi/core": "^1.11.1",
|
||||
"@emnapi/runtime": "^1.11.1",
|
||||
"@emnapi/wasi-threads": "^1.2.2",
|
||||
"@napi-rs/wasm-runtime": "^1.1.4",
|
||||
"@tybys/wasm-util": "^0.10.1",
|
||||
"@tybys/wasm-util": "^0.10.2",
|
||||
"tslib": "^2.8.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -826,9 +741,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz",
|
||||
"integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
|
||||
"integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -843,9 +758,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-x64-msvc": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz",
|
||||
"integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
|
||||
"integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -860,15 +775,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/vite": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/@tailwindcss/vite/-/vite-4.3.0.tgz",
|
||||
"integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
|
||||
"integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tailwindcss/node": "4.3.0",
|
||||
"@tailwindcss/oxide": "4.3.0",
|
||||
"tailwindcss": "4.3.0"
|
||||
"@tailwindcss/node": "4.3.3",
|
||||
"@tailwindcss/oxide": "4.3.3",
|
||||
"tailwindcss": "4.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^5.2.0 || ^6 || ^7 || ^8"
|
||||
@@ -1350,9 +1265,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.21.5",
|
||||
"resolved": "https://registry.npmmirror.com/enhanced-resolve/-/enhanced-resolve-5.21.5.tgz",
|
||||
"integrity": "sha512-mLCNbrQli11K1ySUmuNt4ZUB3OpGIDq4q2vTBTf5cL2lpsRjI9QKqSD0ndjW8FyvcW/Jj46gMe9syyHAsvMa/A==",
|
||||
"version": "5.24.2",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz",
|
||||
"integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1422,7 +1337,7 @@
|
||||
},
|
||||
"node_modules/graceful-fs": {
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
@@ -1442,15 +1357,6 @@
|
||||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/iceberg-js": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/is-what": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz",
|
||||
@@ -1988,15 +1894,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-4.3.0.tgz",
|
||||
"integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==",
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
|
||||
"integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tapable": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmmirror.com/tapable/-/tapable-2.3.3.tgz",
|
||||
"resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
|
||||
"integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
@@ -2035,7 +1941,9 @@
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "6.0.3",
|
||||
|
||||
+2
-3
@@ -10,7 +10,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||
"@supabase/supabase-js": "^2.106.1",
|
||||
"@vercel/speed-insights": "^2.0.0",
|
||||
"naive-ui": "^2.44.1",
|
||||
"pinia": "^3.0.4",
|
||||
@@ -18,14 +17,14 @@
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@types/node": "^24.12.3",
|
||||
"@vercel/analytics": "^2.0.1",
|
||||
"@vicons/tabler": "^0.13.0",
|
||||
"@vicons/utils": "^0.1.4",
|
||||
"@vitejs/plugin-vue": "^6.0.6",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.0.12",
|
||||
"vue-tsc": "^3.2.8"
|
||||
|
||||
+3
-15
@@ -1,17 +1,5 @@
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 16 16"
|
||||
shape-rendering="crispEdges"
|
||||
>
|
||||
<title>OpenCloud Pixel Favicon</title>
|
||||
<rect x="1" y="1" width="14" height="14" fill="#e0f2fe" />
|
||||
<rect x="1" y="1" width="14" height="1" fill="#0f172a" />
|
||||
<rect x="1" y="14" width="14" height="1" fill="#0f172a" />
|
||||
<rect x="1" y="2" width="1" height="12" fill="#0f172a" />
|
||||
<rect x="14" y="2" width="1" height="12" fill="#0f172a" />
|
||||
|
||||
<svg class="h-8 w-8" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" shape-rendering="crispEdges"
|
||||
aria-hidden="true">
|
||||
<rect x="11" y="3" width="1" height="1" fill="#f59e0b" />
|
||||
<rect x="10" y="4" width="3" height="1" fill="#fbbf24" />
|
||||
<rect x="11" y="5" width="1" height="1" fill="#f59e0b" />
|
||||
@@ -28,4 +16,4 @@
|
||||
<rect x="4" y="9" width="5" height="1" fill="#ffffff" />
|
||||
|
||||
<rect x="4" y="11" width="8" height="1" fill="#7dd3fc" opacity="0.55" />
|
||||
</svg>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 923 B |
@@ -7,18 +7,6 @@
|
||||
"skillPath": "skills/git-commit/SKILL.md",
|
||||
"computedHash": "2607fc60629b82b257136dd2a7a373f0a4466c0b49df7746d845d59313c99b21"
|
||||
},
|
||||
"supabase": {
|
||||
"source": "supabase/agent-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/supabase/SKILL.md",
|
||||
"computedHash": "1bb189e255c0e91161f14c618dce0eccf68174ceb963664cef7761b2f90cb466"
|
||||
},
|
||||
"supabase-postgres-best-practices": {
|
||||
"source": "supabase/agent-skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/supabase-postgres-best-practices/SKILL.md",
|
||||
"computedHash": "292c93e5a86e2429204bc37abe26b3c9023c4760eb02418462887f2082f118ce"
|
||||
},
|
||||
"web-design-reviewer": {
|
||||
"source": "github/awesome-copilot",
|
||||
"sourceType": "github",
|
||||
|
||||
@@ -13,7 +13,7 @@ const isMapRoute = computed(() => route.name === 'map')
|
||||
const headerHidden = ref(false)
|
||||
const headerPinnedOpen = ref(false)
|
||||
const accountCardOpen = ref(false)
|
||||
const activeNavClass = 'bg-teal-100 text-teal-800 ring-1 ring-teal-200'
|
||||
const activeNavClass = 'bg-teal-50 text-teal-800 ring-1 ring-teal-100'
|
||||
const inactiveNavClass = 'text-slate-600 hover:bg-teal-50 hover:text-teal-800'
|
||||
|
||||
const displayUsername = computed(() => authStore.profile?.username || authStore.user?.email || 'OpenCloud 用户')
|
||||
@@ -193,6 +193,7 @@ async function handleLogout() {
|
||||
画廊
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="authStore.isLoggedIn"
|
||||
to="/encyclopedia"
|
||||
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"
|
||||
@@ -254,10 +255,10 @@ async function handleLogout() {
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center gap-2 text-xs text-slate-500">
|
||||
<span class="border border-teal-100 bg-teal-50 px-2 py-1 text-teal-700">
|
||||
<!-- <span class="border border-teal-100 bg-teal-50 px-2 py-1 text-teal-700">
|
||||
{{ authStore.isAdmin ? '管理员' : '观测者' }}
|
||||
</span>
|
||||
<span>OpenCloud 账号</span>
|
||||
<span>OpenCloud 账号</span> -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -312,7 +313,7 @@ async function handleLogout() {
|
||||
>
|
||||
<span
|
||||
class="inline-flex h-8 items-center border border-slate-200 bg-white/80 px-3 text-sm font-medium text-slate-700 shadow-[3px_3px_0_0_rgba(15,23,42,0.06)] transition-[background-color,color,transform,box-shadow,border-color] hover:-translate-x-px hover:-translate-y-px hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800 hover:shadow-[4px_4px_0_0_rgba(15,23,42,0.08)] md:h-10 md:px-4"
|
||||
:class="route.name === 'login' ? 'bg-teal-50 ring-1 ring-teal-200 text-teal-800' : ''"
|
||||
:class="route.name === 'login' ? 'bg-teal-50 ring-0 ring-teal-100 text-teal-800' : ''"
|
||||
>
|
||||
登录
|
||||
</span>
|
||||
@@ -323,7 +324,7 @@ async function handleLogout() {
|
||||
>
|
||||
<span
|
||||
class="inline-flex h-8 items-center border border-sky-200 bg-sky-100 px-3 text-sm font-medium text-sky-800 shadow-[4px_4px_0_0_rgba(14,165,233,0.14)] transition-[background-color,color,transform,box-shadow] hover:-translate-x-px hover:-translate-y-px hover:bg-sky-50 hover:text-sky-900 hover:shadow-[5px_5px_0_0_rgba(14,165,233,0.18)] md:h-10 md:px-4"
|
||||
:class="route.name === 'register' ? 'ring-1 ring-sky-300' : ''"
|
||||
:class="route.name === 'register' ? 'ring-0 ring-sky-300' : ''"
|
||||
>
|
||||
注册
|
||||
</span>
|
||||
@@ -356,6 +357,7 @@ async function handleLogout() {
|
||||
画廊
|
||||
</RouterLink>
|
||||
<RouterLink
|
||||
v-if="authStore.isLoggedIn"
|
||||
to="/encyclopedia"
|
||||
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
||||
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? activeNavClass : inactiveNavClass"
|
||||
|
||||
+80
-285
@@ -1,6 +1,8 @@
|
||||
import { ref } from 'vue'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useProfileStore } from '@/stores/profile'
|
||||
import type { CloudCreateResponse } from '@/types/api'
|
||||
import type { CloudType } from '@/types/database'
|
||||
|
||||
export interface UploadItem {
|
||||
@@ -32,8 +34,6 @@ export interface UploadResult {
|
||||
}
|
||||
|
||||
let nextId = 0
|
||||
const THUMBNAIL_MAX_EDGE = 640
|
||||
const THUMBNAIL_QUALITY = 0.72
|
||||
|
||||
function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -44,84 +44,6 @@ function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
||||
})
|
||||
}
|
||||
|
||||
function loadImageElement(file: File): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const objectUrl = URL.createObjectURL(file)
|
||||
const image = new Image()
|
||||
|
||||
image.onload = () => {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
resolve(image)
|
||||
}
|
||||
|
||||
image.onerror = () => {
|
||||
URL.revokeObjectURL(objectUrl)
|
||||
reject(new Error('图片读取失败'))
|
||||
}
|
||||
|
||||
image.src = objectUrl
|
||||
})
|
||||
}
|
||||
|
||||
function getResizedDimensions(width: number, height: number, maxEdge: number) {
|
||||
const largestEdge = Math.max(width, height)
|
||||
if (largestEdge <= maxEdge) {
|
||||
return { width, height }
|
||||
}
|
||||
|
||||
const scale = maxEdge / largestEdge
|
||||
return {
|
||||
width: Math.round(width * scale),
|
||||
height: Math.round(height * scale),
|
||||
}
|
||||
}
|
||||
|
||||
function fileBaseName(name: string) {
|
||||
const idx = name.lastIndexOf('.')
|
||||
return idx === -1 ? name : name.slice(0, idx)
|
||||
}
|
||||
|
||||
async function renderJpegFile(
|
||||
image: HTMLImageElement,
|
||||
sourceFile: File,
|
||||
maxEdge: number,
|
||||
quality: number,
|
||||
suffix = '',
|
||||
) {
|
||||
const { width, height } = getResizedDimensions(image.naturalWidth || image.width, image.naturalHeight || image.height, maxEdge)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = width
|
||||
canvas.height = height
|
||||
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) {
|
||||
throw new Error('图片压缩失败')
|
||||
}
|
||||
|
||||
context.drawImage(image, 0, 0, width, height)
|
||||
|
||||
const blob = await new Promise<Blob | null>(resolve => {
|
||||
canvas.toBlob(resolve, 'image/jpeg', quality)
|
||||
})
|
||||
|
||||
if (!blob) {
|
||||
throw new Error('图片压缩失败')
|
||||
}
|
||||
|
||||
return new File([blob], `${fileBaseName(sourceFile.name)}${suffix}.jpg`, {
|
||||
type: 'image/jpeg',
|
||||
lastModified: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
async function createUploadAssets(file: File) {
|
||||
const image = await loadImageElement(file)
|
||||
|
||||
return {
|
||||
thumbnailFile: await renderJpegFile(image, file, THUMBNAIL_MAX_EDGE, THUMBNAIL_QUALITY, '-thumb'),
|
||||
}
|
||||
}
|
||||
|
||||
function extractExifDate(buffer: ArrayBuffer): string | null {
|
||||
const view = new DataView(buffer)
|
||||
if (view.getUint16(0, false) !== 0xffd8) return null
|
||||
@@ -146,23 +68,20 @@ function extractExifDate(buffer: ArrayBuffer): string | null {
|
||||
|
||||
const tiffStart = exifStart + 6
|
||||
const isLE = view.getUint16(tiffStart, false) === 0x4949
|
||||
const ifd0Offset = view.getUint32(tiffStart + 4, isLE)
|
||||
const ifdStart = tiffStart + ifd0Offset
|
||||
const ifdStart = tiffStart + view.getUint32(tiffStart + 4, isLE)
|
||||
const numEntries = view.getUint16(ifdStart, isLE)
|
||||
|
||||
for (let i = 0; i < numEntries; i++) {
|
||||
const entryStart = ifdStart + 2 + i * 12
|
||||
if (entryStart + 12 > buffer.byteLength) break
|
||||
const tag = view.getUint16(entryStart, isLE)
|
||||
if (tag === 0x9003) {
|
||||
const dataOffset = view.getUint32(entryStart + 8, isLE)
|
||||
const dateStr = Array.from({ length: 20 }, (_, j) =>
|
||||
String.fromCharCode(view.getUint8(tiffStart + dataOffset + j)),
|
||||
).join('')
|
||||
const iso = dateStr.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3')
|
||||
const d = new Date(iso)
|
||||
if (!isNaN(d.getTime())) return d.toISOString()
|
||||
}
|
||||
if (view.getUint16(entryStart, isLE) !== 0x9003) continue
|
||||
|
||||
const dataOffset = view.getUint32(entryStart + 8, isLE)
|
||||
const dateStr = Array.from({ length: 20 }, (_, j) =>
|
||||
String.fromCharCode(view.getUint8(tiffStart + dataOffset + j)),
|
||||
).join('')
|
||||
const date = new Date(dateStr.replace(/^(\d{4}):(\d{2}):(\d{2})/, '$1-$2-$3'))
|
||||
if (!isNaN(date.getTime())) return date.toISOString()
|
||||
}
|
||||
}
|
||||
offset += length - 2
|
||||
@@ -170,51 +89,12 @@ function extractExifDate(buffer: ArrayBuffer): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
async function fetchUnlockedTypeIds(userId: string) {
|
||||
const { data, error } = await supabase
|
||||
.from('user_collections')
|
||||
.select('cloud_type_id')
|
||||
.eq('user_id', userId)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
return new Set(
|
||||
(data || [])
|
||||
.map(row => row.cloud_type_id)
|
||||
.filter((cloudTypeId): cloudTypeId is number => typeof cloudTypeId === 'number'),
|
||||
)
|
||||
}
|
||||
|
||||
async function fetchBadgeDetails(unlockedRows: Array<{ cloudTypeId: number; unlockedAt: string }>) {
|
||||
const ids = unlockedRows.map(item => item.cloudTypeId)
|
||||
const { data, error } = await supabase
|
||||
.from('cloud_types')
|
||||
.select('id,name,name_en,rarity')
|
||||
.in('id', ids)
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const typeMap = new Map(
|
||||
((data || []) as Array<Pick<CloudType, 'id' | 'name' | 'name_en' | 'rarity'>>).map(item => [item.id, item]),
|
||||
)
|
||||
|
||||
return unlockedRows
|
||||
.map(item => {
|
||||
const cloudType = typeMap.get(item.cloudTypeId)
|
||||
if (!cloudType) return null
|
||||
|
||||
return {
|
||||
cloudTypeId: cloudType.id,
|
||||
cloudName: cloudType.name,
|
||||
cloudNameEn: cloudType.name_en,
|
||||
rarity: cloudType.rarity,
|
||||
unlockedAt: item.unlockedAt,
|
||||
} satisfies UnlockedBadge
|
||||
})
|
||||
.filter((item): item is UnlockedBadge => item !== null)
|
||||
function appendOptional(form: FormData, key: string, value: string | number | null) {
|
||||
if (value !== null && value !== '') form.append(key, String(value))
|
||||
}
|
||||
|
||||
export function useUpload() {
|
||||
const authStore = useAuthStore()
|
||||
const profileStore = useProfileStore()
|
||||
const items = ref<UploadItem[]>([])
|
||||
const uploading = ref(false)
|
||||
@@ -223,15 +103,14 @@ export function useUpload() {
|
||||
const totalItems = ref(0)
|
||||
|
||||
async function addFiles(files: File[]) {
|
||||
const imageFiles = files.filter(f => f.type.startsWith('image/'))
|
||||
const imageFiles = files.filter(file => file.type.startsWith('image/'))
|
||||
for (const file of imageFiles) {
|
||||
let capturedAt = new Date().toISOString()
|
||||
try {
|
||||
const buffer = await readFileAsArrayBuffer(file)
|
||||
const exifDate = extractExifDate(buffer)
|
||||
const exifDate = extractExifDate(await readFileAsArrayBuffer(file))
|
||||
if (exifDate) capturedAt = exifDate
|
||||
} catch {
|
||||
// fallback to now
|
||||
// 无 EXIF 时使用当前时间。
|
||||
}
|
||||
|
||||
items.value.push({
|
||||
@@ -252,182 +131,86 @@ export function useUpload() {
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
const idx = items.value.findIndex(i => i.id === id)
|
||||
if (idx !== -1) {
|
||||
URL.revokeObjectURL(items.value[idx].preview)
|
||||
items.value.splice(idx, 1)
|
||||
}
|
||||
const index = items.value.findIndex(item => item.id === id)
|
||||
if (index === -1) return
|
||||
URL.revokeObjectURL(items.value[index].preview)
|
||||
items.value.splice(index, 1)
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
for (const item of items.value) {
|
||||
URL.revokeObjectURL(item.preview)
|
||||
}
|
||||
items.value.forEach(item => URL.revokeObjectURL(item.preview))
|
||||
items.value = []
|
||||
}
|
||||
|
||||
function validateItem(item: UploadItem): boolean {
|
||||
function validateItem(item: UploadItem) {
|
||||
const errors: Record<string, string> = {}
|
||||
if (!item.cloudCategoryId) {
|
||||
errors.cloudCategory = '请选择类别'
|
||||
}
|
||||
if (!item.cloudCategoryId) errors.cloudCategory = '请选择类别'
|
||||
if (item.cloudCategoryId === 'other' && !item.customCloudType.trim()) {
|
||||
errors.cloudCategory = '请输入自定义云型名称'
|
||||
}
|
||||
if (!item.capturedAt) {
|
||||
errors.capturedAt = '请选择拍摄时间'
|
||||
}
|
||||
if (!item.capturedAt) errors.capturedAt = '请选择拍摄时间'
|
||||
|
||||
const hasLat = typeof item.latitude === 'number' && !isNaN(item.latitude)
|
||||
const hasLng = typeof item.longitude === 'number' && !isNaN(item.longitude)
|
||||
if (hasLat && !hasLng) {
|
||||
errors.longitude = '经纬度必须同时填写'
|
||||
}
|
||||
if (hasLng && !hasLat) {
|
||||
errors.latitude = '经纬度必须同时填写'
|
||||
}
|
||||
if (hasLat && !hasLng) errors.longitude = '经纬度必须同时填写'
|
||||
if (hasLng && !hasLat) errors.latitude = '经纬度必须同时填写'
|
||||
item.errors = errors
|
||||
return Object.keys(errors).length === 0
|
||||
}
|
||||
|
||||
function validateAll(): boolean {
|
||||
let allValid = true
|
||||
for (const item of items.value) {
|
||||
if (!validateItem(item)) {
|
||||
allValid = false
|
||||
}
|
||||
}
|
||||
return allValid
|
||||
}
|
||||
|
||||
function blurCoordinate(value: number): number {
|
||||
return Math.round(value * 100) / 100
|
||||
function validateAll() {
|
||||
return items.value.map(validateItem).every(Boolean)
|
||||
}
|
||||
|
||||
async function uploadAll(): Promise<UploadResult> {
|
||||
if (!validateAll()) {
|
||||
return { ok: false, unlockedBadges: [] }
|
||||
}
|
||||
if (!validateAll() || !authStore.user) return { ok: false, unlockedBadges: [] }
|
||||
|
||||
uploading.value = true
|
||||
totalItems.value = items.value.length
|
||||
currentItemIndex.value = 0
|
||||
overallProgress.value = 0
|
||||
|
||||
const userId = (await supabase.auth.getUser()).data.user?.id
|
||||
if (!userId) {
|
||||
uploading.value = false
|
||||
return { ok: false, unlockedBadges: [] }
|
||||
}
|
||||
const badges = new Map<number, UnlockedBadge>()
|
||||
|
||||
try {
|
||||
let unlockedTypeIds = new Set<number>()
|
||||
try {
|
||||
unlockedTypeIds = await fetchUnlockedTypeIds(userId)
|
||||
} catch {
|
||||
unlockedTypeIds = new Set<number>()
|
||||
}
|
||||
for (let index = 0; index < items.value.length; index++) {
|
||||
const item = items.value[index]
|
||||
currentItemIndex.value = index + 1
|
||||
overallProgress.value = Math.round(index / items.value.length * 100)
|
||||
|
||||
const newlyUnlockedRows: Array<{ cloudTypeId: number; unlockedAt: string }> = []
|
||||
|
||||
for (let i = 0; i < items.value.length; i++) {
|
||||
const item = items.value[i]
|
||||
currentItemIndex.value = i + 1
|
||||
|
||||
overallProgress.value = Math.round(i / items.value.length * 100)
|
||||
|
||||
const basePath = `${userId}/${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
const ext = item.file.name.split('.').pop() || 'jpg'
|
||||
const imagePath = `${basePath}.${ext}`
|
||||
const thumbnailPath = `${basePath}-thumb.jpg`
|
||||
const { thumbnailFile } = await createUploadAssets(item.file)
|
||||
|
||||
const { error: imgError } = await supabase.storage
|
||||
.from('clouds')
|
||||
.upload(imagePath, item.file, {
|
||||
upsert: false,
|
||||
contentType: item.file.type,
|
||||
})
|
||||
if (imgError) throw imgError
|
||||
|
||||
const { error: thumbError } = await supabase.storage
|
||||
.from('clouds')
|
||||
.upload(thumbnailPath, thumbnailFile, {
|
||||
upsert: false,
|
||||
contentType: thumbnailFile.type,
|
||||
})
|
||||
if (thumbError) throw thumbError
|
||||
|
||||
overallProgress.value = Math.round((i + 0.5) / items.value.length * 100)
|
||||
|
||||
const { data: { publicUrl: imageUrl } } = supabase.storage.from('clouds').getPublicUrl(imagePath)
|
||||
const { data: { publicUrl: thumbnailUrl } } = supabase.storage.from('clouds').getPublicUrl(thumbnailPath)
|
||||
|
||||
const latitude = item.latitude ? blurCoordinate(item.latitude) : null
|
||||
const longitude = item.longitude ? blurCoordinate(item.longitude) : null
|
||||
|
||||
const { data: insertedCloud, error: dbError } = await supabase
|
||||
.from('clouds')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
cloud_type_id: item.cloudCategoryId === 'other' ? null : item.cloudCategoryId,
|
||||
custom_cloud_type: item.cloudCategoryId === 'other' ? (item.customCloudType.trim() || null) : null,
|
||||
image_url: imageUrl,
|
||||
thumbnail_url: thumbnailUrl,
|
||||
latitude,
|
||||
longitude,
|
||||
location_name: item.locationName || null,
|
||||
description: item.description.trim() || null,
|
||||
captured_at: item.capturedAt,
|
||||
status: 'pending',
|
||||
is_hidden: item.isHidden,
|
||||
})
|
||||
.select('id,cloud_type_id')
|
||||
.single()
|
||||
if (dbError) throw dbError
|
||||
|
||||
if (
|
||||
insertedCloud &&
|
||||
typeof insertedCloud.cloud_type_id === 'number' &&
|
||||
!unlockedTypeIds.has(insertedCloud.cloud_type_id)
|
||||
) {
|
||||
try {
|
||||
const { data: collectionRow, error: collectionError } = await supabase
|
||||
.from('user_collections')
|
||||
.insert({
|
||||
user_id: userId,
|
||||
cloud_type_id: insertedCloud.cloud_type_id,
|
||||
first_cloud_id: insertedCloud.id,
|
||||
})
|
||||
.select('cloud_type_id,unlocked_at')
|
||||
.single()
|
||||
|
||||
if (collectionError) {
|
||||
const duplicate = collectionError.code === '23505'
|
||||
if (!duplicate) throw collectionError
|
||||
} else if (collectionRow) {
|
||||
unlockedTypeIds.add(collectionRow.cloud_type_id)
|
||||
newlyUnlockedRows.push({
|
||||
cloudTypeId: collectionRow.cloud_type_id,
|
||||
unlockedAt: collectionRow.unlocked_at,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
// Ignore collection sync failures so uploads can still complete.
|
||||
}
|
||||
const form = new FormData()
|
||||
form.append('image', item.file)
|
||||
if (item.cloudCategoryId === 'other') {
|
||||
form.append('custom_cloud_type', item.customCloudType.trim())
|
||||
} else {
|
||||
form.append('cloud_type_id', String(item.cloudCategoryId))
|
||||
}
|
||||
appendOptional(form, 'latitude', item.latitude)
|
||||
appendOptional(form, 'longitude', item.longitude)
|
||||
appendOptional(form, 'location_name', item.locationName.trim())
|
||||
appendOptional(form, 'description', item.description.trim())
|
||||
appendOptional(form, 'captured_at', item.capturedAt)
|
||||
form.append('is_hidden', String(item.isHidden))
|
||||
|
||||
overallProgress.value = Math.round((i + 1) / items.value.length * 100)
|
||||
const result = await apiRequest<CloudCreateResponse>('/clouds', {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
})
|
||||
if (result.unlocked_badge) {
|
||||
const badge = result.unlocked_badge
|
||||
badges.set(badge.cloud_type_id, {
|
||||
cloudTypeId: badge.cloud_type_id,
|
||||
cloudName: badge.cloud_name,
|
||||
cloudNameEn: badge.cloud_name_en,
|
||||
rarity: badge.rarity,
|
||||
unlockedAt: badge.unlocked_at,
|
||||
})
|
||||
}
|
||||
overallProgress.value = Math.round((index + 1) / items.value.length * 100)
|
||||
}
|
||||
|
||||
for (const item of items.value) {
|
||||
URL.revokeObjectURL(item.preview)
|
||||
}
|
||||
items.value = []
|
||||
profileStore.invalidateUser(userId)
|
||||
return {
|
||||
ok: true,
|
||||
unlockedBadges: newlyUnlockedRows.length ? await fetchBadgeDetails(newlyUnlockedRows) : [],
|
||||
}
|
||||
clearAll()
|
||||
profileStore.invalidateUser(authStore.user.id)
|
||||
return { ok: true, unlockedBadges: [...badges.values()] }
|
||||
} catch {
|
||||
return { ok: false, unlockedBadges: [] }
|
||||
} finally {
|
||||
@@ -435,5 +218,17 @@ export function useUpload() {
|
||||
}
|
||||
}
|
||||
|
||||
return { items, uploading, overallProgress, currentItemIndex, totalItems, addFiles, removeItem, clearAll, validateItem, validateAll, uploadAll }
|
||||
return {
|
||||
items,
|
||||
uploading,
|
||||
overallProgress,
|
||||
currentItemIndex,
|
||||
totalItems,
|
||||
addFiles,
|
||||
removeItem,
|
||||
clearAll,
|
||||
validateItem,
|
||||
validateAll,
|
||||
uploadAll,
|
||||
}
|
||||
}
|
||||
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
import type { AuthResponse } from '@/types/api'
|
||||
|
||||
const DEFAULT_API_URL = 'http://localhost:8000/api/v1'
|
||||
|
||||
export const API_URL = (import.meta.env.VITE_API_URL || DEFAULT_API_URL).replace(/\/$/, '')
|
||||
export const AUTH_UPDATED_EVENT = 'opencloud:auth-updated'
|
||||
export const AUTH_EXPIRED_EVENT = 'opencloud:auth-expired'
|
||||
|
||||
let accessToken: string | null = null
|
||||
let refreshPromise: Promise<AuthResponse> | null = null
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
detail: unknown
|
||||
|
||||
constructor(message: string, status: number, detail?: unknown) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.detail = detail
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
|
||||
body?: BodyInit | Record<string, unknown> | null
|
||||
auth?: boolean
|
||||
retry?: boolean
|
||||
}
|
||||
|
||||
function isJsonBody(body: ApiRequestOptions['body']): body is Record<string, unknown> {
|
||||
return !!body
|
||||
&& typeof body === 'object'
|
||||
&& !(body instanceof FormData)
|
||||
&& !(body instanceof Blob)
|
||||
&& !(body instanceof URLSearchParams)
|
||||
&& !(body instanceof ArrayBuffer)
|
||||
}
|
||||
|
||||
function detailToMessage(detail: unknown, fallback: string) {
|
||||
if (typeof detail === 'string') return detail
|
||||
if (Array.isArray(detail)) {
|
||||
const messages = detail
|
||||
.map(item => item && typeof item === 'object' && 'msg' in item ? String(item.msg) : '')
|
||||
.filter(Boolean)
|
||||
if (messages.length) return messages.join(';')
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
async function toApiError(response: Response) {
|
||||
let detail: unknown
|
||||
try {
|
||||
const payload = await response.json() as { detail?: unknown }
|
||||
detail = payload.detail
|
||||
} catch {
|
||||
detail = undefined
|
||||
}
|
||||
|
||||
return new ApiError(
|
||||
detailToMessage(detail, `请求失败(${response.status})`),
|
||||
response.status,
|
||||
detail,
|
||||
)
|
||||
}
|
||||
|
||||
export function setAccessToken(token: string | null) {
|
||||
accessToken = token
|
||||
}
|
||||
|
||||
export function clearAccessToken() {
|
||||
accessToken = null
|
||||
}
|
||||
|
||||
async function request<T>(path: string, options: ApiRequestOptions = {}) {
|
||||
const { body, auth = true, retry = true, ...requestOptions } = options
|
||||
const headers = new Headers(requestOptions.headers)
|
||||
let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined
|
||||
|
||||
if (isJsonBody(body)) {
|
||||
headers.set('Content-Type', 'application/json')
|
||||
requestBody = JSON.stringify(body)
|
||||
}
|
||||
|
||||
if (auth && accessToken) {
|
||||
headers.set('Authorization', `Bearer ${accessToken}`)
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_URL}${path}`, {
|
||||
...requestOptions,
|
||||
body: requestBody,
|
||||
headers,
|
||||
credentials: 'include',
|
||||
})
|
||||
|
||||
if (response.status === 401 && auth && accessToken && retry) {
|
||||
try {
|
||||
const authResponse = await refreshSession()
|
||||
window.dispatchEvent(new CustomEvent<AuthResponse>(AUTH_UPDATED_EVENT, { detail: authResponse }))
|
||||
return request<T>(path, { ...options, retry: false })
|
||||
} catch {
|
||||
clearAccessToken()
|
||||
window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT))
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) throw await toApiError(response)
|
||||
if (response.status === 204) return undefined as T
|
||||
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
export function apiRequest<T>(path: string, options: ApiRequestOptions = {}) {
|
||||
return request<T>(path, options)
|
||||
}
|
||||
|
||||
export function refreshSession() {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = (async () => {
|
||||
const response = await fetch(`${API_URL}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!response.ok) throw await toApiError(response)
|
||||
|
||||
const authResponse = await response.json() as AuthResponse
|
||||
setAccessToken(authResponse.access_token)
|
||||
return authResponse
|
||||
})().finally(() => {
|
||||
refreshPromise = null
|
||||
})
|
||||
}
|
||||
|
||||
return refreshPromise
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import {
|
||||
createClient,
|
||||
type EmailOtpType,
|
||||
type Session,
|
||||
type SupabaseClient,
|
||||
} from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
||||
const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
|
||||
|
||||
const knownEmailOtpTypes = new Set<EmailOtpType>([
|
||||
'signup',
|
||||
'invite',
|
||||
'magiclink',
|
||||
'recovery',
|
||||
'email_change',
|
||||
'email',
|
||||
])
|
||||
|
||||
type AuthCallbackParams = {
|
||||
accessToken: string | null
|
||||
code: string | null
|
||||
error: string | null
|
||||
errorCode: string | null
|
||||
errorDescription: string | null
|
||||
refreshToken: string | null
|
||||
tokenHash: string | null
|
||||
type: EmailOtpType | null
|
||||
}
|
||||
|
||||
type ResolveEmailAuthCallbackOptions = {
|
||||
allowedTypes: EmailOtpType[]
|
||||
client: SupabaseClient
|
||||
}
|
||||
|
||||
type ResolveEmailAuthCallbackResult =
|
||||
| {
|
||||
ok: true
|
||||
session: Session | null
|
||||
type: EmailOtpType | null
|
||||
}
|
||||
| {
|
||||
ok: false
|
||||
error: string
|
||||
}
|
||||
|
||||
function parseEmailOtpType(value: string | null): EmailOtpType | null {
|
||||
if (!value || !knownEmailOtpTypes.has(value as EmailOtpType)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return value as EmailOtpType
|
||||
}
|
||||
|
||||
export function readAuthCallbackParams(): AuthCallbackParams {
|
||||
const search = new URLSearchParams(window.location.search)
|
||||
const hash = new URLSearchParams(window.location.hash.slice(1))
|
||||
|
||||
return {
|
||||
code: search.get('code'),
|
||||
tokenHash: search.get('token_hash') ?? hash.get('token_hash'),
|
||||
accessToken: hash.get('access_token'),
|
||||
refreshToken: hash.get('refresh_token'),
|
||||
type: parseEmailOtpType(search.get('type') ?? hash.get('type')),
|
||||
error: search.get('error') ?? hash.get('error'),
|
||||
errorCode: search.get('error_code') ?? hash.get('error_code'),
|
||||
errorDescription: search.get('error_description') ?? hash.get('error_description'),
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAuthCallbackUrl() {
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
}
|
||||
|
||||
export function createDetachedAuthClient() {
|
||||
return createClient(supabaseUrl, supabaseKey, {
|
||||
auth: {
|
||||
autoRefreshToken: false,
|
||||
detectSessionInUrl: false,
|
||||
persistSession: false,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function resolveEmailAuthCallback(
|
||||
options: ResolveEmailAuthCallbackOptions,
|
||||
): Promise<ResolveEmailAuthCallbackResult> {
|
||||
const { client, allowedTypes } = options
|
||||
const params = readAuthCallbackParams()
|
||||
const allowedTypeSet = new Set(allowedTypes)
|
||||
|
||||
if (params.error || params.errorCode || params.errorDescription) {
|
||||
return {
|
||||
ok: false,
|
||||
error: params.errorDescription || '链接无效或已过期。',
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
let session: Session | null = null
|
||||
|
||||
if (params.tokenHash && params.type && allowedTypeSet.has(params.type)) {
|
||||
const { data, error } = await client.auth.verifyOtp({
|
||||
token_hash: params.tokenHash,
|
||||
type: params.type,
|
||||
})
|
||||
if (error) throw error
|
||||
session = data.session
|
||||
} else if (params.code) {
|
||||
const { data, error } = await client.auth.exchangeCodeForSession(params.code)
|
||||
if (error) throw error
|
||||
session = data.session
|
||||
} else if (
|
||||
params.accessToken
|
||||
&& params.refreshToken
|
||||
&& params.type
|
||||
&& allowedTypeSet.has(params.type)
|
||||
) {
|
||||
const { data, error } = await client.auth.setSession({
|
||||
access_token: params.accessToken,
|
||||
refresh_token: params.refreshToken,
|
||||
})
|
||||
if (error) throw error
|
||||
session = data.session
|
||||
} else {
|
||||
return {
|
||||
ok: false,
|
||||
error: '链接无效或已过期。',
|
||||
}
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
const { data, error } = await client.auth.getSession()
|
||||
if (error) throw error
|
||||
session = data.session
|
||||
}
|
||||
|
||||
clearAuthCallbackUrl()
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
session,
|
||||
type: params.type,
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : '链接无效或已过期。'
|
||||
return {
|
||||
ok: false,
|
||||
error: message,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { createClient } from '@supabase/supabase-js'
|
||||
|
||||
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
|
||||
const supabaseKey = import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
|
||||
|
||||
if (!supabaseUrl || !supabaseKey) {
|
||||
throw new Error('Missing Supabase environment variables')
|
||||
}
|
||||
|
||||
export const supabase = createClient(supabaseUrl, supabaseKey, {
|
||||
auth: {
|
||||
// Email confirmation and password recovery are handled by route components.
|
||||
detectSessionInUrl: false,
|
||||
},
|
||||
})
|
||||
@@ -56,8 +56,10 @@ const router = createRouter({
|
||||
name: 'encyclopedia',
|
||||
component: () => import('@/views/encyclopedia/EncyclopediaView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: '云朵图鉴',
|
||||
description: '浏览 10 种基础云属,了解云型特征,并通过拍摄云图点亮个人图鉴。',
|
||||
noindex: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -65,8 +67,10 @@ const router = createRouter({
|
||||
name: 'cloud-type',
|
||||
component: () => import('@/views/encyclopedia/CloudTypeView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: route => getCloudTypeName(route.params.id),
|
||||
description: route => `查看 ${getCloudTypeName(route.params.id)} 的识别要点、公开云图和社区观测记录。`,
|
||||
noindex: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
+68
-116
@@ -1,149 +1,102 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import type { User } from '@supabase/supabase-js'
|
||||
import {
|
||||
AUTH_EXPIRED_EVENT,
|
||||
AUTH_UPDATED_EVENT,
|
||||
apiRequest,
|
||||
clearAccessToken,
|
||||
refreshSession,
|
||||
setAccessToken,
|
||||
} from '@/lib/api'
|
||||
import type { AuthResponse, AuthUser } from '@/types/api'
|
||||
import type { Profile } from '@/types/database'
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<User | null>(null)
|
||||
const user = ref<AuthUser | null>(null)
|
||||
const profile = ref<Profile | null>(null)
|
||||
const loading = ref(true)
|
||||
let listenersAttached = false
|
||||
|
||||
const isLoggedIn = computed(() => !!user.value)
|
||||
const isAdmin = computed(() => profile.value?.role === 'admin')
|
||||
|
||||
async function fetchProfile(userId?: string) {
|
||||
const id = userId ?? user.value?.id
|
||||
if (!id) return
|
||||
const { data } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', id)
|
||||
.single()
|
||||
if (data) {
|
||||
profile.value = data as Profile
|
||||
}
|
||||
function applyAuth(auth: AuthResponse) {
|
||||
setAccessToken(auth.access_token)
|
||||
user.value = auth.user
|
||||
profile.value = auth.profile
|
||||
}
|
||||
|
||||
async function ensureUsernameAvailable(username: string, currentUserId?: string) {
|
||||
let query = supabase
|
||||
.from('profiles')
|
||||
.select('id')
|
||||
.eq('username', username)
|
||||
.limit(1)
|
||||
|
||||
if (currentUserId) {
|
||||
query = query.neq('id', currentUserId)
|
||||
}
|
||||
|
||||
const { data, error } = await query
|
||||
if (error) throw error
|
||||
if (data?.length) {
|
||||
throw new Error('这个昵称已经被使用,请换一个。')
|
||||
}
|
||||
}
|
||||
|
||||
async function login(email: string, password: string) {
|
||||
const { data, error } = await supabase.auth.signInWithPassword({ email, password })
|
||||
if (error) {
|
||||
if (error.message.includes('Invalid login credentials')) {
|
||||
throw new Error('邮箱或密码错误')
|
||||
}
|
||||
if (error.message.includes('Email not confirmed')) {
|
||||
throw new Error('邮箱未确认,请先查收确认邮件')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
await fetchProfile(data.user.id)
|
||||
user.value = data.user
|
||||
}
|
||||
|
||||
async function register(email: string, password: string, username: string) {
|
||||
await ensureUsernameAvailable(username)
|
||||
|
||||
const { error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: {
|
||||
data: { username },
|
||||
emailRedirectTo: `${window.location.origin}/auth/confirm`,
|
||||
},
|
||||
})
|
||||
if (error) {
|
||||
if (error.message.includes('already registered')) {
|
||||
throw new Error('该邮箱已被注册')
|
||||
}
|
||||
if (error.message.includes('Password')) {
|
||||
throw new Error('密码强度不足,请使用至少6位密码')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
const { error } = await supabase.auth.signOut()
|
||||
if (error) throw error
|
||||
function clearAuth() {
|
||||
clearAccessToken()
|
||||
user.value = null
|
||||
profile.value = null
|
||||
}
|
||||
|
||||
async function updateUsername(username: string) {
|
||||
if (!user.value) throw new Error('请先登录。')
|
||||
async function login(email: string, password: string) {
|
||||
const auth = await apiRequest<AuthResponse>('/auth/login', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { email, password },
|
||||
})
|
||||
applyAuth(auth)
|
||||
}
|
||||
|
||||
await ensureUsernameAvailable(username, user.value.id)
|
||||
async function register(email: string, password: string, username: string) {
|
||||
await apiRequest('/auth/register', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { email, password, username },
|
||||
})
|
||||
}
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.update({ username })
|
||||
.eq('id', user.value.id)
|
||||
.select('*')
|
||||
.single()
|
||||
|
||||
if (error) {
|
||||
if (error.code === '23505') {
|
||||
throw new Error('这个昵称已经被使用,请换一个。')
|
||||
}
|
||||
throw error
|
||||
async function logout() {
|
||||
try {
|
||||
await apiRequest('/auth/logout', { method: 'POST', retry: false })
|
||||
} finally {
|
||||
clearAuth()
|
||||
}
|
||||
}
|
||||
|
||||
profile.value = data as Profile
|
||||
async function updateUsername(username: string) {
|
||||
const updatedProfile = await apiRequest<Profile>('/profiles/me', {
|
||||
method: 'PATCH',
|
||||
body: { username },
|
||||
})
|
||||
profile.value = updatedProfile
|
||||
}
|
||||
|
||||
async function updatePassword(password: string) {
|
||||
const { error } = await supabase.auth.updateUser({ password })
|
||||
if (error) throw error
|
||||
const auth = await apiRequest<AuthResponse>('/auth/password', {
|
||||
method: 'PATCH',
|
||||
body: { password },
|
||||
})
|
||||
applyAuth(auth)
|
||||
}
|
||||
|
||||
async function sendPasswordReset(email: string) {
|
||||
const { error } = await supabase.auth.resetPasswordForEmail(email, {
|
||||
redirectTo: `${window.location.origin}/auth/reset-password`,
|
||||
await apiRequest('/auth/forgot-password', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { email },
|
||||
})
|
||||
if (error) {
|
||||
if (error.message.includes('rate limit')) {
|
||||
throw new Error('发送过于频繁,请稍后再试。')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
const { data: { session } } = await supabase.auth.getSession()
|
||||
const initialUser = session?.user ?? null
|
||||
if (initialUser) await fetchProfile(initialUser.id)
|
||||
user.value = initialUser
|
||||
loading.value = false
|
||||
if (!listenersAttached) {
|
||||
window.addEventListener(AUTH_UPDATED_EVENT, event => {
|
||||
applyAuth((event as CustomEvent<AuthResponse>).detail)
|
||||
})
|
||||
window.addEventListener(AUTH_EXPIRED_EVENT, clearAuth)
|
||||
listenersAttached = true
|
||||
}
|
||||
|
||||
supabase.auth.onAuthStateChange((_event, session) => {
|
||||
const nextUser = session?.user ?? null
|
||||
if (nextUser) {
|
||||
fetchProfile(nextUser.id).then(() => {
|
||||
user.value = nextUser
|
||||
})
|
||||
} else {
|
||||
user.value = null
|
||||
profile.value = null
|
||||
}
|
||||
})
|
||||
try {
|
||||
applyAuth(await refreshSession())
|
||||
} catch {
|
||||
clearAuth()
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -152,7 +105,6 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
loading,
|
||||
isLoggedIn,
|
||||
isAdmin,
|
||||
fetchProfile,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { defineStore } from 'pinia'
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import type { CloudType } from '@/types/database'
|
||||
|
||||
export const useCloudsStore = defineStore('clouds', () => {
|
||||
@@ -10,14 +10,11 @@ export const useCloudsStore = defineStore('clouds', () => {
|
||||
async function fetchCloudTypes() {
|
||||
if (cloudTypes.value.length > 0) return
|
||||
loading.value = true
|
||||
const { data } = await supabase
|
||||
.from('cloud_types')
|
||||
.select('*')
|
||||
.order('id')
|
||||
if (data) {
|
||||
cloudTypes.value = data as CloudType[]
|
||||
try {
|
||||
cloudTypes.value = await apiRequest<CloudType[]>('/cloud-types', { auth: false })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
return { cloudTypes, loading, fetchCloudTypes }
|
||||
|
||||
+12
-71
@@ -1,7 +1,8 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { ApiCollection } from '@/types/api'
|
||||
import type { CloudType, UserCollection } from '@/types/database'
|
||||
|
||||
export interface CollectionPreviewCloud {
|
||||
@@ -18,27 +19,7 @@ export interface CollectionEntry extends UserCollection {
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) return error.message
|
||||
if (error && typeof error === 'object' && 'message' in error && typeof error.message === 'string') {
|
||||
return error.message
|
||||
}
|
||||
return '图鉴收藏加载失败'
|
||||
}
|
||||
|
||||
function toCollectionCloudMap(rows: Array<Record<string, unknown>> | null) {
|
||||
return new Map(
|
||||
(rows || []).map(row => [
|
||||
row.id as string,
|
||||
{
|
||||
id: row.id as string,
|
||||
image_url: row.image_url as string,
|
||||
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
|
||||
captured_at: (row.captured_at as string | null) ?? null,
|
||||
created_at: row.created_at as string,
|
||||
location_name: (row.location_name as string | null) ?? null,
|
||||
} satisfies CollectionPreviewCloud,
|
||||
]),
|
||||
)
|
||||
return error instanceof Error ? error.message : '图鉴收藏加载失败'
|
||||
}
|
||||
|
||||
export const useEncyclopediaStore = defineStore('encyclopedia', () => {
|
||||
@@ -52,30 +33,18 @@ export const useEncyclopediaStore = defineStore('encyclopedia', () => {
|
||||
const collectionLoadedForUserId = ref<string | null>(null)
|
||||
const collectionError = ref('')
|
||||
|
||||
const collectionMap = computed(() => {
|
||||
return new Map(myCollection.value.map(item => [item.cloud_type_id, item]))
|
||||
})
|
||||
|
||||
const collectionMap = computed(() => new Map(myCollection.value.map(item => [item.cloud_type_id, item])))
|
||||
const unlockedCount = computed(() => myCollection.value.length)
|
||||
const unlockProgress = computed(() => `${unlockedCount.value}/${cloudTypes.value.length || 10}`)
|
||||
const unlockPercent = computed(() => {
|
||||
if (!cloudTypes.value.length) return 0
|
||||
return Math.round(unlockedCount.value / cloudTypes.value.length * 100)
|
||||
})
|
||||
const unlockPercent = computed(() => cloudTypes.value.length
|
||||
? Math.round(unlockedCount.value / cloudTypes.value.length * 100)
|
||||
: 0)
|
||||
|
||||
async function fetchCloudTypes(force = false) {
|
||||
if (cloudTypesLoaded.value && !force) return
|
||||
|
||||
loadingCloudTypes.value = true
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('cloud_types')
|
||||
.select('*')
|
||||
.order('id')
|
||||
|
||||
if (error) throw error
|
||||
|
||||
cloudTypes.value = (data || []) as CloudType[]
|
||||
cloudTypes.value = await apiRequest<CloudType[]>('/cloud-types', { auth: false })
|
||||
cloudTypesLoaded.value = true
|
||||
} finally {
|
||||
loadingCloudTypes.value = false
|
||||
@@ -84,49 +53,21 @@ export const useEncyclopediaStore = defineStore('encyclopedia', () => {
|
||||
|
||||
async function fetchMyCollection(force = false) {
|
||||
const userId = authStore.user?.id ?? null
|
||||
|
||||
if (!userId) {
|
||||
myCollection.value = []
|
||||
collectionLoadedForUserId.value = null
|
||||
collectionError.value = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (!force && collectionLoadedForUserId.value === userId) return
|
||||
|
||||
loadingCollection.value = true
|
||||
collectionError.value = ''
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('user_collections')
|
||||
.select('*')
|
||||
.eq('user_id', userId)
|
||||
.order('unlocked_at', { ascending: true })
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const collectionRows = (data || []) as UserCollection[]
|
||||
const firstCloudIds = collectionRows
|
||||
.map(item => item.first_cloud_id)
|
||||
.filter((id): id is string => typeof id === 'string' && id.length > 0)
|
||||
|
||||
let firstCloudMap = new Map<string, CollectionPreviewCloud>()
|
||||
|
||||
if (firstCloudIds.length) {
|
||||
const { data: firstCloudData, error: firstCloudError } = await supabase
|
||||
.from('clouds')
|
||||
.select('id,image_url,thumbnail_url,captured_at,created_at,location_name')
|
||||
.in('id', firstCloudIds)
|
||||
|
||||
if (firstCloudError) throw firstCloudError
|
||||
|
||||
firstCloudMap = toCollectionCloudMap(firstCloudData as Array<Record<string, unknown>> | null)
|
||||
}
|
||||
|
||||
myCollection.value = collectionRows.map(item => ({
|
||||
...item,
|
||||
firstCloud: item.first_cloud_id ? firstCloudMap.get(item.first_cloud_id) ?? null : null,
|
||||
const rows = await apiRequest<ApiCollection[]>('/collections/me')
|
||||
myCollection.value = rows.map(({ first_cloud, cloud_type: _cloudType, ...collection }) => ({
|
||||
...collection,
|
||||
firstCloud: first_cloud,
|
||||
}))
|
||||
collectionLoadedForUserId.value = userId
|
||||
} catch (error) {
|
||||
|
||||
+70
-141
@@ -1,6 +1,8 @@
|
||||
import { ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { ApiCloud, ApiPage, PublicProfile } from '@/types/api'
|
||||
import type { CloudType, Profile } from '@/types/database'
|
||||
|
||||
export interface ProfileCloudItem {
|
||||
@@ -21,52 +23,36 @@ export interface ProfileCloudItem {
|
||||
cloudTypeRarity: CloudType['rarity']
|
||||
}
|
||||
|
||||
function toProfileCloud(row: Record<string, unknown>) {
|
||||
const cloudTypes = Array.isArray(row.cloud_types) ? row.cloud_types : row.cloud_types ? [row.cloud_types] : []
|
||||
const cloudType = cloudTypes[0] as Record<string, unknown> | undefined
|
||||
|
||||
function toProfileCloud(row: ApiCloud): ProfileCloudItem {
|
||||
return {
|
||||
id: row.id as string,
|
||||
cloud_type_id: (row.cloud_type_id as number | null) ?? null,
|
||||
custom_cloud_type: (row.custom_cloud_type as string | null) ?? null,
|
||||
image_url: row.image_url as string,
|
||||
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
|
||||
latitude: (row.latitude as number | null) ?? null,
|
||||
longitude: (row.longitude as number | null) ?? null,
|
||||
location_name: (row.location_name as string | null) ?? null,
|
||||
description: (row.description as string | null) ?? null,
|
||||
captured_at: (row.captured_at as string | null) ?? null,
|
||||
created_at: row.created_at as string,
|
||||
status: row.status as ProfileCloudItem['status'],
|
||||
is_hidden: (row.is_hidden as boolean) ?? false,
|
||||
cloudTypeName: (cloudType?.name as string) || (row.custom_cloud_type as string) || '未知',
|
||||
cloudTypeRarity: (cloudType?.rarity as CloudType['rarity']) || 'common',
|
||||
} satisfies ProfileCloudItem
|
||||
id: row.id,
|
||||
cloud_type_id: row.cloud_type_id,
|
||||
custom_cloud_type: row.custom_cloud_type,
|
||||
image_url: row.image_url,
|
||||
thumbnail_url: row.thumbnail_url,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
location_name: row.location_name,
|
||||
description: row.description,
|
||||
captured_at: row.captured_at,
|
||||
created_at: row.created_at,
|
||||
status: row.status,
|
||||
is_hidden: row.is_hidden,
|
||||
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
|
||||
cloudTypeRarity: row.cloud_type_rarity || 'common',
|
||||
}
|
||||
}
|
||||
|
||||
function getSupabaseErrorCode(error: unknown) {
|
||||
if (!error || typeof error !== 'object' || !('code' in error)) return null
|
||||
const code = (error as { code?: unknown }).code
|
||||
return typeof code === 'string' ? code : null
|
||||
}
|
||||
|
||||
function getCloudStoragePath(publicUrl: string | null) {
|
||||
if (!publicUrl) return null
|
||||
|
||||
try {
|
||||
const url = new URL(publicUrl)
|
||||
const marker = '/storage/v1/object/public/clouds/'
|
||||
const markerIndex = url.pathname.indexOf(marker)
|
||||
if (markerIndex === -1) return null
|
||||
|
||||
const path = url.pathname.slice(markerIndex + marker.length)
|
||||
return path ? decodeURIComponent(path) : null
|
||||
} catch {
|
||||
return null
|
||||
function toProfile(profile: PublicProfile): Profile {
|
||||
return {
|
||||
...profile,
|
||||
role: 'user',
|
||||
is_disabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
export const useProfileStore = defineStore('profile-page', () => {
|
||||
const authStore = useAuthStore()
|
||||
const profilesById = ref<Record<string, Profile>>({})
|
||||
const cloudsByKey = ref<Record<string, ProfileCloudItem[]>>({})
|
||||
const loadingKeys = ref<Record<string, boolean>>({})
|
||||
@@ -75,23 +61,32 @@ export const useProfileStore = defineStore('profile-page', () => {
|
||||
const makeKey = (userId: string, isOwnProfile: boolean) => `${userId}:${isOwnProfile ? 'own' : 'public'}`
|
||||
|
||||
function getProfile(userId: string | null) {
|
||||
if (!userId) return null
|
||||
return profilesById.value[userId] ?? null
|
||||
return userId ? profilesById.value[userId] ?? null : null
|
||||
}
|
||||
|
||||
function getClouds(userId: string | null, isOwnProfile: boolean) {
|
||||
if (!userId) return []
|
||||
return cloudsByKey.value[makeKey(userId, isOwnProfile)] ?? []
|
||||
return userId ? cloudsByKey.value[makeKey(userId, isOwnProfile)] ?? [] : []
|
||||
}
|
||||
|
||||
function getError(userId: string | null, isOwnProfile: boolean) {
|
||||
if (!userId) return ''
|
||||
return errorByKey.value[makeKey(userId, isOwnProfile)] ?? ''
|
||||
return userId ? errorByKey.value[makeKey(userId, isOwnProfile)] ?? '' : ''
|
||||
}
|
||||
|
||||
function isLoaded(userId: string | null, isOwnProfile: boolean) {
|
||||
if (!userId) return false
|
||||
return makeKey(userId, isOwnProfile) in cloudsByKey.value
|
||||
return !!userId && makeKey(userId, isOwnProfile) in cloudsByKey.value
|
||||
}
|
||||
|
||||
async function fetchAllClouds(userId: string, isOwnProfile: boolean) {
|
||||
const requestOptions = { auth: isOwnProfile }
|
||||
const first = await apiRequest<ApiPage<ApiCloud>>(`/profiles/${userId}/clouds?page=1&page_size=100`, requestOptions)
|
||||
if (first.total_pages <= 1) return first.items
|
||||
|
||||
const remaining = await Promise.all(
|
||||
Array.from({ length: first.total_pages - 1 }, (_, index) =>
|
||||
apiRequest<ApiPage<ApiCloud>>(`/profiles/${userId}/clouds?page=${index + 2}&page_size=100`, requestOptions),
|
||||
),
|
||||
)
|
||||
return [first, ...remaining].flatMap(page => page.items)
|
||||
}
|
||||
|
||||
async function fetchProfilePage(userId: string, isOwnProfile: boolean, force = false) {
|
||||
@@ -100,34 +95,13 @@ export const useProfileStore = defineStore('profile-page', () => {
|
||||
|
||||
loadingKeys.value[key] = true
|
||||
errorByKey.value[key] = ''
|
||||
|
||||
try {
|
||||
const { data: profile, error: profileError } = await supabase
|
||||
.from('profiles')
|
||||
.select('*')
|
||||
.eq('id', userId)
|
||||
.single()
|
||||
|
||||
if (profileError) throw profileError
|
||||
profilesById.value[userId] = profile as Profile
|
||||
|
||||
let cloudsQuery = supabase
|
||||
.from('clouds')
|
||||
.select('id,cloud_type_id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity)')
|
||||
.eq('user_id', userId)
|
||||
.order('captured_at', { ascending: false, nullsFirst: false })
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
if (!isOwnProfile) {
|
||||
cloudsQuery = cloudsQuery
|
||||
.eq('status', 'approved')
|
||||
.eq('is_hidden', false)
|
||||
}
|
||||
|
||||
const { data: cloudRows, error: cloudsError } = await cloudsQuery
|
||||
if (cloudsError) throw cloudsError
|
||||
|
||||
cloudsByKey.value[key] = ((cloudRows || []) as Array<Record<string, unknown>>).map(toProfileCloud)
|
||||
const profilePromise = isOwnProfile && authStore.profile?.id === userId
|
||||
? Promise.resolve(authStore.profile)
|
||||
: apiRequest<PublicProfile>(`/profiles/${userId}`, { auth: false }).then(toProfile)
|
||||
const [profile, cloudRows] = await Promise.all([profilePromise, fetchAllClouds(userId, isOwnProfile)])
|
||||
profilesById.value[userId] = profile
|
||||
cloudsByKey.value[key] = cloudRows.map(toProfileCloud)
|
||||
} catch (error) {
|
||||
errorByKey.value[key] = error instanceof Error ? error.message : '个人主页加载失败'
|
||||
cloudsByKey.value[key] = []
|
||||
@@ -137,8 +111,7 @@ export const useProfileStore = defineStore('profile-page', () => {
|
||||
}
|
||||
|
||||
function isLoading(userId: string | null, isOwnProfile: boolean) {
|
||||
if (!userId) return false
|
||||
return !!loadingKeys.value[makeKey(userId, isOwnProfile)]
|
||||
return !!userId && !!loadingKeys.value[makeKey(userId, isOwnProfile)]
|
||||
}
|
||||
|
||||
function patchCachedCloud(userId: string, cloudId: string, patch: Partial<ProfileCloudItem>) {
|
||||
@@ -146,9 +119,8 @@ export const useProfileStore = defineStore('profile-page', () => {
|
||||
const key = makeKey(userId, isOwnProfile)
|
||||
const current = cloudsByKey.value[key]
|
||||
if (!current) continue
|
||||
|
||||
cloudsByKey.value[key] = current
|
||||
.map(item => (item.id === cloudId ? { ...item, ...patch } : item))
|
||||
.map(item => item.id === cloudId ? { ...item, ...patch } : item)
|
||||
.filter(item => isOwnProfile || (item.status === 'approved' && !item.is_hidden))
|
||||
}
|
||||
}
|
||||
@@ -158,8 +130,7 @@ export const useProfileStore = defineStore('profile-page', () => {
|
||||
for (const isOwnProfile of [true, false]) {
|
||||
const key = makeKey(userId, isOwnProfile)
|
||||
const current = cloudsByKey.value[key]
|
||||
if (!current) continue
|
||||
cloudsByKey.value[key] = current.filter(item => !idSet.has(item.id))
|
||||
if (current) cloudsByKey.value[key] = current.filter(item => !idSet.has(item.id))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,80 +149,38 @@ export const useProfileStore = defineStore('profile-page', () => {
|
||||
captured_at: string | null
|
||||
is_hidden: boolean
|
||||
}) {
|
||||
const { data, error } = await supabase
|
||||
.from('clouds')
|
||||
.update(patch)
|
||||
.eq('id', cloudId)
|
||||
.eq('user_id', userId)
|
||||
.select('id,cloud_type_id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity)')
|
||||
.single()
|
||||
|
||||
if (error) throw error
|
||||
|
||||
const updated = toProfileCloud(data as Record<string, unknown>)
|
||||
const updated = toProfileCloud(await apiRequest<ApiCloud>(`/clouds/${cloudId}`, {
|
||||
method: 'PATCH',
|
||||
body: patch,
|
||||
}))
|
||||
patchCachedCloud(userId, cloudId, updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
async function updateCloudVisibility(userId: string, cloudId: string, isHidden: boolean) {
|
||||
const { data, error } = await supabase
|
||||
.from('clouds')
|
||||
.update({ is_hidden: isHidden })
|
||||
.eq('id', cloudId)
|
||||
.eq('user_id', userId)
|
||||
.select('id')
|
||||
|
||||
if (error) throw error
|
||||
if (!data?.length) {
|
||||
throw new Error('私密状态没有写入数据库,请检查 clouds 表的 UPDATE RLS policy。')
|
||||
}
|
||||
|
||||
await apiRequest<ApiCloud>(`/clouds/${cloudId}`, {
|
||||
method: 'PATCH',
|
||||
body: { is_hidden: isHidden },
|
||||
})
|
||||
patchCachedCloud(userId, cloudId, { is_hidden: isHidden })
|
||||
}
|
||||
|
||||
async function deleteClouds(userId: string, cloudIds: string[]) {
|
||||
if (!cloudIds.length) return 0
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('clouds')
|
||||
.delete()
|
||||
.eq('user_id', userId)
|
||||
.in('id', cloudIds)
|
||||
.select('id,image_url,thumbnail_url')
|
||||
|
||||
if (error) {
|
||||
if (getSupabaseErrorCode(error) === '23503') {
|
||||
throw new Error('这张照片仍被图鉴收藏记录引用,请先在数据库把 user_collections.first_cloud_id 外键改为 ON DELETE SET NULL。')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const deletedIds = (data || []).map(item => item.id as string)
|
||||
if (deletedIds.length !== cloudIds.length) {
|
||||
throw new Error('图片没有真正从数据库删除,请检查 clouds 表的 DELETE RLS policy。')
|
||||
}
|
||||
|
||||
const storagePaths = Array.from(new Set(
|
||||
(data || [])
|
||||
.flatMap(item => [
|
||||
getCloudStoragePath((item.image_url as string | null) ?? null),
|
||||
getCloudStoragePath((item.thumbnail_url as string | null) ?? null),
|
||||
])
|
||||
.filter((path): path is string => !!path),
|
||||
))
|
||||
|
||||
if (storagePaths.length) {
|
||||
const { error: storageError } = await supabase.storage
|
||||
.from('clouds')
|
||||
.remove(storagePaths)
|
||||
|
||||
if (storageError) {
|
||||
throw new Error(`图片数据库记录已删除,但 Supabase Storage 文件清理失败:${storageError.message}`)
|
||||
if (cloudIds.length === 1) {
|
||||
await apiRequest(`/clouds/${cloudIds[0]}`, { method: 'DELETE' })
|
||||
} else {
|
||||
for (let offset = 0; offset < cloudIds.length; offset += 100) {
|
||||
await apiRequest('/clouds/batch-delete', {
|
||||
method: 'POST',
|
||||
body: { ids: cloudIds.slice(offset, offset + 100) },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
removeCachedClouds(userId, deletedIds)
|
||||
return deletedIds.length
|
||||
removeCachedClouds(userId, cloudIds)
|
||||
return cloudIds.length
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
+2
-2
@@ -38,7 +38,7 @@ body {
|
||||
|
||||
.oc-primary-button--teal.n-button {
|
||||
border-color: rgb(153 246 228) !important;
|
||||
background: rgb(242 250 247) !important;
|
||||
background: rgb(255, 255, 255) !important;
|
||||
color: rgb(17 94 89) !important;
|
||||
box-shadow: 4px 4px 0 0 rgba(20, 184, 166, 0.1) !important;
|
||||
}
|
||||
@@ -56,7 +56,7 @@ body {
|
||||
|
||||
.oc-primary-button--sky.n-button {
|
||||
border-color: rgb(186 230 253) !important;
|
||||
background: rgb(224 242 254) !important;
|
||||
background: rgb(255, 255, 255) !important;
|
||||
color: rgb(7 89 133) !important;
|
||||
box-shadow: 4px 4px 0 0 rgba(14, 165, 233, 0.14) !important;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { Cloud, CloudType, Profile, UserCollection } from '@/types/database'
|
||||
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
email: string
|
||||
email_verified: boolean
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
access_token: string
|
||||
token_type: 'bearer'
|
||||
expires_in: number
|
||||
user: AuthUser
|
||||
profile: Profile
|
||||
}
|
||||
|
||||
export interface MeResponse {
|
||||
user: AuthUser
|
||||
profile: Profile
|
||||
}
|
||||
|
||||
export interface PublicProfile {
|
||||
id: string
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface ApiPage<T> {
|
||||
items: T[]
|
||||
page: number
|
||||
page_size: number
|
||||
total: number
|
||||
total_pages: number
|
||||
}
|
||||
|
||||
export interface ApiCloudTypeSummary {
|
||||
id: number
|
||||
name: string
|
||||
name_en: string
|
||||
rarity: CloudType['rarity']
|
||||
}
|
||||
|
||||
export interface ApiCloudOwner {
|
||||
id: string
|
||||
username: string
|
||||
}
|
||||
|
||||
export interface ApiCloud extends Cloud {
|
||||
updated_at: string
|
||||
cloud_type: ApiCloudTypeSummary | null
|
||||
owner: ApiCloudOwner | null
|
||||
cloud_type_name: string | null
|
||||
cloud_type_rarity: CloudType['rarity'] | null
|
||||
username: string | null
|
||||
}
|
||||
|
||||
export interface ApiCollection extends UserCollection {
|
||||
cloud_type: ApiCloudTypeSummary
|
||||
first_cloud: Pick<
|
||||
ApiCloud,
|
||||
'id' | 'image_url' | 'thumbnail_url' | 'captured_at' | 'created_at' | 'location_name'
|
||||
> | null
|
||||
}
|
||||
|
||||
export interface UnlockedBadge {
|
||||
cloud_type_id: number
|
||||
cloud_name: string
|
||||
cloud_name_en: string
|
||||
rarity: CloudType['rarity']
|
||||
unlocked_at: string
|
||||
}
|
||||
|
||||
export interface CloudCreateResponse {
|
||||
cloud: ApiCloud
|
||||
unlocked_badge: UnlockedBadge | null
|
||||
}
|
||||
|
||||
export interface AdminStats {
|
||||
users: number
|
||||
images: number
|
||||
today_uploads: number
|
||||
pending: number
|
||||
approved: number
|
||||
rejected: number
|
||||
hidden: number
|
||||
}
|
||||
|
||||
export interface AdminUserItem {
|
||||
user: AuthUser
|
||||
profile: Profile
|
||||
}
|
||||
@@ -24,8 +24,6 @@ export interface Cloud {
|
||||
status: 'pending' | 'approved' | 'rejected'
|
||||
is_hidden: boolean
|
||||
created_at: string
|
||||
cloud_types?: CloudType
|
||||
profiles?: Profile
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
|
||||
Vendored
+2
@@ -6,5 +6,7 @@ declare module 'vue-router' {
|
||||
description?: string | ((route: RouteLocationNormalizedLoaded) => string)
|
||||
canonicalPath?: string
|
||||
noindex?: boolean
|
||||
requiresAuth?: boolean
|
||||
requiresAdmin?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
+67
-174
@@ -4,9 +4,9 @@ import { NAlert, NButton, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'nai
|
||||
import { Check, Eye, EyeOff, Refresh, Trash, X } from '@vicons/tabler'
|
||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useProfileStore } from '@/stores/profile'
|
||||
import type { AdminStats, AdminUserItem, ApiCloud, ApiPage } from '@/types/api'
|
||||
import type { CloudType, Profile } from '@/types/database'
|
||||
|
||||
type AdminTab = 'dashboard' | 'review' | 'users' | 'images'
|
||||
@@ -44,7 +44,6 @@ interface DashboardStats {
|
||||
}
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const profileStore = useProfileStore()
|
||||
const message = useMessage()
|
||||
|
||||
const activeTab = ref<AdminTab>('dashboard')
|
||||
@@ -110,33 +109,25 @@ const cloudTypeStats = computed(() => {
|
||||
|
||||
const selectedReviewCount = computed(() => selectedReviewIds.value.size)
|
||||
|
||||
function readJoinedOne(value: unknown) {
|
||||
const rows = Array.isArray(value) ? value : value ? [value] : []
|
||||
return rows[0] as Record<string, unknown> | undefined
|
||||
}
|
||||
|
||||
function toAdminCloud(row: Record<string, unknown>): AdminCloud {
|
||||
const cloudType = readJoinedOne(row.cloud_types)
|
||||
const profile = readJoinedOne(row.profiles)
|
||||
|
||||
function toAdminCloud(row: ApiCloud): AdminCloud {
|
||||
return {
|
||||
id: row.id as string,
|
||||
user_id: row.user_id as string,
|
||||
cloud_type_id: (row.cloud_type_id as number | null) ?? null,
|
||||
custom_cloud_type: (row.custom_cloud_type as string | null) ?? null,
|
||||
image_url: row.image_url as string,
|
||||
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
|
||||
latitude: (row.latitude as number | null) ?? null,
|
||||
longitude: (row.longitude as number | null) ?? null,
|
||||
location_name: (row.location_name as string | null) ?? null,
|
||||
description: (row.description as string | null) ?? null,
|
||||
captured_at: (row.captured_at as string | null) ?? null,
|
||||
created_at: row.created_at as string,
|
||||
status: row.status as CloudStatus,
|
||||
is_hidden: (row.is_hidden as boolean) ?? false,
|
||||
cloudTypeName: (cloudType?.name as string) || (row.custom_cloud_type as string) || '未知云型',
|
||||
cloudTypeRarity: (cloudType?.rarity as CloudType['rarity']) || 'common',
|
||||
username: (profile?.username as string) || '匿名用户',
|
||||
id: row.id,
|
||||
user_id: row.user_id,
|
||||
cloud_type_id: row.cloud_type_id,
|
||||
custom_cloud_type: row.custom_cloud_type,
|
||||
image_url: row.image_url,
|
||||
thumbnail_url: row.thumbnail_url,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
location_name: row.location_name,
|
||||
description: row.description,
|
||||
captured_at: row.captured_at,
|
||||
created_at: row.created_at,
|
||||
status: row.status,
|
||||
is_hidden: row.is_hidden,
|
||||
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知云型',
|
||||
cloudTypeRarity: row.cloud_type_rarity || 'common',
|
||||
username: row.username || '匿名用户',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,104 +147,30 @@ function formatCoordinate(value: number | null) {
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown, fallback: string) {
|
||||
if (!error || typeof error !== 'object') return fallback
|
||||
const payload = error as {
|
||||
message?: unknown
|
||||
code?: unknown
|
||||
details?: unknown
|
||||
hint?: unknown
|
||||
}
|
||||
const parts = [
|
||||
typeof payload.message === 'string' ? payload.message : '',
|
||||
typeof payload.code === 'string' ? `错误码:${payload.code}` : '',
|
||||
typeof payload.details === 'string' ? payload.details : '',
|
||||
typeof payload.hint === 'string' ? `提示:${payload.hint}` : '',
|
||||
].filter(Boolean)
|
||||
return parts.length ? parts.join(';') : fallback
|
||||
}
|
||||
|
||||
function todayIsoStart() {
|
||||
const date = new Date()
|
||||
date.setHours(0, 0, 0, 0)
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
async function countProfiles() {
|
||||
const { count, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('*', { head: true, count: 'exact' })
|
||||
if (error) throw error
|
||||
return count || 0
|
||||
}
|
||||
|
||||
async function countClouds(filters: {
|
||||
status?: CloudStatus
|
||||
isHidden?: boolean
|
||||
createdAfter?: string
|
||||
} = {}) {
|
||||
let query = supabase
|
||||
.from('clouds')
|
||||
.select('*', { head: true, count: 'exact' })
|
||||
|
||||
if (filters.status) query = query.eq('status', filters.status)
|
||||
if (typeof filters.isHidden === 'boolean') query = query.eq('is_hidden', filters.isHidden)
|
||||
if (filters.createdAfter) query = query.gte('created_at', filters.createdAfter)
|
||||
|
||||
const { count, error } = await query
|
||||
if (error) throw error
|
||||
return count || 0
|
||||
return error instanceof Error ? error.message : fallback
|
||||
}
|
||||
|
||||
async function fetchStats() {
|
||||
const [
|
||||
userCount,
|
||||
imageCount,
|
||||
todayUploads,
|
||||
pendingCount,
|
||||
approvedCount,
|
||||
rejectedCount,
|
||||
hiddenCount,
|
||||
] = await Promise.all([
|
||||
countProfiles(),
|
||||
countClouds(),
|
||||
countClouds({ createdAfter: todayIsoStart() }),
|
||||
countClouds({ status: 'pending' }),
|
||||
countClouds({ status: 'approved' }),
|
||||
countClouds({ status: 'rejected' }),
|
||||
countClouds({ isHidden: true }),
|
||||
])
|
||||
|
||||
const stats = await apiRequest<AdminStats>('/admin/stats')
|
||||
dashboardStats.value = {
|
||||
users: userCount,
|
||||
images: imageCount,
|
||||
todayUploads,
|
||||
pending: pendingCount,
|
||||
approved: approvedCount,
|
||||
rejected: rejectedCount,
|
||||
hidden: hiddenCount,
|
||||
users: stats.users,
|
||||
images: stats.images,
|
||||
todayUploads: stats.today_uploads,
|
||||
pending: stats.pending,
|
||||
approved: stats.approved,
|
||||
rejected: stats.rejected,
|
||||
hidden: stats.hidden,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUsers() {
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('id,username,avatar_url,role,is_disabled,created_at')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(100)
|
||||
|
||||
if (error) throw error
|
||||
users.value = (data || []) as Profile[]
|
||||
const result = await apiRequest<ApiPage<AdminUserItem>>('/admin/users?page=1&page_size=100')
|
||||
users.value = result.items.map(item => item.profile)
|
||||
}
|
||||
|
||||
async function fetchImages() {
|
||||
const { data, error } = await supabase
|
||||
.from('clouds')
|
||||
.select('id,user_id,cloud_type_id,custom_cloud_type,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,status,is_hidden,cloud_types(name,rarity),profiles(username)')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(120)
|
||||
|
||||
if (error) throw error
|
||||
images.value = ((data || []) as Array<Record<string, unknown>>).map(toAdminCloud)
|
||||
const result = await apiRequest<ApiPage<ApiCloud>>('/admin/clouds?page=1&page_size=120')
|
||||
images.value = result.items.map(toAdminCloud)
|
||||
selectedReviewIds.value = new Set([...selectedReviewIds.value].filter(id => images.value.some(item => item.id === id)))
|
||||
selectedImageIds.value = new Set([...selectedImageIds.value].filter(id => images.value.some(item => item.id === id)))
|
||||
}
|
||||
@@ -327,6 +244,12 @@ function patchImages(ids: string[], patch: Partial<AdminCloud>) {
|
||||
}
|
||||
}
|
||||
|
||||
function chunkIds(ids: string[]) {
|
||||
return Array.from({ length: Math.ceil(ids.length / 100) }, (_, index) =>
|
||||
ids.slice(index * 100, (index + 1) * 100),
|
||||
)
|
||||
}
|
||||
|
||||
async function updateCloudStatus(ids: string[], status: CloudStatus) {
|
||||
if (!ids.length) return
|
||||
|
||||
@@ -334,18 +257,11 @@ async function updateCloudStatus(ids: string[], status: CloudStatus) {
|
||||
loadError.value = ''
|
||||
|
||||
try {
|
||||
let query = supabase
|
||||
.from('clouds')
|
||||
.update({ status })
|
||||
.select('id')
|
||||
|
||||
query = ids.length === 1 ? query.eq('id', ids[0]) : query.in('id', ids)
|
||||
|
||||
const { data, error } = await query
|
||||
|
||||
if (error) throw error
|
||||
if ((data || []).length !== ids.length) {
|
||||
throw new Error('部分图片状态没有写入数据库,请检查管理员 UPDATE RLS policy。')
|
||||
for (const chunk of chunkIds(ids)) {
|
||||
await apiRequest('/admin/clouds/status', {
|
||||
method: 'PATCH',
|
||||
body: { ids: chunk, status },
|
||||
})
|
||||
}
|
||||
|
||||
patchImages(ids, { status })
|
||||
@@ -369,15 +285,11 @@ async function updateImageVisibility(ids: string[], isHidden: boolean) {
|
||||
loadError.value = ''
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('clouds')
|
||||
.update({ is_hidden: isHidden })
|
||||
.in('id', ids)
|
||||
.select('id')
|
||||
|
||||
if (error) throw error
|
||||
if ((data || []).length !== ids.length) {
|
||||
throw new Error('图片可见性没有写入数据库,请检查管理员 UPDATE RLS policy。')
|
||||
for (const chunk of chunkIds(ids)) {
|
||||
await apiRequest('/admin/clouds/visibility', {
|
||||
method: 'PATCH',
|
||||
body: { ids: chunk, is_hidden: isHidden },
|
||||
})
|
||||
}
|
||||
|
||||
patchImages(ids, { is_hidden: isHidden })
|
||||
@@ -412,16 +324,11 @@ async function deleteImages(clouds: AdminCloud[]) {
|
||||
|
||||
try {
|
||||
const idsToDelete = clouds.map(cloud => cloud.id)
|
||||
const cloudsByUser = new Map<string, string[]>()
|
||||
|
||||
for (const cloud of clouds) {
|
||||
const current = cloudsByUser.get(cloud.user_id) || []
|
||||
current.push(cloud.id)
|
||||
cloudsByUser.set(cloud.user_id, current)
|
||||
}
|
||||
|
||||
for (const [userId, cloudIds] of cloudsByUser.entries()) {
|
||||
await profileStore.deleteClouds(userId, cloudIds)
|
||||
for (const chunk of chunkIds(idsToDelete)) {
|
||||
await apiRequest('/admin/clouds/batch-delete', {
|
||||
method: 'POST',
|
||||
body: { ids: chunk },
|
||||
})
|
||||
}
|
||||
|
||||
images.value = images.value.filter(item => !idsToDelete.includes(item.id))
|
||||
@@ -455,18 +362,11 @@ async function updateUserRole(user: Profile, role: Profile['role']) {
|
||||
loadError.value = ''
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.update({ role })
|
||||
.eq('id', user.id)
|
||||
.select('id,role')
|
||||
|
||||
if (error) throw error
|
||||
if (!data?.length) {
|
||||
throw new Error('用户角色没有写入数据库,请检查管理员 UPDATE RLS policy。')
|
||||
}
|
||||
|
||||
users.value = users.value.map(item => (item.id === user.id ? { ...item, role } : item))
|
||||
const result = await apiRequest<AdminUserItem>(`/admin/users/${user.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { role },
|
||||
})
|
||||
users.value = users.value.map(item => item.id === user.id ? result.profile : item)
|
||||
message.success('用户角色已更新')
|
||||
} catch (error) {
|
||||
const text = getErrorMessage(error, '用户角色更新失败')
|
||||
@@ -488,18 +388,11 @@ async function toggleUserDisabled(user: Profile) {
|
||||
|
||||
try {
|
||||
const nextDisabled = !user.is_disabled
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.update({ is_disabled: nextDisabled })
|
||||
.eq('id', user.id)
|
||||
.select('id,is_disabled')
|
||||
|
||||
if (error) throw error
|
||||
if (!data?.length) {
|
||||
throw new Error('用户状态没有写入数据库,请检查 profiles 表的 UPDATE RLS policy。')
|
||||
}
|
||||
|
||||
users.value = users.value.map(item => (item.id === user.id ? { ...item, is_disabled: nextDisabled } : item))
|
||||
const result = await apiRequest<AdminUserItem>(`/admin/users/${user.id}`, {
|
||||
method: 'PATCH',
|
||||
body: { is_disabled: nextDisabled },
|
||||
})
|
||||
users.value = users.value.map(item => item.id === user.id ? result.profile : item)
|
||||
message.success(nextDisabled ? '用户已禁用' : '用户已恢复')
|
||||
} catch (error) {
|
||||
const text = getErrorMessage(error, '用户状态更新失败')
|
||||
@@ -521,9 +414,9 @@ onMounted(loadAdminData)
|
||||
<div>
|
||||
<p class="text-sm font-semibold uppercase tracking-[0.24em] text-sky-700">Admin Console</p>
|
||||
<h1 class="mt-3 text-4xl font-bold text-slate-950">管理后台</h1>
|
||||
<p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
|
||||
集中处理社区云图审核、图片可见性、用户角色和运行数据。所有写入都直接落到 Supabase。
|
||||
</p>
|
||||
<!-- <p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
|
||||
集中处理社区云图审核、图片可见性、用户角色和运行数据。所有操作都由 FastAPI 服务统一处理。
|
||||
</p> -->
|
||||
</div>
|
||||
|
||||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" :loading="loading" @click="loadAdminData">
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { NButton, NCard, NResult, NSpin } from 'naive-ui'
|
||||
import { createDetachedAuthClient, resolveEmailAuthCallback } from '@/lib/authEmail'
|
||||
import { apiRequest } from '@/lib/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const state = ref<'loading' | 'success' | 'failed'>('loading')
|
||||
@@ -22,18 +23,18 @@ function startCountdown() {
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const confirmClient = createDetachedAuthClient()
|
||||
const result = await resolveEmailAuthCallback({
|
||||
client: confirmClient,
|
||||
allowedTypes: ['signup', 'email'],
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
const token = typeof route.query.token === 'string' ? route.query.token : ''
|
||||
if (!token) {
|
||||
state.value = 'failed'
|
||||
return
|
||||
}
|
||||
|
||||
await confirmClient.auth.signOut()
|
||||
await apiRequest('/auth/confirm-email', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { token },
|
||||
})
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
state.value = 'success'
|
||||
startCountdown()
|
||||
} catch {
|
||||
|
||||
@@ -30,16 +30,18 @@ async function handleLogin() {
|
||||
|
||||
<template>
|
||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||
<div class="mx-auto grid max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-center">
|
||||
<section class="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)]">
|
||||
<p class="text-sm uppercase tracking-[0.26em] text-teal-700">Sky Log In</p>
|
||||
<h1 class="mt-4 max-w-xl text-5xl font-black leading-[1.05] text-slate-900">
|
||||
登录后继续记录
|
||||
<span class="block text-teal-700">你眼中的每一朵云</span>
|
||||
</h1>
|
||||
<p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
|
||||
在地图、图鉴和画廊之间同步你的观测记录。上传新的云图后,图鉴会自动点亮,画廊也会按时间收纳你的作品。
|
||||
</p>
|
||||
<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 text-5xl font-black leading-[1.05] text-slate-900">
|
||||
登录后继续记录
|
||||
<span class="block text-teal-700">你眼中的每一朵云</span>
|
||||
</h1>
|
||||
<!-- <p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
|
||||
在地图、图鉴和画廊之间同步你的观测记录。上传新的云图后,图鉴会自动点亮,画廊也会按时间收纳你的作品。
|
||||
</p> -->
|
||||
</div>
|
||||
<div class="mt-8 flex gap-4">
|
||||
<div class="border border-slate-200 bg-white px-4 py-3">
|
||||
<div class="text-xs uppercase tracking-[0.2em] text-slate-500">Collection</div>
|
||||
@@ -54,7 +56,7 @@ async function handleLogin() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||
<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>
|
||||
|
||||
@@ -55,9 +55,9 @@ async function handleRegister() {
|
||||
加入天空探索者
|
||||
<span class="block text-sky-700">建立你的云图档案</span>
|
||||
</h1>
|
||||
<p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
|
||||
<!-- <p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
|
||||
注册后即可上传云图、点亮图鉴、在社区画廊里按时间展示你的观测记录。所有页面都会围绕你的个人云层档案同步更新。
|
||||
</p>
|
||||
</p> -->
|
||||
</div>
|
||||
<div class="mt-8 flex gap-4">
|
||||
<div class="border border-slate-200 bg-white px-4 py-3">
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult, NSpin } from 'naive-ui'
|
||||
import { resolveEmailAuthCallback } from '@/lib/authEmail'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { apiRequest } from '@/lib/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const password = ref('')
|
||||
@@ -13,10 +13,11 @@ const error = ref('')
|
||||
const state = ref<'checking' | 'ready' | 'invalid' | 'success'>('checking')
|
||||
const loading = ref(false)
|
||||
const countdown = ref(5)
|
||||
const resetToken = ref('')
|
||||
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const canSubmit = computed(() =>
|
||||
state.value === 'ready' && password.value.length >= 6 && password.value === confirmPassword.value,
|
||||
state.value === 'ready' && password.value.length >= 8 && password.value === confirmPassword.value,
|
||||
)
|
||||
|
||||
function showInvalidRecoveryLink() {
|
||||
@@ -26,19 +27,12 @@ function showInvalidRecoveryLink() {
|
||||
|
||||
function getResetErrorMessage(value: unknown) {
|
||||
const message = value instanceof Error ? value.message : ''
|
||||
if (
|
||||
message.includes('Auth session missing')
|
||||
|| message.includes('session_not_found')
|
||||
|| message.includes('refresh_token_not_found')
|
||||
|| message.includes('Invalid Refresh Token')
|
||||
|| message.includes('otp_expired')
|
||||
|| message.includes('expired')
|
||||
) {
|
||||
if (message.includes('无效') || message.includes('过期') || message.includes('expired')) {
|
||||
return '密码重置链接无效或已过期,请重新发送邮件。'
|
||||
}
|
||||
|
||||
if (message.includes('Password should be at least')) {
|
||||
return '新密码至少需要 6 位。'
|
||||
if (message.includes('8')) {
|
||||
return '新密码至少需要 8 位。'
|
||||
}
|
||||
|
||||
return message || '密码重置失败,请稍后重试。'
|
||||
@@ -49,31 +43,27 @@ function startCountdown() {
|
||||
countdown.value--
|
||||
if (countdown.value <= 0) {
|
||||
if (countdownTimer) clearInterval(countdownTimer)
|
||||
router.push('/')
|
||||
router.push('/login')
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function initializeRecoverySession() {
|
||||
const result = await resolveEmailAuthCallback({
|
||||
client: supabase,
|
||||
allowedTypes: ['recovery'],
|
||||
})
|
||||
|
||||
if (!result.ok || !result.session?.user) {
|
||||
error.value = getResetErrorMessage(result.ok ? '链接无效或已过期。' : result.error)
|
||||
state.value = 'invalid'
|
||||
const token = typeof route.query.token === 'string' ? route.query.token : ''
|
||||
if (!token) {
|
||||
showInvalidRecoveryLink()
|
||||
return
|
||||
}
|
||||
|
||||
resetToken.value = token
|
||||
state.value = 'ready'
|
||||
}
|
||||
|
||||
async function handleResetPassword() {
|
||||
error.value = ''
|
||||
|
||||
if (password.value.length < 6) {
|
||||
error.value = '新密码至少需要 6 位。'
|
||||
if (password.value.length < 8) {
|
||||
error.value = '新密码至少需要 8 位。'
|
||||
return
|
||||
}
|
||||
if (password.value !== confirmPassword.value) {
|
||||
@@ -81,17 +71,13 @@ async function handleResetPassword() {
|
||||
return
|
||||
}
|
||||
|
||||
const { data: { session }, error: sessionError } = await supabase.auth.getSession()
|
||||
if (sessionError || !session?.user) {
|
||||
showInvalidRecoveryLink()
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const { error: updateError } = await supabase.auth.updateUser({ password: password.value })
|
||||
if (updateError) throw updateError
|
||||
|
||||
await apiRequest('/auth/reset-password', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
body: { token: resetToken.value, password: password.value },
|
||||
})
|
||||
window.history.replaceState(null, '', window.location.pathname)
|
||||
countdown.value = 5
|
||||
state.value = 'success'
|
||||
@@ -123,12 +109,12 @@ onUnmounted(() => {
|
||||
v-if="state === 'success'"
|
||||
status="success"
|
||||
title="密码已重置"
|
||||
description="你已使用新密码完成更新,正在返回地图页。"
|
||||
description="你已使用新密码完成更新,现在可以重新登录。"
|
||||
>
|
||||
<template #footer>
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动跳转地图页面...</p>
|
||||
<NButton type="primary" class="oc-primary-button oc-primary-button--teal" @click="router.push('/')">进入地图</NButton>
|
||||
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动跳转登录页面...</p>
|
||||
<NButton type="primary" class="oc-primary-button oc-primary-button--teal" @click="router.push('/login')">立即登录</NButton>
|
||||
</div>
|
||||
</template>
|
||||
</NResult>
|
||||
@@ -162,7 +148,7 @@ onUnmounted(() => {
|
||||
required
|
||||
show-password-on="click"
|
||||
autocomplete="new-password"
|
||||
placeholder="至少 6 位"
|
||||
placeholder="至少 8 位"
|
||||
/>
|
||||
</NFormItem>
|
||||
|
||||
|
||||
@@ -4,9 +4,10 @@ import { NAlert, NButton, NCard, NEmpty, NSkeleton, NTag } from 'naive-ui'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useEncyclopediaStore } from '@/stores/encyclopedia'
|
||||
import type { ApiCloud, ApiPage } from '@/types/api'
|
||||
import type { CloudType } from '@/types/database'
|
||||
|
||||
interface CloudGalleryItem {
|
||||
@@ -82,49 +83,20 @@ function openGalleryDetail(item: CloudGalleryItem) {
|
||||
}
|
||||
|
||||
async function loadGallery(typeId: number) {
|
||||
const galleryQuery = supabase
|
||||
.from('clouds')
|
||||
.select('id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,profiles(username)')
|
||||
.eq('cloud_type_id', typeId)
|
||||
.eq('status', 'approved')
|
||||
.eq('is_hidden', false)
|
||||
.order('captured_at', { ascending: false, nullsFirst: false })
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(24)
|
||||
|
||||
const countQuery = supabase
|
||||
.from('clouds')
|
||||
.select('*', { head: true, count: 'exact' })
|
||||
.eq('cloud_type_id', typeId)
|
||||
.eq('status', 'approved')
|
||||
.eq('is_hidden', false)
|
||||
|
||||
const [{ data: galleryData, error: galleryError }, { count, error: countError }] = await Promise.all([
|
||||
galleryQuery,
|
||||
countQuery,
|
||||
])
|
||||
|
||||
if (galleryError) throw galleryError
|
||||
if (countError) throw countError
|
||||
|
||||
gallery.value = ((galleryData || []) as Array<Record<string, unknown>>).map(row => {
|
||||
const profiles = Array.isArray(row.profiles) ? row.profiles : row.profiles ? [row.profiles] : []
|
||||
const profile = profiles[0] as Record<string, unknown> | undefined
|
||||
|
||||
return {
|
||||
id: row.id as string,
|
||||
image_url: row.image_url as string,
|
||||
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
|
||||
latitude: (row.latitude as number | null) ?? null,
|
||||
longitude: (row.longitude as number | null) ?? null,
|
||||
location_name: (row.location_name as string | null) ?? null,
|
||||
description: (row.description as string | null) ?? null,
|
||||
captured_at: (row.captured_at as string | null) ?? null,
|
||||
created_at: row.created_at as string,
|
||||
profiles: profile ? { username: profile.username as string } : null,
|
||||
} satisfies CloudGalleryItem
|
||||
})
|
||||
publicCount.value = count || 0
|
||||
const result = await apiRequest<ApiPage<ApiCloud>>(`/cloud-types/${typeId}/clouds?page=1&page_size=24`, { auth: false })
|
||||
gallery.value = result.items.map(row => ({
|
||||
id: row.id,
|
||||
image_url: row.image_url,
|
||||
thumbnail_url: row.thumbnail_url,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
location_name: row.location_name,
|
||||
description: row.description,
|
||||
captured_at: row.captured_at,
|
||||
created_at: row.created_at,
|
||||
profiles: row.username ? { username: row.username } : null,
|
||||
}))
|
||||
publicCount.value = result.total
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
|
||||
@@ -5,11 +5,12 @@ import { Clock, Location, Search, Settings, User, X } from '@vicons/tabler'
|
||||
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
|
||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { useCloudsStore } from '@/stores/clouds'
|
||||
import { useProfileStore } from '@/stores/profile'
|
||||
|
||||
import type { ApiCloud, ApiPage } from '@/types/api'
|
||||
import type { CloudType } from '@/types/database'
|
||||
|
||||
interface GalleryCloud {
|
||||
@@ -87,135 +88,40 @@ function formatCoordinate(value: number | null) {
|
||||
const normalizedSearch = computed(() => searchQuery.value.trim())
|
||||
const isUserSearch = computed(() => normalizedSearch.value.startsWith('@'))
|
||||
|
||||
function sanitizeSearchTerm(term: string) {
|
||||
return term.replace(/[(),*%]/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
|
||||
function getMatchedCloudTypeIds(term: string) {
|
||||
const lowerTerm = term.toLocaleLowerCase('zh-CN')
|
||||
return cloudsStore.cloudTypes
|
||||
.filter(type => {
|
||||
return type.name.toLocaleLowerCase('zh-CN').includes(lowerTerm) ||
|
||||
type.name_en.toLocaleLowerCase('zh-CN').includes(lowerTerm)
|
||||
})
|
||||
.map(type => type.id)
|
||||
}
|
||||
|
||||
async function fetchUserIdsBySearch(term: string) {
|
||||
const sanitized = sanitizeSearchTerm(term)
|
||||
if (!sanitized) return []
|
||||
|
||||
const { data, error } = await supabase
|
||||
.from('profiles')
|
||||
.select('id')
|
||||
.ilike('username', `%${sanitized}%`)
|
||||
.limit(100)
|
||||
|
||||
if (error) throw error
|
||||
return ((data || []) as Array<{ id: string }>).map(profile => profile.id)
|
||||
}
|
||||
|
||||
function toGalleryCloud(row: Record<string, unknown>) {
|
||||
const cloudTypes = Array.isArray(row.cloud_types) ? row.cloud_types : row.cloud_types ? [row.cloud_types] : []
|
||||
const profiles = Array.isArray(row.profiles) ? row.profiles : row.profiles ? [row.profiles] : []
|
||||
const cloudType = cloudTypes[0] as Record<string, unknown> | undefined
|
||||
const profile = profiles[0] as Record<string, unknown> | undefined
|
||||
|
||||
function toGalleryCloud(row: ApiCloud) {
|
||||
return {
|
||||
id: row.id as string,
|
||||
user_id: row.user_id as string,
|
||||
cloud_type_id: (row.cloud_type_id as number | null) ?? null,
|
||||
custom_cloud_type: (row.custom_cloud_type as string | null) ?? null,
|
||||
image_url: row.image_url as string,
|
||||
thumbnail_url: (row.thumbnail_url as string | null) ?? null,
|
||||
location_name: (row.location_name as string | null) ?? null,
|
||||
description: (row.description as string | null) ?? null,
|
||||
latitude: (row.latitude as number | null) ?? null,
|
||||
longitude: (row.longitude as number | null) ?? null,
|
||||
captured_at: (row.captured_at as string | null) ?? null,
|
||||
created_at: row.created_at as string,
|
||||
status: row.status as GalleryCloud['status'],
|
||||
is_hidden: (row.is_hidden as boolean) ?? false,
|
||||
cloudTypeName: (cloudType?.name as string) || (row.custom_cloud_type as string) || '未知',
|
||||
cloudTypeRarity: (cloudType?.rarity as CloudType['rarity']) || 'common',
|
||||
username: (profile?.username as string) || '匿名',
|
||||
id: row.id,
|
||||
user_id: row.user_id,
|
||||
cloud_type_id: row.cloud_type_id,
|
||||
custom_cloud_type: row.custom_cloud_type,
|
||||
image_url: row.image_url,
|
||||
thumbnail_url: row.thumbnail_url,
|
||||
location_name: row.location_name,
|
||||
description: row.description,
|
||||
latitude: row.latitude,
|
||||
longitude: row.longitude,
|
||||
captured_at: row.captured_at,
|
||||
created_at: row.created_at,
|
||||
status: row.status,
|
||||
is_hidden: row.is_hidden,
|
||||
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
|
||||
cloudTypeRarity: row.cloud_type_rarity || 'common',
|
||||
username: row.username || '匿名',
|
||||
} satisfies GalleryCloud
|
||||
}
|
||||
|
||||
async function resolveSearchFilters() {
|
||||
const search = normalizedSearch.value
|
||||
const usernameTerm = isUserSearch.value ? sanitizeSearchTerm(search.slice(1)) : ''
|
||||
const cloudTypeTerm = !isUserSearch.value ? sanitizeSearchTerm(search) : ''
|
||||
|
||||
if (isUserSearch.value && !usernameTerm) return null
|
||||
if (search && !isUserSearch.value && !cloudTypeTerm) return null
|
||||
|
||||
let userIds: string[] = []
|
||||
if (usernameTerm) {
|
||||
userIds = await fetchUserIdsBySearch(usernameTerm)
|
||||
if (userIds.length === 0) return null
|
||||
}
|
||||
|
||||
const matchedCloudTypeIds = cloudTypeTerm ? getMatchedCloudTypeIds(cloudTypeTerm) : []
|
||||
|
||||
return { usernameTerm, cloudTypeTerm, userIds, matchedCloudTypeIds }
|
||||
}
|
||||
|
||||
const FULL_SELECT = 'id,user_id,cloud_type_id,image_url,thumbnail_url,location_name,description,latitude,longitude,captured_at,created_at,status,is_hidden,custom_cloud_type,cloud_types(name,rarity),profiles(username)'
|
||||
|
||||
function buildFilteredQuery(selectStr: string, options?: { count?: 'exact'; head?: boolean }) {
|
||||
let query = supabase
|
||||
.from('clouds')
|
||||
.select(selectStr, options as any)
|
||||
.eq('status', 'approved')
|
||||
.eq('is_hidden', false)
|
||||
|
||||
if (selectedTypeId.value !== 'all') {
|
||||
query = query.eq('cloud_type_id', selectedTypeId.value)
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
|
||||
async function loadPage(page: number) {
|
||||
loading.value = true
|
||||
loadError.value = ''
|
||||
|
||||
try {
|
||||
const filters = await resolveSearchFilters()
|
||||
if (filters === null) {
|
||||
galleryItems.value = []
|
||||
totalCount.value = 0
|
||||
currentPage.value = 1
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
|
||||
if (selectedTypeId.value !== 'all') params.set('type_id', String(selectedTypeId.value))
|
||||
if (normalizedSearch.value) params.set('search', normalizedSearch.value)
|
||||
|
||||
let countQuery = buildFilteredQuery('id', { count: 'exact', head: true })
|
||||
let dataQuery = buildFilteredQuery(FULL_SELECT)
|
||||
.order('created_at', { ascending: false })
|
||||
.range((page - 1) * PAGE_SIZE, page * PAGE_SIZE - 1)
|
||||
|
||||
if (filters.usernameTerm) {
|
||||
countQuery = countQuery.in('user_id', filters.userIds)
|
||||
dataQuery = dataQuery.in('user_id', filters.userIds)
|
||||
} else if (filters.cloudTypeTerm) {
|
||||
if (filters.matchedCloudTypeIds.length) {
|
||||
const orFilter = `cloud_type_id.in.(${filters.matchedCloudTypeIds.join(',')}),custom_cloud_type.ilike.*${filters.cloudTypeTerm}*`
|
||||
countQuery = countQuery.or(orFilter)
|
||||
dataQuery = dataQuery.or(orFilter)
|
||||
} else {
|
||||
countQuery = countQuery.ilike('custom_cloud_type', `%${filters.cloudTypeTerm}%`)
|
||||
dataQuery = dataQuery.ilike('custom_cloud_type', `%${filters.cloudTypeTerm}%`)
|
||||
}
|
||||
}
|
||||
|
||||
const [{ count }, { data, error }] = await Promise.all([countQuery, dataQuery])
|
||||
|
||||
if (error) throw error
|
||||
|
||||
totalCount.value = count ?? 0
|
||||
galleryItems.value = ((data || []) as unknown as Array<Record<string, unknown>>).map(toGalleryCloud)
|
||||
const result = await apiRequest<ApiPage<ApiCloud>>(`/clouds?${params}`, { auth: false })
|
||||
totalCount.value = result.total
|
||||
galleryItems.value = result.items.map(toGalleryCloud)
|
||||
currentPage.value = page
|
||||
} catch (error) {
|
||||
loadError.value = error instanceof Error ? error.message : '画廊加载失败'
|
||||
|
||||
+24
-31
@@ -3,9 +3,10 @@ import { computed, ref, onMounted, onUnmounted } from 'vue'
|
||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||
import QuickUploadModal from '@/components/cloud/QuickUploadModal.vue'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { apiRequest } from '@/lib/api'
|
||||
import { loadAMap } from '@/lib/amap'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type { ApiCloud } from '@/types/api'
|
||||
import { NIcon } from 'naive-ui'
|
||||
import { Adjustments, Calendar, CloudUpload, Refresh, Map, Satellite, X } from '@vicons/tabler'
|
||||
|
||||
@@ -212,44 +213,36 @@ function hideHeader() {
|
||||
window.dispatchEvent(new CustomEvent(HIDE_HEADER_EVENT))
|
||||
}
|
||||
|
||||
function toCloudMarker(row: Record<string, unknown>): CloudMarkerData {
|
||||
const ct = row.cloud_types as Record<string, unknown> | null
|
||||
const pf = row.profiles as Record<string, unknown> | null
|
||||
function toCloudMarker(row: ApiCloud): CloudMarkerData {
|
||||
return {
|
||||
id: row.id as string,
|
||||
id: row.id,
|
||||
latitude: row.latitude as number,
|
||||
longitude: row.longitude as number,
|
||||
imageUrl: row.image_url as string,
|
||||
thumbnailUrl: (row.thumbnail_url as string | null) ?? null,
|
||||
locationName: (row.location_name as string | null) ?? null,
|
||||
description: (row.description as string | null) ?? null,
|
||||
cloudTypeName: (ct?.name as string) || (row.custom_cloud_type as string) || '未知',
|
||||
rarity: (ct?.rarity as 'common' | 'uncommon' | 'rare') || 'common',
|
||||
username: (pf?.username as string) || '匿名',
|
||||
capturedAt: (row.captured_at as string) || (row.created_at as string),
|
||||
createdAt: row.created_at as string,
|
||||
imageUrl: row.image_url,
|
||||
thumbnailUrl: row.thumbnail_url,
|
||||
locationName: row.location_name,
|
||||
description: row.description,
|
||||
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
|
||||
rarity: row.cloud_type_rarity || 'common',
|
||||
username: row.username || '匿名',
|
||||
capturedAt: row.captured_at || row.created_at,
|
||||
createdAt: row.created_at,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCloudsByRange(field: 'captured_at' | 'created_at', start: Date, end: Date): Promise<CloudMarkerData[]> {
|
||||
const { data, error } = await supabase
|
||||
.from('clouds')
|
||||
.select('id,image_url,thumbnail_url,latitude,longitude,location_name,description,captured_at,created_at,custom_cloud_type,cloud_types(name,rarity),profiles(username)')
|
||||
.eq('status', 'approved')
|
||||
.eq('is_hidden', false)
|
||||
.not('latitude', 'is', null)
|
||||
.not('longitude', 'is', null)
|
||||
.gte(field, start.toISOString())
|
||||
.lt(field, end.toISOString())
|
||||
.order(field, { ascending: true })
|
||||
.limit(1000)
|
||||
|
||||
if (error) {
|
||||
statusText.value = `查询失败: ${error.message}`
|
||||
const params = new URLSearchParams({
|
||||
start: start.toISOString(),
|
||||
end: end.toISOString(),
|
||||
time_field: field,
|
||||
})
|
||||
try {
|
||||
const rows = await apiRequest<ApiCloud[]>(`/clouds/map?${params}`, { auth: false })
|
||||
return rows.map(toCloudMarker)
|
||||
} catch (error) {
|
||||
statusText.value = `查询失败: ${error instanceof Error ? error.message : '未知错误'}`
|
||||
return []
|
||||
}
|
||||
|
||||
return ((data || []) as Array<Record<string, unknown>>).map(toCloudMarker)
|
||||
}
|
||||
|
||||
async function loadRealtimeClouds() {
|
||||
@@ -486,7 +479,7 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-6 right-16 z-10 flex justify-end pointer-events-none">
|
||||
<div class="absolute bottom-6 right-19 z-10 flex justify-end pointer-events-none">
|
||||
<div class="pointer-events-auto flex items-center justify-end gap-3">
|
||||
<Transition name="map-timeline-controls">
|
||||
<div v-if="timelineControlsOpen" class="flex items-center gap-3">
|
||||
|
||||
@@ -54,8 +54,8 @@ async function savePassword() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
if (newPassword.value.length < 6) {
|
||||
error.value = '新密码至少需要 6 位。'
|
||||
if (newPassword.value.length < 8) {
|
||||
error.value = '新密码至少需要 8 位。'
|
||||
return
|
||||
}
|
||||
if (newPassword.value !== confirmNewPassword.value) {
|
||||
@@ -145,7 +145,7 @@ async function savePassword() {
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-slate-900">登录密码</h2>
|
||||
<p class="mt-2 text-sm leading-6 text-slate-500">
|
||||
修改后下次登录需要使用新密码。建议使用至少 6 位且不易猜测的密码。
|
||||
修改后下次登录需要使用新密码。建议使用至少 8 位且不易猜测的密码。
|
||||
</p>
|
||||
</div>
|
||||
<div class="grid gap-3">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { RouterLink, useRoute } from 'vue-router'
|
||||
import { NAlert, NButton, NCard, NDropdown, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
|
||||
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'
|
||||
@@ -70,12 +70,12 @@ const pageTitle = computed(() => {
|
||||
return isOwnProfile.value ? '我的天空日志' : `${profileData.value?.username || '这位用户'}的天空日志`
|
||||
})
|
||||
|
||||
const profileSubtitle = computed(() => {
|
||||
if (isOwnProfile.value) {
|
||||
return '回看你的云图记录、收集进度和拍摄节奏。'
|
||||
}
|
||||
return '公开展示的云图记录会按时间顺序陈列在这里。'
|
||||
})
|
||||
// const profileSubtitle = computed(() => {
|
||||
// if (isOwnProfile.value) {
|
||||
// return '回看你的云图记录、收集进度和拍摄节奏。'
|
||||
// }
|
||||
// return '公开展示的云图记录会按时间顺序陈列在这里。'
|
||||
// })
|
||||
|
||||
const totalShots = computed(() => clouds.value.length)
|
||||
|
||||
@@ -526,13 +526,13 @@ watch(selectedUploadDate, async newValue => {
|
||||
</button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
|
||||
<!-- <p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
|
||||
{{ profileSubtitle }}
|
||||
</p>
|
||||
</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>
|
||||
<!-- <NTag v-if="isOwnProfile" type="primary" :bordered="false">你的主页</NTag>
|
||||
<NTag v-else :bordered="false">公开展示</NTag> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+9
-1
@@ -6,7 +6,6 @@ import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
const publicRoutes = [
|
||||
'/',
|
||||
'/encyclopedia',
|
||||
'/gallery',
|
||||
'/community',
|
||||
]
|
||||
@@ -32,6 +31,7 @@ function seoFilesPlugin() {
|
||||
'Disallow: /login',
|
||||
'Disallow: /register',
|
||||
'Disallow: /upload',
|
||||
'Disallow: /encyclopedia',
|
||||
'Disallow: /profile/settings',
|
||||
`Sitemap: ${siteUrl}/sitemap.xml`,
|
||||
'',
|
||||
@@ -57,6 +57,14 @@ function seoFilesPlugin() {
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue(), tailwindcss(), seoFilesPlugin()],
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
preview: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
|
||||
Reference in New Issue
Block a user