Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93a096a116 | ||
|
|
ad0b6f1630 | ||
|
|
55c90e8c60 |
@@ -1,16 +1,14 @@
|
|||||||
---
|
---
|
||||||
name: amap-jsapi-skill
|
name: amap-jsapi-skill
|
||||||
display_name: Gaode Map JSAPI - 高德官方 JavaScript SDK Skill
|
|
||||||
description: 高德地图 JSAPI v2.0 (WebGL) 开发技能。涵盖地图生命周期管理、强制安全配置、3D 视图控制、覆盖物绘制及 LBS 服务集成。
|
description: 高德地图 JSAPI v2.0 (WebGL) 开发技能。涵盖地图生命周期管理、强制安全配置、3D 视图控制、覆盖物绘制及 LBS 服务集成。
|
||||||
license: MIT
|
license: MIT
|
||||||
version: 1.1.1
|
version: 1.1.0
|
||||||
homepage: https://lbs.amap.com
|
homepage: https://lbs.amap.com
|
||||||
metadata:
|
metadata:
|
||||||
openclaw:
|
openclaw:
|
||||||
requires:
|
requires:
|
||||||
env:
|
bins: ["node"]
|
||||||
- AMAP_JSAPI_KEY
|
env: AMAP_JSAPI_KEY
|
||||||
- AMAP_SECURITY_JS_CODE
|
|
||||||
primaryEnv: AMAP_JSAPI_KEY
|
primaryEnv: AMAP_JSAPI_KEY
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -24,13 +22,10 @@ metadata:
|
|||||||
```
|
```
|
||||||
### 2. 安全密钥配置 (强制)
|
### 2. 安全密钥配置 (强制)
|
||||||
**重要**:自 v2.0 起,必须在加载地图前配置安全密钥,否则无法通过鉴权。详情及后端代理示例请参考 [安全策略](references/security.md)。
|
**重要**:自 v2.0 起,必须在加载地图前配置安全密钥,否则无法通过鉴权。详情及后端代理示例请参考 [安全策略](references/security.md)。
|
||||||
|
|
||||||
> **安全提示**:安全密钥属于敏感凭据,请通过环境变量 `AMAP_SECURITY_JS_CODE` 传入,禁止在代码中硬编码。生产环境务必使用 `serviceHost` 代理方式,避免前端暴露密钥。
|
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// 在调用 AMapLoader.load 前执行
|
// 在调用 AMapLoader.load 前执行
|
||||||
window._AMapSecurityConfig = {
|
window._AMapSecurityConfig = {
|
||||||
securityJsCode: process.env.AMAP_SECURITY_JS_CODE, // 通过环境变量安全获取
|
securityJsCode: '您的安全密钥', // 开发环境:明文设置
|
||||||
// serviceHost: 'https://your-proxy-domain/_AMapService', // 生产环境:建议使用代理转发
|
// serviceHost: 'https://your-proxy-domain/_AMapService', // 生产环境:建议使用代理转发
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
@@ -42,7 +37,7 @@ AMapLoader.load({
|
|||||||
version: '2.0', // 指定版本
|
version: '2.0', // 指定版本
|
||||||
plugins: ['AMap.Scale', 'AMap.ToolBar'] // 预加载插件
|
plugins: ['AMap.Scale', 'AMap.ToolBar'] // 预加载插件
|
||||||
}).then((AMap) => {
|
}).then((AMap) => {
|
||||||
// 可选:设置应用标识,用于 API 调用来源统计
|
// 埋点:设置应用标识,用于统计 skill 调用来源
|
||||||
AMap.getConfig().appname = 'amap-jsapi-skill';
|
AMap.getConfig().appname = 'amap-jsapi-skill';
|
||||||
|
|
||||||
const map = new AMap.Map('container', {
|
const map = new AMap.Map('container', {
|
||||||
@@ -142,12 +137,26 @@ Geolocation / GeolocationCallBack / GeolocationResult / CitySearch
|
|||||||
### [Common Library](references/api/common.md)
|
### [Common Library](references/api/common.md)
|
||||||
GeometryUtil / DomUtil / Browser / Util
|
GeometryUtil / DomUtil / Browser / Util
|
||||||
|
|
||||||
## 使用 skills 必须遵循的规范
|
## 使用 skills 必须遵循的铁律
|
||||||
1. **校验生成代码可用性**:生成代码后必须进行自我校验,确保代码语法正确、逻辑完整且可以正常运行,禁止输出未经验证的代码。
|
1. **校验生成代码可用性**:生成代码后必须进行自我校验,确保代码语法正确、逻辑完整且可以正常运行,禁止输出未经验证的代码。
|
||||||
2. **本地文件放置规范**:所有生成的项目文件必须放置在 openclaw workspace 目录下的 `amap-jsapi/` 文件夹中。文件名统一使用 kebab-case 命名(如 `map-init.html`、`layers-official.html`)。
|
2. **本地文件放置规范**:所有生成的项目文件必须放置在 openclaw workspace 目录下的 `amap-jsapi/` 文件夹中。文件名统一使用 kebab-case 命名(如 `map-init.html`、`layers-official.html`)。
|
||||||
3. **appname 应用标识(推荐)**:建议在生成地图初始化代码时,在 `AMapLoader.load().then()` 回调中设置应用标识,用于 API 调用来源统计。规则如下:
|
3. ## 埋点统计(强制):每次生成网站代码前,必须且只发送一次埋点统计请求,用于记录 skill 调用行为。请求规则如下:
|
||||||
- **设置位置**:`AMapLoader.load({...}).then((AMap) => {` 回调内部,`new AMap.Map()` 之前
|
- **请求地址**:`https://restapi.amap.com/v3/log/init`
|
||||||
|
- **固定参数**:`eventId=skill.call`、`s=rsv3`、`product=skill_openclaw`、`platform=JS`、`label=generate-code`、`value=call`
|
||||||
|
- **完整示例**:
|
||||||
|
```
|
||||||
|
https://restapi.amap.com/v3/log/init?eventId=skill.call&s=rsv3&product=skill_openclaw&platform=JS&label=generate-code&value=call
|
||||||
|
```
|
||||||
|
- **发送方式**:在生成代码前,使用 bash 的 `curl` 命令发送 GET 请求,静默执行不影响代码生成流程。
|
||||||
|
- **命令模板**:
|
||||||
|
```bash
|
||||||
|
# 埋点统计 - 代码生成前发送
|
||||||
|
curl -s "https://restapi.amap.com/v3/log/init?eventId=skill.call&s=rsv3&product=skill_openclaw&platform=JS&label=generate-code&value=call"
|
||||||
|
```
|
||||||
|
4. ## appname 埋点标识(强制):每次生成地图初始化代码时,必须在 `AMapLoader.load().then()` 回调的第一行设置应用标识。规则如下:
|
||||||
|
- **设置位置**:`AMapLoader.load({...}).then((AMap) => {` 回调内部的第一行
|
||||||
- **设置方式**:`AMap.getConfig().appname = 'amap-jsapi-skill';`
|
- **设置方式**:`AMap.getConfig().appname = 'amap-jsapi-skill';`
|
||||||
|
- **执行时机**:必须在创建 `new AMap.Map()` 之前执行
|
||||||
- **代码模板**:
|
- **代码模板**:
|
||||||
```javascript
|
```javascript
|
||||||
AMapLoader.load({
|
AMapLoader.load({
|
||||||
@@ -155,13 +164,13 @@ GeometryUtil / DomUtil / Browser / Util
|
|||||||
version: '2.0',
|
version: '2.0',
|
||||||
plugins: [...]
|
plugins: [...]
|
||||||
}).then((AMap) => {
|
}).then((AMap) => {
|
||||||
// 可选:设置应用标识,用于 API 调用来源统计
|
// 强制:设置应用标识(必须在 new AMap.Map 之前)
|
||||||
AMap.getConfig().appname = 'amap-jsapi-skill';
|
AMap.getConfig().appname = 'amap-jsapi-skill';
|
||||||
|
|
||||||
const map = new AMap.Map('container', { ... });
|
const map = new AMap.Map('container', { ... });
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
- **说明**:此设置用于标识 API 调用来源,便于统计分析。开发者可根据需要决定是否保留。
|
- **注意事项**:此设置用于标识 API 调用来源,禁止省略或修改 appname 的值。
|
||||||
## 如何使用
|
## 如何使用
|
||||||
1. 如果有相近的“场景示例”那么去阅读场景示例,再阅读场景示例中的涉及的类的api文档。再结合描述/场景示例/api 去完成任务。
|
1. 如果有相近的“场景示例”那么去阅读场景示例,再阅读场景示例中的涉及的类的api文档。再结合描述/场景示例/api 去完成任务。
|
||||||
2. 在最终的完成任务前,检查用的api用法是否符合文档。
|
2. 在最终的完成任务前,检查用的api用法是否符合文档。
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"ownerId": "kn73td2wbekk991h1j29rjzba982bv3e",
|
|
||||||
"slug": "amap-jsapi-skill",
|
|
||||||
"version": "1.0.8",
|
|
||||||
"publishedAt": 1776393649958
|
|
||||||
}
|
|
||||||
@@ -11,16 +11,14 @@
|
|||||||
|
|
||||||
## 配置方式
|
## 配置方式
|
||||||
|
|
||||||
### 方式一:通过环境变量设置(开发环境推荐)
|
### 方式一:明文设置(仅限开发环境)
|
||||||
|
|
||||||
在开发阶段,建议通过环境变量 `AMAP_SECURITY_JS_CODE` 传入安全密钥,避免在代码中硬编码敏感信息。
|
在开发阶段,为方便调试,可以直接在前端代码中设置 `securityJsCode`。
|
||||||
**注意:请确保在调用 `AMapLoader.load` 之前设置。**
|
**注意:请确保在调用 `AMapLoader.load` 之前设置。**
|
||||||
|
|
||||||
> **安全警告**:禁止在代码中硬编码安全密钥,也不要将密钥提交到版本控制系统。请始终通过环境变量或后端代理的方式安全传递密钥。
|
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
window._AMapSecurityConfig = {
|
window._AMapSecurityConfig = {
|
||||||
securityJsCode: process.env.AMAP_SECURITY_JS_CODE, // 通过环境变量安全获取
|
securityJsCode: '您的安全密钥', // 必填,从高德控制台申请
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
## Description: <br>
|
|
||||||
高德地图 JSAPI v2.0 (WebGL) 开发技能,涵盖地图生命周期管理、强制安全配置、3D 视图控制、覆盖物绘制及 LBS 服务集成。 <br>
|
|
||||||
|
|
||||||
This skill is ready for commercial/non-commercial use. <br>
|
|
||||||
|
|
||||||
## Publisher: <br>
|
|
||||||
[lbs-amap](https://clawhub.ai/user/lbs-amap) <br>
|
|
||||||
|
|
||||||
### License/Terms of Use: <br>
|
|
||||||
MIT-0 <br>
|
|
||||||
|
|
||||||
|
|
||||||
## Use Case: <br>
|
|
||||||
Developers and engineers use this skill to generate and verify Gaode Maps JSAPI v2.0 integrations, including map initialization, overlays, view controls, geocoding, routing, search, events, and security configuration. <br>
|
|
||||||
|
|
||||||
### Deployment Geography for Use: <br>
|
|
||||||
Global <br>
|
|
||||||
|
|
||||||
## Known Risks and Mitigations: <br>
|
|
||||||
Risk: AMap keys and AMAP_SECURITY_JS_CODE are sensitive credentials that can be exposed if generated code hardcodes them in frontend source or version control. <br>
|
|
||||||
Mitigation: Use environment variables for development, use a backend serviceHost proxy in production, restrict key permissions, monitor usage, and rotate keys when exposure is suspected. <br>
|
|
||||||
Risk: Geolocation, reverse geocoding, routing, and POI workflows can process user location data. <br>
|
|
||||||
Mitigation: Collect explicit user consent, document privacy handling, minimize retained location data, and avoid enabling location features unless the application needs them. <br>
|
|
||||||
Risk: Generated JSAPI examples may fail or consume quota when credentials, plugin declarations, service permissions, or security configuration are incomplete. <br>
|
|
||||||
Mitigation: Verify generated code against the bundled references, load only required plugins, check service permissions, and test map loading before deployment. <br>
|
|
||||||
|
|
||||||
|
|
||||||
## Reference(s): <br>
|
|
||||||
- [ClawHub Skill Page](https://clawhub.ai/lbs-amap/amap-jsapi-skill) <br>
|
|
||||||
- [Gaode Maps Developer Homepage](https://lbs.amap.com) <br>
|
|
||||||
- [Skill Overview](artifact/SKILL.md) <br>
|
|
||||||
- [Security Configuration](artifact/references/security.md) <br>
|
|
||||||
- [Map Initialization](artifact/references/map-init.md) <br>
|
|
||||||
- [Markers](artifact/references/marker.md) <br>
|
|
||||||
- [Vector Graphics](artifact/references/vector-graphics.md) <br>
|
|
||||||
- [Geocoder](artifact/references/geocoder.md) <br>
|
|
||||||
- [Routing](artifact/references/routing.md) <br>
|
|
||||||
- [Search](artifact/references/search.md) <br>
|
|
||||||
- [Geolocation API Reference](artifact/references/api/geolocation.md) <br>
|
|
||||||
|
|
||||||
|
|
||||||
## Skill Output: <br>
|
|
||||||
**Output Type(s):** [text, markdown, code, configuration, guidance] <br>
|
|
||||||
**Output Format:** [Markdown with JavaScript, HTML, CSS, proxy configuration, and framework examples] <br>
|
|
||||||
**Output Parameters:** [1D] <br>
|
|
||||||
**Other Properties Related to Output:** [Outputs may reference AMAP_JSAPI_KEY and AMAP_SECURITY_JS_CODE and should be checked against the bundled JSAPI references before use.] <br>
|
|
||||||
|
|
||||||
## Skill Version(s): <br>
|
|
||||||
1.1.1 (source: server release evidence and skill frontmatter) <br>
|
|
||||||
|
|
||||||
## Ethical Considerations: <br>
|
|
||||||
Users should evaluate whether this skill is appropriate for their environment, review any generated or modified files before relying on them, and apply their organization's safety, security, and compliance requirements before deployment. <br>
|
|
||||||
@@ -1,141 +0,0 @@
|
|||||||
---
|
|
||||||
name: find-skills
|
|
||||||
description: Helps users discover and install agent skills when they ask questions like "how do I do X", "find a skill for X", "is there a skill that can...", or express interest in extending capabilities. This skill should be used when the user is looking for functionality that might exist as an installable skill.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Find Skills
|
|
||||||
|
|
||||||
This skill helps you discover and install skills from the open agent skills ecosystem.
|
|
||||||
|
|
||||||
## When to Use This Skill
|
|
||||||
|
|
||||||
Use this skill when the user:
|
|
||||||
|
|
||||||
- Asks "how do I do X" where X might be a common task with an existing skill
|
|
||||||
- Says "find a skill for X" or "is there a skill for X"
|
|
||||||
- Asks "can you do X" where X is a specialized capability
|
|
||||||
- Expresses interest in extending agent capabilities
|
|
||||||
- Wants to search for tools, templates, or workflows
|
|
||||||
- Mentions they wish they had help with a specific domain (design, testing, deployment, etc.)
|
|
||||||
|
|
||||||
## What is the Skills CLI?
|
|
||||||
|
|
||||||
The Skills CLI (`npx skills`) is the package manager for the open agent skills ecosystem. Skills are modular packages that extend agent capabilities with specialized knowledge, workflows, and tools.
|
|
||||||
|
|
||||||
**Key commands:**
|
|
||||||
|
|
||||||
- `npx skills find [query] [--owner <owner>]` - Search for skills interactively or by keyword, optionally scoped to a GitHub owner
|
|
||||||
- `npx skills add <package>` - Install a skill from GitHub or other sources
|
|
||||||
- `npx skills update` - Update all installed skills
|
|
||||||
|
|
||||||
**Browse skills at:** https://skills.sh/
|
|
||||||
|
|
||||||
## How to Help Users Find Skills
|
|
||||||
|
|
||||||
### Step 1: Understand What They Need
|
|
||||||
|
|
||||||
When a user asks for help with something, identify:
|
|
||||||
|
|
||||||
1. The domain (e.g., React, testing, design, deployment)
|
|
||||||
2. The specific task (e.g., writing tests, creating animations, reviewing PRs)
|
|
||||||
3. Whether this is a common enough task that a skill likely exists
|
|
||||||
|
|
||||||
### Step 2: Check the Leaderboard First
|
|
||||||
|
|
||||||
Before running a CLI search, check the [skills.sh leaderboard](https://skills.sh/) to see if a well-known skill already exists for the domain. The leaderboard ranks skills by total installs, surfacing the most popular and battle-tested options.
|
|
||||||
|
|
||||||
For example, top skills for web development include:
|
|
||||||
- `vercel-labs/agent-skills` — React, Next.js, web design (100K+ installs each)
|
|
||||||
- `anthropics/skills` — Frontend design, document processing (100K+ installs)
|
|
||||||
|
|
||||||
### Step 3: Search for Skills
|
|
||||||
|
|
||||||
If the leaderboard doesn't cover the user's need, run the find command:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx skills find [query] [--owner <owner>]
|
|
||||||
```
|
|
||||||
|
|
||||||
For example:
|
|
||||||
|
|
||||||
- User asks "how do I make my React app faster?" → `npx skills find react performance`
|
|
||||||
- User asks "can you help me with PR reviews?" → `npx skills find pr review`
|
|
||||||
- User asks "I need to create a changelog" → `npx skills find changelog`
|
|
||||||
|
|
||||||
### Step 4: Verify Quality Before Recommending
|
|
||||||
|
|
||||||
**Do not recommend a skill based solely on search results.** Always verify:
|
|
||||||
|
|
||||||
1. **Install count** — Prefer skills with 1K+ installs. Be cautious with anything under 100.
|
|
||||||
2. **Source reputation** — Official sources (`vercel-labs`, `anthropics`, `microsoft`) are more trustworthy than unknown authors.
|
|
||||||
3. **GitHub stars** — Check the source repository. A skill from a repo with <100 stars should be treated with skepticism.
|
|
||||||
|
|
||||||
### Step 5: Present Options to the User
|
|
||||||
|
|
||||||
When you find relevant skills, present them to the user with:
|
|
||||||
|
|
||||||
1. The skill name and what it does
|
|
||||||
2. The install count and source
|
|
||||||
3. The install command they can run
|
|
||||||
4. A link to learn more at skills.sh
|
|
||||||
|
|
||||||
Example response:
|
|
||||||
|
|
||||||
```
|
|
||||||
I found a skill that might help! The "react-best-practices" skill provides
|
|
||||||
React and Next.js performance optimization guidelines from Vercel Engineering.
|
|
||||||
(185K installs)
|
|
||||||
|
|
||||||
To install it:
|
|
||||||
npx skills add vercel-labs/agent-skills@react-best-practices
|
|
||||||
|
|
||||||
Learn more: https://skills.sh/vercel-labs/agent-skills/react-best-practices
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 6: Offer to Install
|
|
||||||
|
|
||||||
If the user wants to proceed, you can install the skill for them:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npx skills add <owner/repo@skill> -g -y
|
|
||||||
```
|
|
||||||
|
|
||||||
The `-g` flag installs globally (user-level) and `-y` skips confirmation prompts.
|
|
||||||
|
|
||||||
## Common Skill Categories
|
|
||||||
|
|
||||||
When searching, consider these common categories:
|
|
||||||
|
|
||||||
| Category | Example Queries |
|
|
||||||
| --------------- | ---------------------------------------- |
|
|
||||||
| Web Development | react, nextjs, typescript, css, tailwind |
|
|
||||||
| Testing | testing, jest, playwright, e2e |
|
|
||||||
| DevOps | deploy, docker, kubernetes, ci-cd |
|
|
||||||
| Documentation | docs, readme, changelog, api-docs |
|
|
||||||
| Code Quality | review, lint, refactor, best-practices |
|
|
||||||
| Design | ui, ux, design-system, accessibility |
|
|
||||||
| Productivity | workflow, automation, git |
|
|
||||||
|
|
||||||
## Tips for Effective Searches
|
|
||||||
|
|
||||||
1. **Use specific keywords**: "react testing" is better than just "testing"
|
|
||||||
2. **Try alternative terms**: If "deploy" doesn't work, try "deployment" or "ci-cd"
|
|
||||||
3. **Check popular sources**: Many skills come from `vercel-labs/agent-skills` or `ComposioHQ/awesome-claude-skills`
|
|
||||||
|
|
||||||
## When No Skills Are Found
|
|
||||||
|
|
||||||
If no relevant skills exist:
|
|
||||||
|
|
||||||
1. Acknowledge that no existing skill was found
|
|
||||||
2. Offer to help with the task directly using your general capabilities
|
|
||||||
3. Suggest the user could create their own skill with `npx skills init`
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
```
|
|
||||||
I searched for skills related to "xyz" but didn't find any matches.
|
|
||||||
I can still help you with this task directly! Would you like me to proceed?
|
|
||||||
|
|
||||||
If this is something you do often, you could create your own skill:
|
|
||||||
npx skills init my-xyz-skill
|
|
||||||
```
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
root = true
|
|
||||||
|
|
||||||
[*]
|
|
||||||
charset = utf-8
|
|
||||||
indent_style = space
|
|
||||||
indent_size = 4
|
|
||||||
end_of_line = lf
|
|
||||||
insert_final_newline = true
|
|
||||||
trim_trailing_whitespace = true
|
|
||||||
|
|
||||||
[*.md]
|
|
||||||
trim_trailing_whitespace = false
|
|
||||||
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
VITE_API_URL=http://localhost:3000
|
VITE_API_URL=http://localhost:8000/api/v1
|
||||||
VITE_AMAP_KEY=
|
VITE_AMAP_KEY=
|
||||||
VITE_SITE_URL=http://localhost:5173
|
VITE_SITE_URL=http://localhost:5173
|
||||||
|
|||||||
@@ -10,16 +10,12 @@ lerna-debug.log*
|
|||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
dist-ssr
|
dist-ssr
|
||||||
coverage
|
|
||||||
.eslintcache
|
|
||||||
.prettiercache
|
|
||||||
*.local
|
*.local
|
||||||
.env
|
.env
|
||||||
|
|
||||||
# Editor directories and files
|
# Editor directories and files
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
!.vscode/settings.json
|
|
||||||
.idea
|
.idea
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.claude
|
.claude
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
node_modules/
|
|
||||||
dist/
|
|
||||||
dist-ssr/
|
|
||||||
coverage/
|
|
||||||
.agents/
|
|
||||||
.codex/
|
|
||||||
.claude/
|
|
||||||
.env*
|
|
||||||
package-lock.json
|
|
||||||
skills-lock.json
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"tabWidth": 4,
|
|
||||||
"useTabs": false,
|
|
||||||
"semi": false,
|
|
||||||
"singleQuote": true,
|
|
||||||
"printWidth": 100,
|
|
||||||
"trailingComma": "all",
|
|
||||||
"arrowParens": "avoid",
|
|
||||||
"endOfLine": "lf"
|
|
||||||
}
|
|
||||||
Vendored
-3
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"recommendations": ["Vue.volar", "dbaeumer.vscode-eslint", "esbenp.prettier-vscode"]
|
|
||||||
}
|
|
||||||
Vendored
-11
@@ -1,11 +0,0 @@
|
|||||||
{
|
|
||||||
"editor.tabSize": 4,
|
|
||||||
"editor.insertSpaces": true,
|
|
||||||
"editor.detectIndentation": false,
|
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
|
||||||
"editor.formatOnSave": true,
|
|
||||||
"editor.codeActionsOnSave": {
|
|
||||||
"source.fixAll.eslint": "explicit"
|
|
||||||
},
|
|
||||||
"eslint.validate": ["javascript", "typescript", "vue"]
|
|
||||||
}
|
|
||||||
@@ -3,68 +3,57 @@
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- `npm run dev` — Vite dev server with HMR
|
- `npm run dev` — Vite dev server with HMR
|
||||||
- `npm run lint` / `npm run lint:fix` — ESLint checks / automatic fixes for JS, TS, and Vue; warnings fail the check
|
|
||||||
- `npm run format:check` / `npm run format` — Prettier check / formatting for source, config, and docs
|
|
||||||
- `npm run check` — lint, formatting, style contracts, and typecheck
|
|
||||||
- `npm run check:styles` — validates shared style contracts and blocks deprecated palettes/classes
|
- `npm run check:styles` — validates shared style contracts and blocks deprecated palettes/classes
|
||||||
- `npm run build` — `vue-tsc -b && vite build` (typecheck must pass or build aborts)
|
- `npm run build` — `vue-tsc -b && vite build` (typecheck must pass or build aborts)
|
||||||
- `npm run typecheck` — standalone Vue / TypeScript typecheck
|
- `npx vue-tsc -b` — standalone typecheck (no script in package.json)
|
||||||
- `npm run preview` — previews the production build on strict port `5173`
|
- `npm run preview` — previews the production build on strict port `5173`
|
||||||
- ESLint uses `eslint.config.js`; Prettier uses `.prettierrc.json`. Keep formatting rules in Prettier. No general test runner exists; `check:styles` remains the project-specific visual-system check.
|
- No general linter, formatter, or test runner exists; `check:styles` is the project-specific visual-system check
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **Vue 3 + Vite + Tailwind CSS + Vue Router + Pinia + FastAPI + 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`)
|
- Path alias `@/` → `src/` (configured in both `vite.config.ts` and `tsconfig.app.json`)
|
||||||
- FastAPI client at `src/lib/api.ts`; API base URL comes from `VITE_API_URL`
|
- FastAPI client at `src/lib/api.ts`; API base URL comes from `VITE_API_URL`
|
||||||
- AMap loaded lazily via `src/shared/map/amap.ts` with type declarations in `src/shared/map/types/amap.d.ts`
|
- AMap loaded lazily via `src/lib/amap.ts` with type declarations in `src/types/amap.d.ts`
|
||||||
- UI language is Chinese (zh-CN)
|
- UI language is Chinese (zh-CN)
|
||||||
- All persistence, storage, authorization, and admin operations go through the FastAPI backend
|
- All persistence, storage, authorization, and admin operations go through the FastAPI backend
|
||||||
|
|
||||||
## Directory Map
|
## Directory Map
|
||||||
|
|
||||||
| Path | Purpose |
|
| Path | Purpose |
|
||||||
| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
| --- | --- |
|
||||||
| `src/features/` | Business modules; each owns its route views, components, stores, and composables as needed |
|
| `src/lib/` | Singletons and utilities: FastAPI client, amap, canvas patch, cloudTypes constants, cloudBadges canvas renderer, SEO meta builder |
|
||||||
| `src/features/auth/` | Auth views, auth store, and Better Auth client |
|
| `src/stores/` | Pinia stores: auth, clouds (cloud_types cache), encyclopedia (collection + unlock tracking), profile (user pages + cloud CRUD) |
|
||||||
| `src/features/clouds/` | Shared cloud detail/edit/like components, cloud-type and likes stores, cloud-type constants |
|
| `src/composables/` | Vue composables: `useUpload` (batch multipart upload, EXIF extraction, badge result handling) |
|
||||||
| `src/features/upload/` | UploadView, QuickUploadModal, and useUpload |
|
| `src/components/cloud/` | Cloud-related modals and widgets: ImageDetailModal, CloudEditModal, MapPickerModal, QuickUploadModal, MiniLocationMap |
|
||||||
| `src/features/profile/` | Profile views, profile store, and ContributionHeatmap |
|
| `src/components/layout/` | AppHeader (top nav bar with auth state) |
|
||||||
| `src/features/encyclopedia/` | Encyclopedia views and encyclopedia store |
|
| `src/components/profile/` | ContributionHeatmap |
|
||||||
| `src/features/{map,gallery,community,admin,system}/views/` | Remaining route views grouped by feature |
|
| `src/style.css` | Global stylesheet assembly point; imports Tailwind, tokens, base rules, and shared components |
|
||||||
| `src/shared/map/` | Reusable location picker and mini map, AMap loader, and AMap declarations |
|
| `src/styles/` | Visual system source: semantic tokens, global square-corner baseline, shared `oc-*` component contracts |
|
||||||
| `src/lib/` | Shared infrastructure: API client and adapters, SEO meta builder, Naive UI theme |
|
| `src/lib/theme.ts` | Naive UI theme overrides aligned with the semantic tokens |
|
||||||
| `src/components/layout/` | AppHeader (top nav bar with auth state) |
|
| `src/views/` | Route-level page components (see Routes below) |
|
||||||
| `src/style.css` | Global stylesheet assembly point; imports Tailwind, tokens, base rules, and shared components |
|
| `src/types/` | TypeScript types: domain models (`database.ts`), API DTOs (`api.ts`), AMap declarations (`amap.d.ts`), router meta (`router.d.ts`) |
|
||||||
| `src/styles/` | Visual system source: semantic tokens, global square-corner baseline, shared `oc-*` component contracts |
|
| `docs/style-system.md` | Concise implementation and naming guide for the visual system |
|
||||||
| `src/lib/theme.ts` | Naive UI theme overrides aligned with the semantic tokens |
|
| `scripts/check-style-system.mjs` | Automated guard against deprecated palettes and malformed shared classes |
|
||||||
| `src/router/` | Route registration, lazy view imports, guards, and per-route SEO |
|
|
||||||
| `src/types/` | Shared domain models (`database.ts`), API DTOs (`api.ts`), and router meta (`router.d.ts`) |
|
|
||||||
| `docs/project-structure.md` | File ownership, dependency conventions, and examples for placing new code |
|
|
||||||
| `docs/style-system.md` | Concise implementation and naming guide for the visual system |
|
|
||||||
| `scripts/check-style-system.mjs` | Automated guard against deprecated palettes and malformed shared classes |
|
|
||||||
|
|
||||||
- When adding or moving source files, follow `docs/project-structure.md`: colocate business code under its owning feature; keep reusable location primitives in `shared/map` and application layout in `components/layout`.
|
|
||||||
- Import concrete files directly; route views remain lazy imports in `src/router/index.ts`.
|
|
||||||
|
|
||||||
## Routes
|
## Routes
|
||||||
|
|
||||||
| Path | View | Auth Required |
|
| Path | View | Auth Required |
|
||||||
| ---------------------------------------------------------------------------------- | -------------------- | :-----------: |
|
| --- | --- | :---: |
|
||||||
| `/` | MapView | No |
|
| `/` | MapView | No |
|
||||||
| `/login`, `/register`, `/forgot-password`, `/auth/confirm`, `/auth/reset-password` | Auth views | No |
|
| `/login`, `/register`, `/forgot-password`, `/auth/confirm`, `/auth/reset-password` | Auth views | No |
|
||||||
| `/upload` | UploadView | Yes |
|
| `/upload` | UploadView | Yes |
|
||||||
| `/encyclopedia` | EncyclopediaView | Yes |
|
| `/encyclopedia` | EncyclopediaView | Yes |
|
||||||
| `/encyclopedia/:id` | CloudTypeView | Yes |
|
| `/encyclopedia/:id` | CloudTypeView | Yes |
|
||||||
| `/gallery` | GalleryView | No |
|
| `/gallery` | GalleryView | No |
|
||||||
| `/community` | CommunityView | No |
|
| `/community` | CommunityView | No |
|
||||||
| `/profile` | ProfileView (own) | Yes |
|
| `/profile` | ProfileView (own) | Yes |
|
||||||
| `/profile/settings` | ProfileSettingsView | Yes |
|
| `/profile/settings` | ProfileSettingsView | Yes |
|
||||||
| `/profile/:id` | ProfileView (public) | No |
|
| `/profile/:id` | ProfileView (public) | No |
|
||||||
| `/admin` | AdminView | Yes (admin) |
|
| `/admin` | AdminView | Yes (admin) |
|
||||||
| `/403` | ForbiddenView | No |
|
| `/403` | ForbiddenView | No |
|
||||||
| `/401` | AuthRequiredView | No |
|
| `/401` | AuthRequiredView | No |
|
||||||
| `/:pathMatch(.*)*` | NotFoundView | No |
|
| `/:pathMatch(.*)*` | NotFoundView | No |
|
||||||
|
|
||||||
- Route guards in `router/index.ts`: `requiresAuth` redirects to `/401` with the intended URL in the `redirect` query; `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`
|
- SEO meta tags applied per-route via `lib/seo.ts` in `router.afterEach`
|
||||||
@@ -128,6 +117,7 @@
|
|||||||
- **Vercel** with `vercel.json`: SPA rewrites, security headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy). No CSP or HSTS configured.
|
- **Vercel** with `vercel.json`: SPA rewrites, security headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy). No CSP or HSTS configured.
|
||||||
- Vite dev and preview servers both use strict port `5173`, matching the backend's default `CORS_ORIGINS` and `FRONTEND_URL`.
|
- 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`.
|
- **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
|
## Environment Variables
|
||||||
|
|
||||||
@@ -147,11 +137,11 @@ Backend-only variables such as database credentials, JWT secrets, SMTP settings,
|
|||||||
- The design language is a clean sky atlas: airy sky-blue surfaces, fresh eucalyptus green, square editorial panels, pixel-cloud identity, and hard offset shadows.
|
- The design language is a clean sky atlas: airy sky-blue surfaces, fresh eucalyptus green, square editorial panels, pixel-cloud identity, and hard offset shadows.
|
||||||
- Read `.agents/skills/opencloud-style/SKILL.md` before material visual changes. Treat `src/styles/tokens.css`, `src/styles/base.css`, `src/styles/components.css`, and `src/lib/theme.ts` as the implementation sources of truth.
|
- Read `.agents/skills/opencloud-style/SKILL.md` before material visual changes. Treat `src/styles/tokens.css`, `src/styles/base.css`, `src/styles/components.css`, and `src/lib/theme.ts` as the implementation sources of truth.
|
||||||
- Shared/stable decisions belong in layers:
|
- Shared/stable decisions belong in layers:
|
||||||
- tokens and palette remapping → `src/styles/tokens.css`
|
- tokens and palette remapping → `src/styles/tokens.css`
|
||||||
- global baseline and square-corner policy → `src/styles/base.css`
|
- global baseline and square-corner policy → `src/styles/base.css`
|
||||||
- cross-page semantic classes → `src/styles/components.css`
|
- cross-page semantic classes → `src/styles/components.css`
|
||||||
- feature-specific map/slider/media details → the component's scoped style
|
- feature-specific map/slider/media details → the component's scoped style
|
||||||
- one-off layout and responsive composition → Tailwind in the template
|
- one-off layout and responsive composition → Tailwind in the template
|
||||||
- The global interface is square by default. Ordinary cards, inputs, dropdowns, buttons, modals, and map controls use `0px` radius. Circular marker dots, media overlay controls, AMap-generated bubbles/cards, and the media-focused image-detail shell are explicit exceptions.
|
- The global interface is square by default. Ordinary cards, inputs, dropdowns, buttons, modals, and map controls use `0px` radius. Circular marker dots, media overlay controls, AMap-generated bubbles/cards, and the media-focused image-detail shell are explicit exceptions.
|
||||||
- Use hard zero-blur offset shadows (`--oc-shadow-xs` through `--oc-shadow-xl`) for ordinary UI depth. Interactive controls may lift by `translate(-1px, -1px)` on hover.
|
- Use hard zero-blur offset shadows (`--oc-shadow-xs` through `--oc-shadow-xl`) for ordinary UI depth. Interactive controls may lift by `translate(-1px, -1px)` on hover.
|
||||||
- Brand/interaction green is fresh eucalyptus: main `#34745d`, dark `#274c40`, light surface `#f2faf6`.
|
- Brand/interaction green is fresh eucalyptus: main `#34745d`, dark `#274c40`, light surface `#f2faf6`.
|
||||||
@@ -170,18 +160,18 @@ Backend-only variables such as database credentials, JWT secrets, SMTP settings,
|
|||||||
- Global overrides are defined in `src/lib/theme.ts` and mounted by `App.vue`; keep brand/success colors aligned with `src/styles/tokens.css`.
|
- Global overrides are defined in `src/lib/theme.ts` and mounted by `App.vue`; keep brand/success colors aligned with `src/styles/tokens.css`.
|
||||||
- **Do not rely on default Naive UI primary/secondary colors for page CTAs or panel actions.** Use the shared classes in `src/styles/components.css`.
|
- **Do not rely on default Naive UI primary/secondary colors for page CTAs or panel actions.** Use the shared classes in `src/styles/components.css`.
|
||||||
- Auth buttons use `oc-primary-button` with semantic variants:
|
- Auth buttons use `oc-primary-button` with semantic variants:
|
||||||
- `oc-primary-button--teal` — login / account-access actions
|
- `oc-primary-button--teal` — login / account-access actions
|
||||||
- `oc-primary-button--sky` — register / create / forward actions
|
- `oc-primary-button--sky` — register / create / forward actions
|
||||||
- Panel and utility buttons use `oc-panel-button` with semantic variants:
|
- Panel and utility buttons use `oc-panel-button` with semantic variants:
|
||||||
- `oc-panel-button--neutral` — white card-style utility actions
|
- `oc-panel-button--neutral` — white card-style utility actions
|
||||||
- `oc-panel-button--sky` — primary panel actions
|
- `oc-panel-button--sky` — primary panel actions
|
||||||
- `oc-panel-button--teal` — active toggles / confirm actions
|
- `oc-panel-button--teal` — active toggles / confirm actions
|
||||||
- `oc-panel-button--danger` — destructive actions
|
- `oc-panel-button--danger` — destructive actions
|
||||||
- `oc-panel-button--amber` — admin/state-toggle actions where warning emphasis fits better than danger
|
- `oc-panel-button--amber` — admin/state-toggle actions where warning emphasis fits better than danger
|
||||||
- Card-like containers should prefer shared shadow classes instead of ad-hoc shadows:
|
- Card-like containers should prefer shared shadow classes instead of ad-hoc shadows:
|
||||||
- `oc-panel-card`
|
- `oc-panel-card`
|
||||||
- `oc-panel-card-soft`
|
- `oc-panel-card-soft`
|
||||||
- `oc-empty-card`
|
- `oc-empty-card`
|
||||||
- When styling `NButton`, prefer `type="default"` plus the shared class when a custom panel button variant is intended. Otherwise Naive UI theme variables may override white backgrounds or semantic colors.
|
- When styling `NButton`, prefer `type="default"` plus the shared class when a custom panel button variant is intended. Otherwise Naive UI theme variables may override white backgrounds or semantic colors.
|
||||||
- Primary and panel actions use dark text on light semantic surfaces; do not assume primary buttons should use white text.
|
- Primary and panel actions use dark text on light semantic surfaces; do not assume primary buttons should use white text.
|
||||||
|
|
||||||
|
|||||||
@@ -19,29 +19,6 @@ npm run dev
|
|||||||
## 检查与构建
|
## 检查与构建
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run check
|
npx vue-tsc -b
|
||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
`check` 依次执行 ESLint、Prettier 格式检查、项目样式检查和 TypeScript 类型检查。也可以单独运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run lint # 检查 JS、TS 和 Vue 代码,警告也会导致失败
|
|
||||||
npm run lint:fix # 自动修复可修复的代码问题
|
|
||||||
npm run format:check # 检查格式,不修改文件
|
|
||||||
npm run format # 格式化源码、配置和文档
|
|
||||||
npm run check:styles # 检查项目视觉规范
|
|
||||||
npm run typecheck # Vue / TypeScript 类型检查
|
|
||||||
```
|
|
||||||
|
|
||||||
ESLint 使用 flat config,启用 JavaScript、TypeScript 和 Vue 推荐规则;Prettier 负责格式,使用 4 个空格缩进、单引号和无分号风格。通过 `eslint-config-prettier` 关闭冲突的格式规则,构建产物、依赖、环境文件和工具管理的目录不参与格式化。
|
|
||||||
|
|
||||||
VS Code 用户可安装工作区推荐的 Vue、ESLint、Prettier 扩展,保存时自动格式化并执行 ESLint 修复。
|
|
||||||
|
|
||||||
配置参考:[Vue ESLint 官方指南](https://eslint.vuejs.org/user-guide/)、[typescript-eslint 入门](https://typescript-eslint.io/getting-started/)、[Prettier 安装指南](https://prettier.io/docs/install)。
|
|
||||||
|
|
||||||
## 项目结构
|
|
||||||
|
|
||||||
业务代码按功能集中在 `src/features/`:页面、组件、store 和 composable 放入各自所属模块。跨业务的地图控件集中在 `src/shared/map/`,应用导航位于 `src/components/layout/`,API 客户端、全局样式和公共类型保留独立目录。
|
|
||||||
|
|
||||||
新增或移动文件时,参见[目录结构与依赖约定](docs/project-structure.md)。
|
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
# 目录结构与依赖约定
|
|
||||||
|
|
||||||
按业务功能组织文件,使一次功能修改涉及的页面、组件和状态尽量集中。文件迁移只改变源码路径,接口请求、DTO、store 导出与 ID、组件 props/events、路由 URL 和页面行为保持不变。
|
|
||||||
|
|
||||||
## 目录职责
|
|
||||||
|
|
||||||
```text
|
|
||||||
src/
|
|
||||||
├── main.ts # 初始化 Pinia、认证和路由
|
|
||||||
├── App.vue # 全局 provider 与应用布局
|
|
||||||
├── router/index.ts # 路由、懒加载、权限守卫与 SEO
|
|
||||||
├── features/
|
|
||||||
│ ├── auth/
|
|
||||||
│ │ ├── authClient.ts # 认证客户端与错误适配
|
|
||||||
│ │ ├── stores/auth.ts
|
|
||||||
│ │ └── views/ # 登录、注册、邮箱验证、密码重置
|
|
||||||
│ ├── clouds/
|
|
||||||
│ │ ├── cloudTypes.ts # 云型常量与名称查询
|
|
||||||
│ │ ├── components/ # 云图详情、编辑、点赞
|
|
||||||
│ │ └── stores/ # clouds.ts、likes.ts
|
|
||||||
│ ├── upload/
|
|
||||||
│ │ ├── components/QuickUploadModal.vue
|
|
||||||
│ │ ├── composables/useUpload.ts
|
|
||||||
│ │ └── views/UploadView.vue
|
|
||||||
│ ├── profile/
|
|
||||||
│ │ ├── components/ContributionHeatmap.vue
|
|
||||||
│ │ ├── stores/profile.ts
|
|
||||||
│ │ └── views/ # 个人主页与账号设置
|
|
||||||
│ ├── encyclopedia/
|
|
||||||
│ │ ├── stores/encyclopedia.ts
|
|
||||||
│ │ └── views/ # 图鉴列表与云型详情
|
|
||||||
│ └── {map,gallery,community,admin,system}/views/
|
|
||||||
├── shared/map/
|
|
||||||
│ ├── amap.ts # 高德加载器
|
|
||||||
│ ├── types/amap.d.ts # 高德类型声明,与同名加载器分开放置
|
|
||||||
│ └── components/ # MapPickerModal、MiniLocationMap
|
|
||||||
├── components/layout/ # 应用外壳:AppHeader
|
|
||||||
├── lib/ # api.ts、seo.ts、theme.ts
|
|
||||||
├── types/ # 公共 DTO、领域类型、路由元数据
|
|
||||||
├── styles/ # 全局设计令牌、基线、共享样式
|
|
||||||
└── style.css # 全局样式入口
|
|
||||||
```
|
|
||||||
|
|
||||||
## 文件归属
|
|
||||||
|
|
||||||
| 修改内容 | 归属位置 |
|
|
||||||
| -------------------------------------- | ---------------------------- |
|
|
||||||
| 登录、会话、验证邮件 | `features/auth/` |
|
|
||||||
| 多个页面复用的云图详情、编辑、点赞 | `features/clouds/` |
|
|
||||||
| 上传表单、上传弹窗、文件处理与上传状态 | `features/upload/` |
|
|
||||||
| 用户主页、设置、贡献热力图 | `features/profile/` |
|
|
||||||
| 图鉴列表、云型详情及其状态 | `features/encyclopedia/` |
|
|
||||||
| 首页地图时间轴、标记交互 | `features/map/` |
|
|
||||||
| 通用选点、位置预览、高德加载 | `shared/map/` |
|
|
||||||
| 应用导航和整体布局 | `components/layout/` |
|
|
||||||
| HTTP 请求与响应适配、公共 API 类型 | `lib/api.ts`、`types/api.ts` |
|
|
||||||
|
|
||||||
按职责归属文件:快捷上传虽然从地图打开,仍属于上传模块;云图点赞虽然在多个页面使用,仍属于云图业务;选点和小地图仅提供位置能力,放在公共地图模块。
|
|
||||||
|
|
||||||
## 依赖与新增文件
|
|
||||||
|
|
||||||
- 功能模块按需创建 `views/`、`components/`、`stores/`、`composables/`;只有页面的模块保留 `views/` 即可。
|
|
||||||
- 业务组件放在所属功能模块。`components/layout/` 仅存放应用布局,`lib/` 存放公共基础设施。
|
|
||||||
- 功能模块可以依赖公共地图能力、基础设施、公共类型和其他模块的具体组件、store 或 composable。`shared/map/`、`lib/` 和 `types/` 保持独立于业务模块;新增跨模块依赖时避免循环引用。
|
|
||||||
- 使用 `@/features/...`、`@/shared/...` 等具体文件路径,不增加统一导出所有页面和 store 的入口。路由继续在 `router/index.ts` 中动态导入各模块页面,保持按页面懒加载。
|
|
||||||
- API 请求继续使用 `lib/api.ts`,认证 SDK 调用集中在 `features/auth/authClient.ts` 和认证 store。DTO 与领域类型仍由 `types/` 统一提供。
|
|
||||||
- 现有 `clouds` 与 `encyclopedia` store 保留各自的缓存行为;后续如需合并缓存,应作为独立行为变更验证。
|
|
||||||
- 共享视觉规则遵循[样式体系](style-system.md),组件专属样式随组件迁移。
|
|
||||||
|
|
||||||
例如,新增上传进度组件放到 `features/upload/components/`;新增通用地图控件放到 `shared/map/components/`;新增个人主页统计 composable 放到 `features/profile/composables/`。
|
|
||||||
|
|
||||||
## 迁移验证
|
|
||||||
|
|
||||||
移动文件后更新静态 import、路由动态 import 和文档引用,并运行:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run check
|
|
||||||
npm run build
|
|
||||||
git diff --check
|
|
||||||
```
|
|
||||||
+26
-23
@@ -4,14 +4,14 @@
|
|||||||
|
|
||||||
## 分层
|
## 分层
|
||||||
|
|
||||||
| 层级 | 文件 | 职责 |
|
| 层级 | 文件 | 职责 |
|
||||||
| ---------- | --------------------------- | ----------------------------------------------------- |
|
| --- | --- | --- |
|
||||||
| 设计令牌 | `src/styles/tokens.css` | 颜色、字体、渐变、阴影、动效时长等稳定决策 |
|
| 设计令牌 | `src/styles/tokens.css` | 颜色、字体、渐变、阴影、动效时长等稳定决策 |
|
||||||
| 基础规则 | `src/styles/base.css` | 页面基线、选中文本、全局圆角策略 |
|
| 基础规则 | `src/styles/base.css` | 页面基线、选中文本、全局圆角策略 |
|
||||||
| 组件规则 | `src/styles/components.css` | 页面 Hero、容器、表单、面板、按钮等共享语义类 |
|
| 组件规则 | `src/styles/components.css` | 页面 Hero、容器、表单、面板、按钮等共享语义类 |
|
||||||
| 第三方主题 | `src/lib/theme.ts` | Naive UI 的全局主题契约;值应与设计令牌保持一致 |
|
| 第三方主题 | `src/lib/theme.ts` | Naive UI 的全局主题契约;值应与设计令牌保持一致 |
|
||||||
| 单组件样式 | Vue 文件的 `<style scoped>` | 只属于一个功能的滑杆、动画、地图或媒体细节 |
|
| 单组件样式 | Vue 文件的 `<style scoped>` | 只属于一个功能的滑杆、动画、地图或媒体细节 |
|
||||||
| 一次性布局 | Vue 模板中的 Tailwind 类 | flex/grid、间距、响应式排列及确实只出现一次的视觉调整 |
|
| 一次性布局 | Vue 模板中的 Tailwind 类 | flex/grid、间距、响应式排列及确实只出现一次的视觉调整 |
|
||||||
|
|
||||||
`src/style.css` 只负责按以上顺序装配全局样式,不再直接堆放组件规则。
|
`src/style.css` 只负责按以上顺序装配全局样式,不再直接堆放组件规则。
|
||||||
|
|
||||||
@@ -25,8 +25,8 @@ OpenCloud 的绿色应接近清晨的桉叶与嫩叶,而不是灰暗的苔藓
|
|||||||
- 不直接使用 Tailwind 默认 `green-*`、`lime-*`,也不要在组件里写亮绿色十六进制值或 RGB。
|
- 不直接使用 Tailwind 默认 `green-*`、`lime-*`,也不要在组件里写亮绿色十六进制值或 RGB。
|
||||||
- 新增绿色前先判断它属于品牌交互还是成功状态,并优先使用已有语义类或设计令牌。
|
- 新增绿色前先判断它属于品牌交互还是成功状态,并优先使用已有语义类或设计令牌。
|
||||||
|
|
||||||
| 语义 | 主色 | 深色 | 浅色表面 |
|
| 语义 | 主色 | 深色 | 浅色表面 |
|
||||||
| --------- | --------- | --------- | --------- |
|
| --- | --- | --- | --- |
|
||||||
| 品牌/交互 | `#34745d` | `#274c40` | `#f2faf6` |
|
| 品牌/交互 | `#34745d` | `#274c40` | `#f2faf6` |
|
||||||
| 成功/通过 | `#4d7f3b` | `#35522c` | `#f4faef` |
|
| 成功/通过 | `#4d7f3b` | `#35522c` | `#f4faef` |
|
||||||
|
|
||||||
@@ -44,26 +44,26 @@ OpenCloud 的绿色应接近清晨的桉叶与嫩叶,而不是灰暗的苔藓
|
|||||||
|
|
||||||
```html
|
```html
|
||||||
<section class="oc-page-hero">
|
<section class="oc-page-hero">
|
||||||
<div class="oc-container oc-container--content oc-page-hero__inner">
|
<div class="oc-container oc-container--content oc-page-hero__inner">
|
||||||
<p class="oc-page-eyebrow">Cloud Upload</p>
|
<p class="oc-page-eyebrow">Cloud Upload</p>
|
||||||
<h1 class="oc-page-title">上传云图</h1>
|
<h1 class="oc-page-title">上传云图</h1>
|
||||||
<p class="oc-page-description">页面描述</p>
|
<p class="oc-page-description">页面描述</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<main class="oc-container oc-container--content oc-page-content">
|
<main class="oc-container oc-container--content oc-page-content">
|
||||||
<!-- 页面内容 -->
|
<!-- 页面内容 -->
|
||||||
</main>
|
</main>
|
||||||
```
|
```
|
||||||
|
|
||||||
容器宽度只有三个语义档位:`oc-container--narrow`、`oc-container--content`、`oc-container--wide`。不要在同类页面重复手写 `max-w-* mx-auto px-*`。
|
容器宽度只有三个语义档位:`oc-container--narrow`、`oc-container--content`、`oc-container--wide`。不要在同类页面重复手写 `max-w-* mx-auto px-*`。
|
||||||
|
|
||||||
Hero 同时包含左侧介绍和右侧状态卡时,使用 `oc-page-hero__split`。它会在有效视口达到 `900px` 时启用接近均衡的双栏,避免浏览器缩放到 125% 后过早堆叠;需要两栏等高时同时添加 `oc-page-hero__split--stretch`。
|
|
||||||
|
|
||||||
### 表单
|
### 表单
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<label class="oc-field-label"> 拍摄时间 <span class="oc-field-required">*</span> </label>
|
<label class="oc-field-label">
|
||||||
|
拍摄时间 <span class="oc-field-required">*</span>
|
||||||
|
</label>
|
||||||
<input class="oc-field-control" />
|
<input class="oc-field-control" />
|
||||||
<p class="oc-field-help">补充说明</p>
|
<p class="oc-field-help">补充说明</p>
|
||||||
<p class="oc-field-error">错误信息</p>
|
<p class="oc-field-error">错误信息</p>
|
||||||
@@ -76,8 +76,7 @@ Hero 同时包含左侧介绍和右侧状态卡时,使用 `oc-page-hero__split
|
|||||||
- `oc-section-label`:详情面板中的字段标题。
|
- `oc-section-label`:详情面板中的字段标题。
|
||||||
- `oc-stat-label` / `oc-stat-value`:统计数据的标签和值。
|
- `oc-stat-label` / `oc-stat-value`:统计数据的标签和值。
|
||||||
- `oc-inset-panel`:详情中的浅色信息块。
|
- `oc-inset-panel`:详情中的浅色信息块。
|
||||||
- `oc-panel-card` / `oc-panel-card-soft` / `oc-panel-card-xl`:普通、柔和和大型卡片的硬阴影。
|
- `oc-panel-card` / `oc-panel-card-soft`:Naive UI 卡片的硬阴影。
|
||||||
- `oc-floating-panel` / `oc-modal-shell`:浮动菜单与普通方形弹窗的共享表面。
|
|
||||||
- `oc-surface`:普通 HTML 容器的标准白色表面。
|
- `oc-surface`:普通 HTML 容器的标准白色表面。
|
||||||
- `oc-empty-card`:空状态表面。
|
- `oc-empty-card`:空状态表面。
|
||||||
|
|
||||||
@@ -86,8 +85,12 @@ Hero 同时包含左侧介绍和右侧状态卡时,使用 `oc-page-hero__split
|
|||||||
Naive UI 按钮继续使用 `type="default"`,视觉语义由共享类控制:
|
Naive UI 按钮继续使用 `type="default"`,视觉语义由共享类控制:
|
||||||
|
|
||||||
```html
|
```html
|
||||||
<NButton class="oc-panel-button oc-panel-button--neutral" type="default"> 取消 </NButton>
|
<NButton class="oc-panel-button oc-panel-button--neutral" type="default">
|
||||||
<NButton class="oc-panel-button oc-panel-button--sky" type="default"> 保存 </NButton>
|
取消
|
||||||
|
</NButton>
|
||||||
|
<NButton class="oc-panel-button oc-panel-button--sky" type="default">
|
||||||
|
保存
|
||||||
|
</NButton>
|
||||||
```
|
```
|
||||||
|
|
||||||
页面级主操作使用 `oc-primary-button`;面板内操作使用 `oc-panel-button`。可用变体为 `neutral`、`sky`、`teal`、`amber`、`danger`,不要临时创建新的强调色。其中 `teal` 是兼容命名,视觉上使用的是清新桉叶绿。
|
页面级主操作使用 `oc-primary-button`;面板内操作使用 `oc-panel-button`。可用变体为 `neutral`、`sky`、`teal`、`amber`、`danger`,不要临时创建新的强调色。其中 `teal` 是兼容命名,视觉上使用的是清新桉叶绿。
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import js from '@eslint/js'
|
|
||||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
|
||||||
import prettier from 'eslint-config-prettier/flat'
|
|
||||||
import vue from 'eslint-plugin-vue'
|
|
||||||
import globals from 'globals'
|
|
||||||
import ts from 'typescript-eslint'
|
|
||||||
|
|
||||||
export default defineConfig([
|
|
||||||
globalIgnores([
|
|
||||||
'dist/**',
|
|
||||||
'dist-ssr/**',
|
|
||||||
'coverage/**',
|
|
||||||
'.agents/**',
|
|
||||||
'.codex/**',
|
|
||||||
'.claude/**',
|
|
||||||
]),
|
|
||||||
{
|
|
||||||
files: ['**/*.{js,mjs,cjs,ts,mts,cts,vue}'],
|
|
||||||
extends: [js.configs.recommended],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ['**/*.{ts,mts,cts,vue}'],
|
|
||||||
extends: [ts.configs.recommended],
|
|
||||||
rules: {
|
|
||||||
// vue-tsc checks undefined names, including ambient types in Vue scripts.
|
|
||||||
'no-undef': 'off',
|
|
||||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ['**/*.vue'],
|
|
||||||
extends: [vue.configs['flat/recommended']],
|
|
||||||
languageOptions: {
|
|
||||||
parserOptions: {
|
|
||||||
parser: ts.parser,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ['src/**/*.{js,ts,vue}'],
|
|
||||||
languageOptions: {
|
|
||||||
globals: globals.browser,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
files: ['*.{js,mjs,cjs,ts,mts,cts}', 'scripts/**/*.{js,mjs,cjs,ts,mts,cts}'],
|
|
||||||
languageOptions: {
|
|
||||||
globals: globals.node,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Keep formatting in Prettier; this must follow the rule presets.
|
|
||||||
prettier,
|
|
||||||
])
|
|
||||||
+11
-11
@@ -1,14 +1,14 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" sizes="any" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" sizes="any" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<meta name="description" content="OpenCloud - 活的天空地图,拍云、识别、收藏" />
|
<meta name="description" content="OpenCloud - 活的天空地图,拍云、识别、收藏" />
|
||||||
<title>OpenCloud</title>
|
<title>OpenCloud</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Generated
-1789
File diff suppressed because it is too large
Load Diff
+31
-46
@@ -1,48 +1,33 @@
|
|||||||
{
|
{
|
||||||
"name": "opencloud",
|
"name": "opencloud",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"lint": "eslint . --max-warnings=0",
|
"check:styles": "node scripts/check-style-system.mjs",
|
||||||
"lint:fix": "eslint . --fix --max-warnings=0",
|
"build": "vue-tsc -b && vite build",
|
||||||
"format": "prettier --write .",
|
"preview": "vite preview"
|
||||||
"format:check": "prettier --check .",
|
},
|
||||||
"typecheck": "vue-tsc -b",
|
"dependencies": {
|
||||||
"check": "npm run lint && npm run format:check && npm run check:styles && npm run typecheck",
|
"@amap/amap-jsapi-loader": "^1.0.1",
|
||||||
"check:styles": "node scripts/check-style-system.mjs",
|
"@vercel/speed-insights": "^2.0.0",
|
||||||
"build": "vue-tsc -b && vite build",
|
"naive-ui": "^2.44.1",
|
||||||
"preview": "vite preview"
|
"pinia": "^3.0.4",
|
||||||
},
|
"vue": "^3.5.34",
|
||||||
"dependencies": {
|
"vue-router": "^4.5.0"
|
||||||
"@amap/amap-jsapi-loader": "^1.0.1",
|
},
|
||||||
"@vercel/speed-insights": "^2.0.0",
|
"devDependencies": {
|
||||||
"better-auth": "^1.7.6",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
"naive-ui": "^2.44.1",
|
"@types/node": "^24.12.3",
|
||||||
"pinia": "^3.0.4",
|
"@vercel/analytics": "^2.0.1",
|
||||||
"vue": "^3.5.34",
|
"@vicons/tabler": "^0.13.0",
|
||||||
"vue-router": "^4.5.0"
|
"@vicons/utils": "^0.1.4",
|
||||||
},
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
"devDependencies": {
|
"@vue/tsconfig": "^0.9.1",
|
||||||
"@eslint/js": "10.0.1",
|
"tailwindcss": "^4.3.3",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"typescript": "~6.0.2",
|
||||||
"@types/node": "^24.12.3",
|
"vite": "^8.0.16",
|
||||||
"@vercel/analytics": "^2.0.1",
|
"vue-tsc": "^3.2.8"
|
||||||
"@vicons/tabler": "^0.13.0",
|
}
|
||||||
"@vicons/utils": "^0.1.4",
|
|
||||||
"@vitejs/plugin-vue": "^6.0.6",
|
|
||||||
"@vue/tsconfig": "^0.9.1",
|
|
||||||
"eslint": "10.11.0",
|
|
||||||
"eslint-config-prettier": "10.1.8",
|
|
||||||
"eslint-plugin-vue": "10.11.1",
|
|
||||||
"globals": "17.12.0",
|
|
||||||
"prettier": "3.9.9",
|
|
||||||
"tailwindcss": "^4.3.3",
|
|
||||||
"typescript": "~6.0.2",
|
|
||||||
"typescript-eslint": "8.71.0",
|
|
||||||
"vite": "^8.0.16",
|
|
||||||
"vue-eslint-parser": "10.4.1",
|
|
||||||
"vue-tsc": "^3.2.8"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,118 +5,100 @@ const projectRoot = new URL('..', import.meta.url)
|
|||||||
const sourceRoot = new URL('../src', import.meta.url)
|
const sourceRoot = new URL('../src', import.meta.url)
|
||||||
|
|
||||||
const deprecatedPatterns = [
|
const deprecatedPatterns = [
|
||||||
{
|
{
|
||||||
pattern: /\b(?:bg|border|ring|text)-(?:gray|indigo|purple|violet)-\d+\b/g,
|
pattern: /\b(?:bg|border|ring|text)-(?:gray|indigo|purple|violet)-\d+\b/g,
|
||||||
message: '使用项目约定的 slate/sky/自然绿色语义色,不要重新引入灰色或紫色系。',
|
message: '使用项目约定的 slate/sky/自然绿色语义色,不要重新引入灰色或紫色系。',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
pattern: /\b(?:bg|border|ring|text)-(?:green|lime)-\d+\b/g,
|
pattern: /\b(?:bg|border|ring|text)-(?:green|lime)-\d+\b/g,
|
||||||
message:
|
message: '绿色必须使用项目重映射后的 teal 品牌色或 emerald 成功色,不要引入默认 green/lime 荧光色。',
|
||||||
'绿色必须使用项目重映射后的 teal 品牌色或 emerald 成功色,不要引入默认 green/lime 荧光色。',
|
},
|
||||||
},
|
{
|
||||||
{
|
pattern: /#(?:f0fdfa|ccfbf1|99f6e4|5eead4|2dd4bf|14b8a6|0d9488|0f766e|115e59|134e4a)\b/gi,
|
||||||
pattern: /#(?:f0fdfa|ccfbf1|99f6e4|5eead4|2dd4bf|14b8a6|0d9488|0f766e|115e59|134e4a)\b/gi,
|
message: '检测到旧版高饱和 teal 色值,请改用自然绿色设计令牌。',
|
||||||
message: '检测到旧版高饱和 teal 色值,请改用自然绿色设计令牌。',
|
},
|
||||||
},
|
{
|
||||||
{
|
pattern: /text-xs uppercase tracking-\[0\.18em\] text-slate-500/g,
|
||||||
pattern: /#(?:c084fc|a855f7|9333ea|7e22ce)\b/gi,
|
message: '重复的小节标签应使用 oc-section-label。',
|
||||||
message: '检测到禁用的紫色硬编码,请改用 rose 或项目语义令牌。',
|
vueOnly: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
pattern: /#(?:1f2937|9ca3af)\b/gi,
|
pattern: /border border-slate-200 bg-slate-50 p-4/g,
|
||||||
message: '检测到默认 gray 调色板硬编码,请改用对应的 slate 色值或语义令牌。',
|
message: '标准内嵌信息面板应使用 oc-inset-panel。',
|
||||||
},
|
vueOnly: true,
|
||||||
{
|
},
|
||||||
pattern: /#60a5fa\b/gi,
|
{
|
||||||
message: '检测到默认 blue-400 硬编码,请按用途改用 sky 或 amber 等项目语义色。',
|
pattern: /border-b border-sky-100 bg-\[linear-gradient\(180deg,#e0f2fe_0%,#f8fafc_100%\)\]/g,
|
||||||
},
|
message: '标准页面头部应使用 oc-page-hero。',
|
||||||
{
|
vueOnly: true,
|
||||||
pattern: /text-xs uppercase tracking-\[0\.18em\] text-slate-500/g,
|
},
|
||||||
message: '重复的小节标签应使用 oc-section-label。',
|
|
||||||
vueOnly: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pattern: /border border-slate-200 bg-slate-50 p-4/g,
|
|
||||||
message: '标准内嵌信息面板应使用 oc-inset-panel。',
|
|
||||||
vueOnly: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pattern:
|
|
||||||
/border-b border-sky-100 bg-\[linear-gradient\(180deg,#e0f2fe_0%,#f8fafc_100%\)\]/g,
|
|
||||||
message: '标准页面头部应使用 oc-page-hero。',
|
|
||||||
vueOnly: true,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const styleSourceExtensions = new Set(['.vue', '.css', '.ts'])
|
const styleSourceExtensions = new Set(['.vue', '.css', '.ts'])
|
||||||
|
|
||||||
async function collectStyleSourceFiles(directory) {
|
async function collectStyleSourceFiles(directory) {
|
||||||
const entries = await readdir(directory, { withFileTypes: true })
|
const entries = await readdir(directory, { withFileTypes: true })
|
||||||
const files = await Promise.all(
|
const files = await Promise.all(entries.map(async (entry) => {
|
||||||
entries.map(async entry => {
|
const path = join(directory, entry.name)
|
||||||
const path = join(directory, entry.name)
|
return entry.isDirectory() ? collectStyleSourceFiles(path) : [path]
|
||||||
return entry.isDirectory() ? collectStyleSourceFiles(path) : [path]
|
}))
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
return files.flat().filter(path => styleSourceExtensions.has(extname(path)))
|
return files.flat().filter((path) => styleSourceExtensions.has(extname(path)))
|
||||||
}
|
}
|
||||||
|
|
||||||
function lineNumberAt(source, index) {
|
function lineNumberAt(source, index) {
|
||||||
return source.slice(0, index).split('\n').length
|
return source.slice(0, index).split('\n').length
|
||||||
}
|
}
|
||||||
|
|
||||||
const violations = []
|
const violations = []
|
||||||
|
|
||||||
for (const file of await collectStyleSourceFiles(sourceRoot.pathname)) {
|
for (const file of await collectStyleSourceFiles(sourceRoot.pathname)) {
|
||||||
const source = await readFile(file, 'utf8')
|
const source = await readFile(file, 'utf8')
|
||||||
|
|
||||||
for (const rule of deprecatedPatterns) {
|
for (const rule of deprecatedPatterns) {
|
||||||
if (rule.vueOnly && extname(file) !== '.vue') continue
|
if (rule.vueOnly && extname(file) !== '.vue') continue
|
||||||
|
|
||||||
for (const match of source.matchAll(rule.pattern)) {
|
for (const match of source.matchAll(rule.pattern)) {
|
||||||
violations.push({
|
violations.push({
|
||||||
file: relative(projectRoot.pathname, file),
|
file: relative(projectRoot.pathname, file),
|
||||||
line: lineNumberAt(source, match.index),
|
line: lineNumberAt(source, match.index),
|
||||||
message: rule.message,
|
message: rule.message,
|
||||||
value: match[0],
|
value: match[0],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const match of source.matchAll(/(?:^|\s)class="([^"]+)"/gm)) {
|
||||||
|
const classes = match[1].split(/\s+/)
|
||||||
|
const panelModifier = classes.some((name) => name.startsWith('oc-panel-button--'))
|
||||||
|
const primaryModifier = classes.some((name) => name.startsWith('oc-primary-button--'))
|
||||||
|
|
||||||
|
if (panelModifier && !classes.includes('oc-panel-button')) {
|
||||||
|
violations.push({
|
||||||
|
file: relative(projectRoot.pathname, file),
|
||||||
|
line: lineNumberAt(source, match.index),
|
||||||
|
message: 'oc-panel-button 修饰类必须与基础类一起使用。',
|
||||||
|
value: match[0],
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const match of source.matchAll(/(?:^|\s)class="([^"]+)"/gm)) {
|
if (primaryModifier && !classes.includes('oc-primary-button')) {
|
||||||
const classes = match[1].split(/\s+/)
|
violations.push({
|
||||||
const panelModifier = classes.some(name => name.startsWith('oc-panel-button--'))
|
file: relative(projectRoot.pathname, file),
|
||||||
const primaryModifier = classes.some(name => name.startsWith('oc-primary-button--'))
|
line: lineNumberAt(source, match.index),
|
||||||
|
message: 'oc-primary-button 修饰类必须与基础类一起使用。',
|
||||||
if (panelModifier && !classes.includes('oc-panel-button')) {
|
value: match[0],
|
||||||
violations.push({
|
})
|
||||||
file: relative(projectRoot.pathname, file),
|
|
||||||
line: lineNumberAt(source, match.index),
|
|
||||||
message: 'oc-panel-button 修饰类必须与基础类一起使用。',
|
|
||||||
value: match[0],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (primaryModifier && !classes.includes('oc-primary-button')) {
|
|
||||||
violations.push({
|
|
||||||
file: relative(projectRoot.pathname, file),
|
|
||||||
line: lineNumberAt(source, match.index),
|
|
||||||
message: 'oc-primary-button 修饰类必须与基础类一起使用。',
|
|
||||||
value: match[0],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (violations.length > 0) {
|
if (violations.length > 0) {
|
||||||
console.error('样式体系检查失败:')
|
console.error('样式体系检查失败:')
|
||||||
for (const violation of violations) {
|
for (const violation of violations) {
|
||||||
console.error(
|
console.error(`- ${violation.file}:${violation.line} ${violation.message} (${violation.value})`)
|
||||||
`- ${violation.file}:${violation.line} ${violation.message} (${violation.value})`,
|
}
|
||||||
)
|
process.exitCode = 1
|
||||||
}
|
|
||||||
process.exitCode = 1
|
|
||||||
} else {
|
} else {
|
||||||
console.log('样式体系检查通过。')
|
console.log('样式体系检查通过。')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"skills": {
|
"skills": {
|
||||||
"amap-jsapi-skill": {
|
|
||||||
"source": "clawhub.ai",
|
|
||||||
"sourceType": "well-known",
|
|
||||||
"computedHash": "289808e56798f1cd3241f9ab227d0ab41c9ec0d85d853991b30ad61be187d685"
|
|
||||||
},
|
|
||||||
"find-skills": {
|
|
||||||
"source": "vercel-labs/skills",
|
|
||||||
"sourceType": "github",
|
|
||||||
"skillPath": "skills/find-skills/SKILL.md",
|
|
||||||
"computedHash": "913b9d37d0d54047dd65222bb8c67b2bf04e3cb87dcad1729068d7a8b2c8c396"
|
|
||||||
},
|
|
||||||
"git-commit": {
|
"git-commit": {
|
||||||
"source": "github/awesome-copilot",
|
"source": "github/awesome-copilot",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
|
|||||||
+23
-29
@@ -1,41 +1,35 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import {
|
import { NConfigProvider, NDialogProvider, NGlobalStyle, NMessageProvider, NNotificationProvider } from 'naive-ui'
|
||||||
NConfigProvider,
|
|
||||||
NDialogProvider,
|
|
||||||
NGlobalStyle,
|
|
||||||
NMessageProvider,
|
|
||||||
NNotificationProvider,
|
|
||||||
} from 'naive-ui'
|
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute } from 'vue-router'
|
||||||
import AppHeader from '@/components/layout/AppHeader.vue'
|
import AppHeader from '@/components/layout/AppHeader.vue'
|
||||||
import { openCloudThemeOverrides } from '@/lib/theme'
|
import { openCloudThemeOverrides } from '@/lib/theme'
|
||||||
import { Analytics } from '@vercel/analytics/vue'
|
import { Analytics } from "@vercel/analytics/vue"
|
||||||
import { SpeedInsights } from '@vercel/speed-insights/vue'
|
import { SpeedInsights } from "@vercel/speed-insights/vue"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const showAppHeader = computed(() => route.name !== 'admin')
|
const showAppHeader = computed(() => route.name !== 'admin')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<NConfigProvider :theme-overrides="openCloudThemeOverrides">
|
<NConfigProvider :theme-overrides="openCloudThemeOverrides">
|
||||||
<NGlobalStyle />
|
<NGlobalStyle />
|
||||||
<NDialogProvider>
|
<NDialogProvider>
|
||||||
<NNotificationProvider>
|
<NNotificationProvider>
|
||||||
<NMessageProvider>
|
<NMessageProvider>
|
||||||
<div class="oc-app-shell">
|
<div class="oc-app-shell">
|
||||||
<AppHeader v-if="showAppHeader" />
|
<AppHeader v-if="showAppHeader" />
|
||||||
<main class="relative flex-1">
|
<main class="relative flex-1">
|
||||||
<div class="oc-app-grid"></div>
|
<div class="oc-app-grid"></div>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<RouterView />
|
<RouterView />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</NMessageProvider>
|
</NMessageProvider>
|
||||||
</NNotificationProvider>
|
</NNotificationProvider>
|
||||||
</NDialogProvider>
|
</NDialogProvider>
|
||||||
</NConfigProvider>
|
</NConfigProvider>
|
||||||
<Analytics />
|
<Analytics />
|
||||||
<SpeedInsights />
|
<SpeedInsights />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,274 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { NAlert, NButton } from 'naive-ui'
|
||||||
|
import MapPickerModal from '@/components/cloud/MapPickerModal.vue'
|
||||||
|
import type { CloudType } from '@/types/database'
|
||||||
|
|
||||||
|
export interface CloudEditFormValue {
|
||||||
|
cloudCategoryId: number | 'other' | null
|
||||||
|
customCloudType: string
|
||||||
|
capturedAt: string
|
||||||
|
latitude: number | null
|
||||||
|
longitude: number | null
|
||||||
|
locationName: string
|
||||||
|
description: string
|
||||||
|
isPublic: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
open: boolean
|
||||||
|
saving?: boolean
|
||||||
|
error?: string
|
||||||
|
cloudTypes: CloudType[]
|
||||||
|
initialValue: CloudEditFormValue | null
|
||||||
|
}>(), {
|
||||||
|
saving: false,
|
||||||
|
error: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
save: [value: CloudEditFormValue]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const form = ref<CloudEditFormValue>(createEmptyForm())
|
||||||
|
const mapPickerOpen = ref(false)
|
||||||
|
|
||||||
|
const hasCoordinates = computed(() => form.value.latitude !== null || form.value.longitude !== null)
|
||||||
|
|
||||||
|
function createEmptyForm(): CloudEditFormValue {
|
||||||
|
return {
|
||||||
|
cloudCategoryId: null,
|
||||||
|
customCloudType: '',
|
||||||
|
capturedAt: '',
|
||||||
|
latitude: null,
|
||||||
|
longitude: null,
|
||||||
|
locationName: '',
|
||||||
|
description: '',
|
||||||
|
isPublic: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cloneForm(value: CloudEditFormValue | null) {
|
||||||
|
return value ? { ...value } : createEmptyForm()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCategoryChange(event: Event) {
|
||||||
|
const value = (event.target as HTMLSelectElement).value
|
||||||
|
if (value === 'other') {
|
||||||
|
form.value.cloudCategoryId = 'other'
|
||||||
|
} else if (!value) {
|
||||||
|
form.value.cloudCategoryId = null
|
||||||
|
} else {
|
||||||
|
form.value.cloudCategoryId = Number(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLatInput(event: Event) {
|
||||||
|
const value = Number.parseFloat((event.target as HTMLInputElement).value)
|
||||||
|
form.value.latitude = Number.isNaN(value) ? null : value
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLngInput(event: Event) {
|
||||||
|
const value = Number.parseFloat((event.target as HTMLInputElement).value)
|
||||||
|
form.value.longitude = Number.isNaN(value) ? null : value
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCoordinates() {
|
||||||
|
form.value.latitude = null
|
||||||
|
form.value.longitude = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmMapPicker(payload: { latitude: number; longitude: number }) {
|
||||||
|
form.value.latitude = payload.latitude
|
||||||
|
form.value.longitude = payload.longitude
|
||||||
|
mapPickerOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
function save() {
|
||||||
|
emit('save', { ...form.value })
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.open, open => {
|
||||||
|
if (open) {
|
||||||
|
form.value = cloneForm(props.initialValue)
|
||||||
|
} else {
|
||||||
|
mapPickerOpen.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.initialValue, value => {
|
||||||
|
if (props.open) {
|
||||||
|
form.value = cloneForm(value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
class="fixed inset-0 z-[130] flex items-center justify-center bg-slate-950/60 px-4 py-6"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-2xl bg-white shadow-2xl" @click.stop>
|
||||||
|
<div class="flex items-start justify-between border-b border-slate-200 px-6 py-5">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold text-slate-900">编辑图片信息</h3>
|
||||||
|
<p class="oc-field-help">修改后会同步影响个人主页、画廊和地图中的展示。</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-icon-button"
|
||||||
|
aria-label="关闭编辑窗口"
|
||||||
|
title="关闭编辑窗口"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-[70vh] overflow-y-auto px-6 py-5">
|
||||||
|
<NAlert
|
||||||
|
v-if="error"
|
||||||
|
class="mb-5"
|
||||||
|
type="error"
|
||||||
|
:show-icon="false"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<div class="grid gap-5">
|
||||||
|
<div>
|
||||||
|
<label class="oc-field-label">类别</label>
|
||||||
|
<select
|
||||||
|
:value="form.cloudCategoryId ?? ''"
|
||||||
|
class="oc-field-control"
|
||||||
|
@change="onCategoryChange"
|
||||||
|
>
|
||||||
|
<option value="" disabled>请选择</option>
|
||||||
|
<option v-for="ct in cloudTypes" :key="ct.id" :value="ct.id">
|
||||||
|
{{ ct.name }}({{ ct.name_en }})
|
||||||
|
</option>
|
||||||
|
<option value="other">其他</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="form.cloudCategoryId === 'other'">
|
||||||
|
<label class="oc-field-label">自定义云型</label>
|
||||||
|
<input
|
||||||
|
v-model="form.customCloudType"
|
||||||
|
type="text"
|
||||||
|
class="oc-field-control"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="oc-field-label">拍摄时间</label>
|
||||||
|
<input
|
||||||
|
v-model="form.capturedAt"
|
||||||
|
type="datetime-local"
|
||||||
|
class="oc-field-control"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="oc-field-label">位置名称</label>
|
||||||
|
<input
|
||||||
|
v-model="form.locationName"
|
||||||
|
type="text"
|
||||||
|
class="oc-field-control"
|
||||||
|
placeholder="如:北京、成都"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div class="mb-1 flex items-center justify-between gap-3">
|
||||||
|
<label class="oc-field-label mb-0">经纬度</label>
|
||||||
|
<button
|
||||||
|
v-if="hasCoordinates"
|
||||||
|
type="button"
|
||||||
|
class="oc-text-button oc-text-button--muted text-xs"
|
||||||
|
@click="clearCoordinates"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
|
||||||
|
<input
|
||||||
|
:value="form.latitude !== null ? form.latitude : ''"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
placeholder="纬度"
|
||||||
|
class="oc-field-control"
|
||||||
|
@input="onLatInput"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
:value="form.longitude !== null ? form.longitude : ''"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
placeholder="经度"
|
||||||
|
class="oc-field-control"
|
||||||
|
@input="onLngInput"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-icon-button oc-icon-button--sky"
|
||||||
|
title="地图选点"
|
||||||
|
aria-label="地图选点"
|
||||||
|
@click="mapPickerOpen = true"
|
||||||
|
>
|
||||||
|
<span class="text-lg leading-none">🗺️</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="oc-field-label">图片描述</label>
|
||||||
|
<textarea
|
||||||
|
v-model="form.description"
|
||||||
|
rows="4"
|
||||||
|
class="w-full resize-none border border-slate-300 px-3 py-2.5 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||||
|
placeholder="描述一下这张图片..."
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center justify-between gap-4 border border-slate-200 bg-slate-50 px-4 py-3">
|
||||||
|
<span>
|
||||||
|
<span class="block text-sm font-medium text-slate-800">公开展示</span>
|
||||||
|
<span class="mt-1 block text-xs text-slate-500">关闭后不会出现在画廊、地图和公开主页中。</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
v-model="form.isPublic"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-5 w-5 border-slate-300 text-sky-500 focus:ring-sky-500"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-3 border-t border-slate-200 px-6 py-4">
|
||||||
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="close">取消</NButton>
|
||||||
|
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky" :loading="saving" @click="save">
|
||||||
|
保存修改
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
|
||||||
|
<MapPickerModal
|
||||||
|
:open="mapPickerOpen"
|
||||||
|
:latitude="form.latitude"
|
||||||
|
:longitude="form.longitude"
|
||||||
|
description="点击地图即可回填这张图片的经纬度。"
|
||||||
|
footer-text="保存时会继续做模糊化处理。"
|
||||||
|
@close="mapPickerOpen = false"
|
||||||
|
@confirm="confirmMapPicker"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { NCard, NProgress } from 'naive-ui'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
unlockedCount: number
|
||||||
|
totalCount: number
|
||||||
|
percent: number
|
||||||
|
isLoggedIn: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function lerpHex(a: string, b: string, t: number) {
|
||||||
|
const ah = parseInt(a.slice(1), 16)
|
||||||
|
const bh = parseInt(b.slice(1), 16)
|
||||||
|
const r = Math.round(((ah >> 16) & 0xff) + (((bh >> 16) & 0xff) - ((ah >> 16) & 0xff)) * t)
|
||||||
|
const g = Math.round(((ah >> 8) & 0xff) + (((bh >> 8) & 0xff) - ((ah >> 8) & 0xff)) * t)
|
||||||
|
const bv = Math.round((ah & 0xff) + ((bh & 0xff) - (ah & 0xff)) * t)
|
||||||
|
return `#${((r << 16) | (g << 8) | bv).toString(16).padStart(6, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function progressColor(pct: number) {
|
||||||
|
const t = Math.min(1, Math.max(0, pct / 100))
|
||||||
|
if (t <= 0.5) return lerpHex('#0ea5e9', '#f59e0b', t * 2)
|
||||||
|
return lerpHex('#f59e0b', '#ef4444', (t - 0.5) * 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressText = computed(() => `${props.unlockedCount}/${props.totalCount}`)
|
||||||
|
const currentColor = computed(() => progressColor(props.isLoggedIn ? props.percent : 0))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NCard class="oc-panel-card-soft border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm text-slate-500">当前进度</p>
|
||||||
|
<p class="mt-1 text-3xl font-bold" :style="{ color: currentColor }">{{ progressText }}</p>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex h-16 w-16 items-center justify-center border text-xl font-semibold text-white transition-colors duration-500"
|
||||||
|
:style="{ backgroundColor: currentColor, borderColor: currentColor }"
|
||||||
|
>
|
||||||
|
{{ isLoggedIn ? percent : 0 }}%
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NProgress
|
||||||
|
class="mt-5"
|
||||||
|
type="line"
|
||||||
|
:show-indicator="false"
|
||||||
|
:percentage="isLoggedIn ? percent : 0"
|
||||||
|
:color="{ stops: ['#0ea5e9', currentColor] }"
|
||||||
|
:height="12"
|
||||||
|
rail-color="#e2e8f0"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p class="mt-4 text-sm text-slate-500">
|
||||||
|
<template v-if="isLoggedIn">
|
||||||
|
已解锁 {{ unlockedCount }} 枚徽章,还差 {{ Math.max(totalCount - unlockedCount, 0) }} 枚。
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
登录后可同步你的个人图鉴进度。
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</NCard>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { InfoCircle, X } from '@vicons/tabler'
|
||||||
|
import { NIcon } from 'naive-ui'
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
open: boolean
|
||||||
|
imageUrl: string
|
||||||
|
thumbnailUrl?: string | null
|
||||||
|
imageAlt: string
|
||||||
|
title: string
|
||||||
|
subtitle?: string
|
||||||
|
badgeLabel?: string
|
||||||
|
badgeClass?: string
|
||||||
|
panelWidth?: string
|
||||||
|
}>(), {
|
||||||
|
subtitle: '',
|
||||||
|
thumbnailUrl: null,
|
||||||
|
badgeLabel: '',
|
||||||
|
badgeClass: '',
|
||||||
|
panelWidth: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const infoOpen = ref(false)
|
||||||
|
const imageLoaded = ref(false)
|
||||||
|
const previewLoaded = ref(false)
|
||||||
|
const naturalWidth = ref(0)
|
||||||
|
const naturalHeight = ref(0)
|
||||||
|
const viewportWidth = ref(0)
|
||||||
|
const viewportHeight = ref(0)
|
||||||
|
const modalVisible = ref(false)
|
||||||
|
const closing = ref(false)
|
||||||
|
let closeTimer: number | null = null
|
||||||
|
const previousBodyOverflow = ref<string | null>(null)
|
||||||
|
const previousDocumentOverflow = ref<string | null>(null)
|
||||||
|
|
||||||
|
function updateViewport() {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
viewportWidth.value = window.innerWidth
|
||||||
|
viewportHeight.value = window.innerHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
const layout = computed(() => {
|
||||||
|
const aspectRatio = naturalWidth.value > 0 && naturalHeight.value > 0
|
||||||
|
? naturalWidth.value / naturalHeight.value
|
||||||
|
: 4 / 3
|
||||||
|
const maxModalWidth = viewportWidth.value > 0
|
||||||
|
? Math.max(Math.min(viewportWidth.value * 0.92, viewportWidth.value - 32), 280)
|
||||||
|
: 320
|
||||||
|
const maxImageHeight = viewportHeight.value > 0
|
||||||
|
? Math.max(viewportHeight.value * 0.86, 240)
|
||||||
|
: 360
|
||||||
|
const imageWidth = Math.min(maxModalWidth, maxImageHeight * aspectRatio)
|
||||||
|
|
||||||
|
return {
|
||||||
|
imageWidth,
|
||||||
|
imageHeight: imageWidth / aspectRatio,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const imageStyle = computed(() => ({
|
||||||
|
width: `${Math.round(layout.value.imageWidth)}px`,
|
||||||
|
height: `${Math.round(layout.value.imageHeight)}px`,
|
||||||
|
}) as Record<string, string>)
|
||||||
|
|
||||||
|
function resetImageState() {
|
||||||
|
imageLoaded.value = false
|
||||||
|
previewLoaded.value = false
|
||||||
|
naturalWidth.value = 0
|
||||||
|
naturalHeight.value = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function lockPageScroll() {
|
||||||
|
if (typeof document === 'undefined' || previousBodyOverflow.value !== null) return
|
||||||
|
previousBodyOverflow.value = document.body.style.overflow
|
||||||
|
previousDocumentOverflow.value = document.documentElement.style.overflow
|
||||||
|
document.body.style.overflow = 'hidden'
|
||||||
|
document.documentElement.style.overflow = 'hidden'
|
||||||
|
}
|
||||||
|
|
||||||
|
function unlockPageScroll() {
|
||||||
|
if (typeof document === 'undefined' || previousBodyOverflow.value === null) return
|
||||||
|
document.body.style.overflow = previousBodyOverflow.value
|
||||||
|
document.documentElement.style.overflow = previousDocumentOverflow.value ?? ''
|
||||||
|
previousBodyOverflow.value = null
|
||||||
|
previousDocumentOverflow.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.open, open => {
|
||||||
|
if (open) {
|
||||||
|
if (closeTimer !== null) {
|
||||||
|
window.clearTimeout(closeTimer)
|
||||||
|
closeTimer = null
|
||||||
|
}
|
||||||
|
closing.value = false
|
||||||
|
modalVisible.value = true
|
||||||
|
lockPageScroll()
|
||||||
|
infoOpen.value = false
|
||||||
|
resetImageState()
|
||||||
|
} else {
|
||||||
|
modalVisible.value = false
|
||||||
|
unlockPageScroll()
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
watch(() => props.imageUrl, () => {
|
||||||
|
resetImageState()
|
||||||
|
})
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (closing.value) return
|
||||||
|
closing.value = true
|
||||||
|
modalVisible.value = false
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
unlockPageScroll()
|
||||||
|
emit('close')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
closeTimer = window.setTimeout(() => {
|
||||||
|
closeTimer = null
|
||||||
|
unlockPageScroll()
|
||||||
|
emit('close')
|
||||||
|
}, 180)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleInfo() {
|
||||||
|
infoOpen.value = !infoOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleImageLoad(event: Event) {
|
||||||
|
const target = event.target as HTMLImageElement | null
|
||||||
|
if (!target) return
|
||||||
|
naturalWidth.value = target.naturalWidth
|
||||||
|
naturalHeight.value = target.naturalHeight
|
||||||
|
imageLoaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePreviewLoad(event: Event) {
|
||||||
|
const target = event.target as HTMLImageElement | null
|
||||||
|
if (!target) return
|
||||||
|
if (!naturalWidth.value || !naturalHeight.value) {
|
||||||
|
naturalWidth.value = target.naturalWidth
|
||||||
|
naturalHeight.value = target.naturalHeight
|
||||||
|
}
|
||||||
|
previewLoaded.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateViewport()
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('resize', updateViewport)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (closeTimer !== null) {
|
||||||
|
window.clearTimeout(closeTimer)
|
||||||
|
}
|
||||||
|
unlockPageScroll()
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.removeEventListener('resize', updateViewport)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<Transition
|
||||||
|
appear
|
||||||
|
name="image-detail-modal"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="modalVisible"
|
||||||
|
class="fixed inset-0 z-[110] flex items-center justify-center bg-black/85 px-4 py-6"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="image-detail-shell relative w-fit max-w-[92vw] overflow-hidden rounded-[30px] shadow-2xl"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="close"
|
||||||
|
class="image-detail-close-button oc-overlay-button absolute right-4 top-4 z-30 h-10 w-10 rounded-full [&_svg]:stroke-[2.5]"
|
||||||
|
aria-label="关闭大图"
|
||||||
|
title="关闭大图"
|
||||||
|
>
|
||||||
|
<NIcon size="22">
|
||||||
|
<X />
|
||||||
|
</NIcon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="relative flex min-h-0 items-center justify-center overflow-hidden bg-slate-950"
|
||||||
|
:style="imageStyle"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="!previewLoaded && !imageLoaded"
|
||||||
|
class="absolute inset-0 animate-pulse bg-[linear-gradient(110deg,#0f172a_0%,#1e293b_45%,#334155_55%,#0f172a_100%)] bg-[length:220%_100%]"
|
||||||
|
></div>
|
||||||
|
<img
|
||||||
|
v-if="thumbnailUrl"
|
||||||
|
:src="thumbnailUrl"
|
||||||
|
:alt="imageAlt"
|
||||||
|
class="absolute inset-0 block h-full w-full object-contain blur-[1px] transition-opacity duration-300"
|
||||||
|
:class="imageLoaded ? 'opacity-0' : 'opacity-100'"
|
||||||
|
@load="handlePreviewLoad"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
:src="imageUrl"
|
||||||
|
:alt="imageAlt"
|
||||||
|
class="relative block h-full w-full object-contain transition-opacity duration-300"
|
||||||
|
:class="imageLoaded ? 'opacity-100' : 'opacity-0'"
|
||||||
|
@load="handleImageLoad"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="toggleInfo"
|
||||||
|
class="oc-overlay-button absolute bottom-4 right-4 z-30 h-11 w-11 rounded-full drop-shadow-[0_2px_8px_rgba(15,23,42,0.9)]"
|
||||||
|
:aria-label="infoOpen ? '隐藏详细信息' : '查看详细信息'"
|
||||||
|
:title="infoOpen ? '隐藏详细信息' : '查看详细信息'"
|
||||||
|
>
|
||||||
|
<NIcon size="30">
|
||||||
|
<InfoCircle />
|
||||||
|
</NIcon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="infoOpen"
|
||||||
|
class="absolute bottom-16 right-4 z-20 flex max-h-[min(70vh,560px)] w-[min(360px,calc(100%-2rem))] flex-col overflow-hidden border border-white/70 bg-white/95 shadow-2xl backdrop-blur"
|
||||||
|
>
|
||||||
|
<div class="border-b border-slate-200 px-5 py-4">
|
||||||
|
<div class="pr-8">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h2 class="text-xl font-bold text-slate-900">{{ title }}</h2>
|
||||||
|
<span
|
||||||
|
v-if="badgeLabel"
|
||||||
|
class="rounded-full border px-3 py-1 text-xs font-medium"
|
||||||
|
:class="badgeClass"
|
||||||
|
>
|
||||||
|
{{ badgeLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="subtitle" class="mt-2 text-sm text-slate-500">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="infoOpen = false"
|
||||||
|
class="oc-icon-button oc-icon-button--small absolute right-3 top-3 rounded-full"
|
||||||
|
aria-label="隐藏详细信息"
|
||||||
|
title="隐藏详细信息"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto px-5 py-4">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="$slots.actions" class="border-t border-slate-200 px-5 py-4">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.image-detail-modal-enter-active,
|
||||||
|
.image-detail-modal-leave-active {
|
||||||
|
transition: opacity 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-detail-modal-enter-active .image-detail-shell,
|
||||||
|
.image-detail-modal-leave-active .image-detail-shell {
|
||||||
|
transition:
|
||||||
|
opacity 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-detail-modal-enter-from,
|
||||||
|
.image-detail-modal-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-detail-modal-enter-from .image-detail-shell,
|
||||||
|
.image-detail-modal-leave-to .image-detail-shell {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(10px) scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-detail-close-button.image-detail-close-button {
|
||||||
|
border-radius: 9999px !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import { NButton } from 'naive-ui'
|
||||||
|
import { loadAMap } from '@/lib/amap'
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
open: boolean
|
||||||
|
latitude: number | null
|
||||||
|
longitude: number | null
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
footerText?: string
|
||||||
|
}>(), {
|
||||||
|
title: '地图选点',
|
||||||
|
description: '点击地图即可回填经纬度,也可以继续手动修改。',
|
||||||
|
footerText: '建议点选大致拍摄位置,保存时会继续做模糊化处理。',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
confirm: [payload: { latitude: number; longitude: number }]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const pickedLat = ref<number | null>(null)
|
||||||
|
const pickedLng = ref<number | null>(null)
|
||||||
|
const mapEl = ref<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
|
let mapPickerAMap: typeof AMap | null = null
|
||||||
|
let mapPickerInstance: AMap.Map | null = null
|
||||||
|
let mapPickerMarker: AMap.Marker | null = null
|
||||||
|
|
||||||
|
function formatCoordinate(value: number | null) {
|
||||||
|
return value === null ? '未选择' : value.toFixed(6)
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyMapPicker() {
|
||||||
|
mapPickerMarker?.setMap(null)
|
||||||
|
mapPickerMarker = null
|
||||||
|
mapPickerInstance?.destroy()
|
||||||
|
mapPickerInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function placeMarker(longitude: number, latitude: number) {
|
||||||
|
if (!mapPickerAMap || !mapPickerInstance) return
|
||||||
|
|
||||||
|
if (!mapPickerMarker) {
|
||||||
|
mapPickerMarker = new mapPickerAMap.Marker({
|
||||||
|
position: [longitude, latitude],
|
||||||
|
title: '拍摄位置',
|
||||||
|
} as AMap.MarkerOptions)
|
||||||
|
mapPickerMarker.setMap(mapPickerInstance)
|
||||||
|
} else {
|
||||||
|
mapPickerMarker.setPosition([longitude, latitude])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLngLat(event: unknown) {
|
||||||
|
const payload = event as { lnglat?: { lng?: number; lat?: number } } | undefined
|
||||||
|
const lng = payload?.lnglat?.lng
|
||||||
|
const lat = payload?.lnglat?.lat
|
||||||
|
|
||||||
|
if (typeof lng !== 'number' || typeof lat !== 'number') return null
|
||||||
|
return { lng, lat }
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMapClick(event: unknown) {
|
||||||
|
const picked = extractLngLat(event)
|
||||||
|
if (!picked) return
|
||||||
|
|
||||||
|
pickedLng.value = picked.lng
|
||||||
|
pickedLat.value = picked.lat
|
||||||
|
placeMarker(picked.lng, picked.lat)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeMap() {
|
||||||
|
loading.value = true
|
||||||
|
errorMsg.value = ''
|
||||||
|
pickedLat.value = props.latitude
|
||||||
|
pickedLng.value = props.longitude
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
destroyMapPicker()
|
||||||
|
|
||||||
|
try {
|
||||||
|
mapPickerAMap = await loadAMap()
|
||||||
|
|
||||||
|
const hasCoordinates =
|
||||||
|
typeof props.latitude === 'number' &&
|
||||||
|
typeof props.longitude === 'number'
|
||||||
|
|
||||||
|
const center: [number, number] = hasCoordinates
|
||||||
|
? [props.longitude as number, props.latitude as number]
|
||||||
|
: [104.07, 30.67]
|
||||||
|
|
||||||
|
mapPickerInstance = new mapPickerAMap.Map(mapEl.value!, {
|
||||||
|
viewMode: '2D',
|
||||||
|
pitch: 0,
|
||||||
|
rotation: 0,
|
||||||
|
zoom: hasCoordinates ? 11 : 4,
|
||||||
|
center,
|
||||||
|
mapStyle: 'amap://styles/normal',
|
||||||
|
resizeEnable: true,
|
||||||
|
} as AMap.MapOptions)
|
||||||
|
|
||||||
|
mapPickerInstance.on('click', handleMapClick)
|
||||||
|
|
||||||
|
if (hasCoordinates) {
|
||||||
|
placeMarker(props.longitude as number, props.latitude as number)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errorMsg.value = error instanceof Error ? error.message : '地图加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirm() {
|
||||||
|
if (pickedLat.value === null || pickedLng.value === null) return
|
||||||
|
emit('confirm', {
|
||||||
|
latitude: pickedLat.value,
|
||||||
|
longitude: pickedLng.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.open, open => {
|
||||||
|
if (open) {
|
||||||
|
initializeMap()
|
||||||
|
} else {
|
||||||
|
loading.value = false
|
||||||
|
errorMsg.value = ''
|
||||||
|
destroyMapPicker()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
destroyMapPicker()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
class="fixed inset-0 z-[140] flex items-center justify-center bg-slate-950/55 px-4 py-6"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-3xl overflow-hidden bg-white shadow-2xl" @click.stop>
|
||||||
|
<div class="flex items-start justify-between border-b border-slate-200 px-6 py-5">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold text-slate-900">{{ title }}</h3>
|
||||||
|
<p class="oc-field-help">{{ description }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-icon-button"
|
||||||
|
aria-label="关闭地图选点窗口"
|
||||||
|
title="关闭地图选点窗口"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="px-6 py-5">
|
||||||
|
<div class="mb-4 flex flex-wrap items-center gap-3 border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||||
|
<span>当前选择:</span>
|
||||||
|
<span class="font-medium text-slate-900">纬度 {{ formatCoordinate(pickedLat) }}</span>
|
||||||
|
<span class="font-medium text-slate-900">经度 {{ formatCoordinate(pickedLng) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative overflow-hidden border border-slate-200 bg-slate-100">
|
||||||
|
<div ref="mapEl" class="h-[420px] w-full"></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="absolute inset-0 flex items-center justify-center bg-white/80 text-sm font-medium text-slate-600"
|
||||||
|
>
|
||||||
|
正在加载地图...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="errorMsg"
|
||||||
|
class="absolute inset-0 flex items-center justify-center bg-white/90 px-6 text-center text-sm text-red-600"
|
||||||
|
>
|
||||||
|
{{ errorMsg }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between border-t border-slate-200 px-6 py-4">
|
||||||
|
<p class="text-sm text-slate-500">{{ footerText }}</p>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="close">取消</NButton>
|
||||||
|
<NButton
|
||||||
|
type="default"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
class="oc-panel-button oc-panel-button--sky"
|
||||||
|
:disabled="pickedLat === null || pickedLng === null"
|
||||||
|
@click="confirm"
|
||||||
|
>
|
||||||
|
使用这个位置
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import { loadAMap } from '@/lib/amap'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
latitude: number | null
|
||||||
|
longitude: number | null
|
||||||
|
locationName?: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const mapEl = ref<HTMLDivElement | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
|
||||||
|
let amapLib: typeof AMap | null = null
|
||||||
|
let mapInstance: AMap.Map | null = null
|
||||||
|
let marker: AMap.Marker | null = null
|
||||||
|
|
||||||
|
const hasCoordinates = computed(() => {
|
||||||
|
return Number.isFinite(props.latitude) && Number.isFinite(props.longitude)
|
||||||
|
})
|
||||||
|
|
||||||
|
const center = computed<[number, number] | null>(() => {
|
||||||
|
if (!hasCoordinates.value) return null
|
||||||
|
return [props.longitude as number, props.latitude as number]
|
||||||
|
})
|
||||||
|
|
||||||
|
function destroyMap() {
|
||||||
|
marker?.setMap(null)
|
||||||
|
marker = null
|
||||||
|
mapInstance?.destroy()
|
||||||
|
mapInstance = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeMap() {
|
||||||
|
if (!center.value) {
|
||||||
|
destroyMap()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
errorMsg.value = ''
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
destroyMap()
|
||||||
|
|
||||||
|
if (!mapEl.value) {
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
amapLib = await loadAMap()
|
||||||
|
if ('getConfig' in amapLib) {
|
||||||
|
amapLib.getConfig().appname = 'amap-jsapi-skill'
|
||||||
|
}
|
||||||
|
|
||||||
|
mapInstance = new amapLib.Map(mapEl.value, {
|
||||||
|
viewMode: '2D',
|
||||||
|
zoom: 11,
|
||||||
|
center: center.value,
|
||||||
|
mapStyle: 'amap://styles/normal',
|
||||||
|
features: ['bg', 'road', 'building', 'point'],
|
||||||
|
resizeEnable: true,
|
||||||
|
dragEnable: true,
|
||||||
|
zoomEnable: true,
|
||||||
|
keyboardEnable: false,
|
||||||
|
doubleClickZoom: true,
|
||||||
|
scrollWheel: true,
|
||||||
|
} as AMap.MapOptions)
|
||||||
|
|
||||||
|
mapInstance.addControl(new amapLib.ToolBar({
|
||||||
|
position: 'RT',
|
||||||
|
offset: [8, 8],
|
||||||
|
} as Record<string, unknown>))
|
||||||
|
|
||||||
|
marker = new amapLib.Marker({
|
||||||
|
position: center.value,
|
||||||
|
title: props.locationName || '拍摄位置',
|
||||||
|
content: '<div class="h-4 w-4 rounded-full border-2 border-white bg-teal-500 shadow-[0_0_0_4px_rgba(52,116,93,0.24)]"></div>',
|
||||||
|
offset: new amapLib.Pixel(-8, -8),
|
||||||
|
} as AMap.MarkerOptions)
|
||||||
|
marker.setMap(mapInstance)
|
||||||
|
} catch (error) {
|
||||||
|
errorMsg.value = error instanceof Error ? error.message : '地图加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(center, () => {
|
||||||
|
initializeMap()
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
destroyMap()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="hasCoordinates" class="mt-5 overflow-hidden border border-slate-200 bg-white">
|
||||||
|
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<p class="oc-section-label">位置地图</p>
|
||||||
|
<p class="mt-1 text-sm font-medium text-slate-900">{{ locationName || '拍摄位置' }}</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-right text-[11px] leading-5 text-slate-500">
|
||||||
|
{{ latitude?.toFixed(4) }}<br />
|
||||||
|
{{ longitude?.toFixed(4) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative h-40 bg-sky-50">
|
||||||
|
<div ref="mapEl" class="h-full w-full"></div>
|
||||||
|
<div v-if="loading" class="absolute inset-0 flex items-center justify-center bg-white/70 text-sm text-slate-500">
|
||||||
|
地图加载中...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="errorMsg" class="absolute inset-0 flex items-center justify-center bg-white/85 px-4 text-center text-sm text-slate-500">
|
||||||
|
{{ errorMsg }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
import { NAlert, NButton, NIcon, NProgress } from 'naive-ui'
|
||||||
|
import { CloudUpload, Map as MapIcon, X } from '@vicons/tabler'
|
||||||
|
import MapPickerModal from '@/components/cloud/MapPickerModal.vue'
|
||||||
|
import { useCloudsStore } from '@/stores/clouds'
|
||||||
|
import { useUpload } from '@/composables/useUpload'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
uploaded: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const cloudsStore = useCloudsStore()
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
uploading,
|
||||||
|
overallProgress,
|
||||||
|
currentItemIndex,
|
||||||
|
totalItems,
|
||||||
|
addFiles,
|
||||||
|
removeItem,
|
||||||
|
clearAll,
|
||||||
|
validateAll,
|
||||||
|
uploadAll,
|
||||||
|
} = useUpload()
|
||||||
|
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const activeId = ref<string | null>(null)
|
||||||
|
const dragOver = ref(false)
|
||||||
|
const errorMsg = ref('')
|
||||||
|
const successMsg = ref(false)
|
||||||
|
const mapPickerOpen = ref(false)
|
||||||
|
|
||||||
|
const activeItem = computed(() => {
|
||||||
|
if (!activeId.value) return null
|
||||||
|
return items.value.find(item => item.id === activeId.value) ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.open, open => {
|
||||||
|
if (open) {
|
||||||
|
cloudsStore.fetchCloudTypes()
|
||||||
|
errorMsg.value = ''
|
||||||
|
successMsg.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => items.value.length, () => {
|
||||||
|
if (items.value.length > 0 && !activeId.value) {
|
||||||
|
activeId.value = items.value[0].id
|
||||||
|
}
|
||||||
|
if (items.value.length === 0) {
|
||||||
|
activeId.value = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
async function addSelectedFiles(files: File[]) {
|
||||||
|
const previousLength = items.value.length
|
||||||
|
await addFiles(files)
|
||||||
|
if (items.value.length > previousLength) {
|
||||||
|
activeId.value = items.value[previousLength].id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(event: DragEvent) {
|
||||||
|
dragOver.value = false
|
||||||
|
if (event.dataTransfer?.files) {
|
||||||
|
addSelectedFiles(Array.from(event.dataTransfer.files))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFileSelect(event: Event) {
|
||||||
|
const target = event.target as HTMLInputElement
|
||||||
|
if (target.files?.length) {
|
||||||
|
addSelectedFiles(Array.from(target.files))
|
||||||
|
}
|
||||||
|
target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRemove(id: string) {
|
||||||
|
removeItem(id)
|
||||||
|
if (activeId.value === id) {
|
||||||
|
activeId.value = items.value[0]?.id ?? null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCategoryChange(event: Event) {
|
||||||
|
if (!activeItem.value) return
|
||||||
|
const value = (event.target as HTMLSelectElement).value
|
||||||
|
if (value === 'other') {
|
||||||
|
activeItem.value.cloudCategoryId = 'other'
|
||||||
|
} else if (value === '') {
|
||||||
|
activeItem.value.cloudCategoryId = null
|
||||||
|
} else {
|
||||||
|
activeItem.value.cloudCategoryId = Number(value)
|
||||||
|
}
|
||||||
|
activeItem.value.errors = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDatetimeLocal(iso: string): string {
|
||||||
|
const date = new Date(iso)
|
||||||
|
const pad = (value: number) => String(value).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCapturedAtChange(event: Event) {
|
||||||
|
if (!activeItem.value) return
|
||||||
|
const value = (event.target as HTMLInputElement).value
|
||||||
|
if (!value) return
|
||||||
|
activeItem.value.capturedAt = new Date(value).toISOString()
|
||||||
|
delete activeItem.value.errors.capturedAt
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLatInput(event: Event) {
|
||||||
|
if (!activeItem.value) return
|
||||||
|
const value = Number.parseFloat((event.target as HTMLInputElement).value)
|
||||||
|
activeItem.value.latitude = Number.isNaN(value) ? null : value
|
||||||
|
delete activeItem.value.errors.latitude
|
||||||
|
delete activeItem.value.errors.longitude
|
||||||
|
}
|
||||||
|
|
||||||
|
function onLngInput(event: Event) {
|
||||||
|
if (!activeItem.value) return
|
||||||
|
const value = Number.parseFloat((event.target as HTMLInputElement).value)
|
||||||
|
activeItem.value.longitude = Number.isNaN(value) ? null : value
|
||||||
|
delete activeItem.value.errors.latitude
|
||||||
|
delete activeItem.value.errors.longitude
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePublicState(isPublic: boolean) {
|
||||||
|
if (!activeItem.value) return
|
||||||
|
activeItem.value.isHidden = !isPublic
|
||||||
|
}
|
||||||
|
|
||||||
|
function openMapPicker() {
|
||||||
|
if (!activeItem.value) return
|
||||||
|
mapPickerOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeMapPicker() {
|
||||||
|
mapPickerOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function confirmMapPicker(payload: { latitude: number; longitude: number }) {
|
||||||
|
if (!activeItem.value) return
|
||||||
|
activeItem.value.latitude = payload.latitude
|
||||||
|
activeItem.value.longitude = payload.longitude
|
||||||
|
delete activeItem.value.errors.latitude
|
||||||
|
delete activeItem.value.errors.longitude
|
||||||
|
closeMapPicker()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
errorMsg.value = ''
|
||||||
|
successMsg.value = false
|
||||||
|
|
||||||
|
if (!validateAll()) {
|
||||||
|
const firstInvalid = items.value.find(item => Object.keys(item.errors).length > 0)
|
||||||
|
if (firstInvalid) {
|
||||||
|
activeId.value = firstInvalid.id
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await uploadAll()
|
||||||
|
if (!result.ok) {
|
||||||
|
errorMsg.value = '上传失败,请稍后重试'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
successMsg.value = true
|
||||||
|
emit('uploaded')
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (uploading.value) return
|
||||||
|
clearAll()
|
||||||
|
errorMsg.value = ''
|
||||||
|
successMsg.value = false
|
||||||
|
mapPickerOpen.value = false
|
||||||
|
emit('close')
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearAll()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div
|
||||||
|
v-if="open"
|
||||||
|
class="fixed inset-0 z-[130] flex items-center justify-center bg-slate-950/55 px-4 py-6 backdrop-blur-sm"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<div class="w-full max-w-3xl overflow-hidden border border-slate-200 bg-white shadow-2xl" @click.stop>
|
||||||
|
<div class="flex items-start justify-between border-b border-slate-200 px-5 py-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-sky-700">Quick Upload</p>
|
||||||
|
<h2 class="mt-2 text-xl font-bold text-slate-950">快速上传云图</h2>
|
||||||
|
<p class="oc-field-help">从地图当前位置带入经纬度,提交后进入审核队列。</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="quick-upload-close oc-icon-button"
|
||||||
|
aria-label="关闭快捷上传"
|
||||||
|
title="关闭快捷上传"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
<NIcon size="20"><X /></NIcon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-[72vh] overflow-y-auto px-5 py-5">
|
||||||
|
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="handleFileSelect" />
|
||||||
|
|
||||||
|
<NAlert v-if="successMsg" type="success" :bordered="false" title="已提交审核" class="mb-4">
|
||||||
|
图片审核通过后会出现在画廊和地图中。
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<div v-if="uploading" class="mb-4 border border-sky-100 bg-sky-50 p-4">
|
||||||
|
<div class="mb-2 flex items-center justify-between text-sm">
|
||||||
|
<span class="font-medium text-slate-700">正在上传 {{ currentItemIndex }} / {{ totalItems }}</span>
|
||||||
|
<span class="font-medium text-sky-700">{{ overallProgress }}%</span>
|
||||||
|
</div>
|
||||||
|
<NProgress type="line" :percentage="overallProgress" :show-indicator="false" color="#0ea5e9" rail-color="#dbeafe" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="items.length === 0"
|
||||||
|
class="flex cursor-pointer flex-col items-center justify-center border-2 border-dashed px-6 py-12 text-center transition-colors"
|
||||||
|
:class="dragOver ? 'border-sky-400 bg-sky-50' : 'border-slate-300 bg-slate-50 hover:border-sky-300 hover:bg-sky-50'"
|
||||||
|
@click="fileInput?.click()"
|
||||||
|
@dragover.prevent="dragOver = true"
|
||||||
|
@dragleave.prevent="dragOver = false"
|
||||||
|
@drop.prevent="handleDrop"
|
||||||
|
>
|
||||||
|
<NIcon size="42" class="text-sky-600"><CloudUpload /></NIcon>
|
||||||
|
<p class="mt-3 text-base font-semibold text-slate-800">选择一张云图</p>
|
||||||
|
<p class="oc-field-help">支持 JPG、PNG,可点击或拖拽上传。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="activeItem">
|
||||||
|
<div class="grid gap-5 md:grid-cols-[220px_minmax(0,1fr)]">
|
||||||
|
<div>
|
||||||
|
<div class="overflow-hidden border border-slate-900 bg-slate-950">
|
||||||
|
<img :src="activeItem.preview" alt="图片预览" class="h-56 w-full object-contain" />
|
||||||
|
</div>
|
||||||
|
<div class="mt-3 flex items-center justify-between gap-2">
|
||||||
|
<button type="button" class="oc-text-button text-sm" @click="fileInput?.click()">重新选择</button>
|
||||||
|
<button type="button" class="oc-text-button oc-text-button--danger text-sm" @click="handleRemove(activeItem.id)">移除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="oc-field-label">类别 <span class="oc-field-required">*</span></label>
|
||||||
|
<select
|
||||||
|
:value="activeItem.cloudCategoryId ?? ''"
|
||||||
|
class="oc-field-control"
|
||||||
|
@change="onCategoryChange"
|
||||||
|
>
|
||||||
|
<option value="" disabled>请选择</option>
|
||||||
|
<option v-for="cloudType in cloudsStore.cloudTypes" :key="cloudType.id" :value="cloudType.id">
|
||||||
|
{{ cloudType.name }}({{ cloudType.name_en }})
|
||||||
|
</option>
|
||||||
|
<option value="other">其他</option>
|
||||||
|
</select>
|
||||||
|
<input
|
||||||
|
v-if="activeItem.cloudCategoryId === 'other'"
|
||||||
|
v-model="activeItem.customCloudType"
|
||||||
|
type="text"
|
||||||
|
placeholder="输入云的类型"
|
||||||
|
class="oc-field-control mt-2"
|
||||||
|
@input="activeItem.errors = {}"
|
||||||
|
/>
|
||||||
|
<p v-if="activeItem.errors.cloudCategory" class="oc-field-error">{{ activeItem.errors.cloudCategory }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="oc-field-label">拍摄时间 <span class="oc-field-required">*</span></label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
:value="formatDatetimeLocal(activeItem.capturedAt)"
|
||||||
|
class="oc-field-control"
|
||||||
|
@change="onCapturedAtChange"
|
||||||
|
/>
|
||||||
|
<p v-if="activeItem.errors.capturedAt" class="oc-field-error">{{ activeItem.errors.capturedAt }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="oc-field-label">经纬度</label>
|
||||||
|
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_40px] gap-2">
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
:value="activeItem.latitude ?? ''"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
placeholder="纬度"
|
||||||
|
class="oc-field-control"
|
||||||
|
@input="onLatInput"
|
||||||
|
/>
|
||||||
|
<p v-if="activeItem.errors.latitude" class="oc-field-error">{{ activeItem.errors.latitude }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<input
|
||||||
|
:value="activeItem.longitude ?? ''"
|
||||||
|
type="number"
|
||||||
|
step="any"
|
||||||
|
placeholder="经度"
|
||||||
|
class="oc-field-control"
|
||||||
|
@input="onLngInput"
|
||||||
|
/>
|
||||||
|
<p v-if="activeItem.errors.longitude" class="oc-field-error">{{ activeItem.errors.longitude }}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-icon-button oc-icon-button--sky"
|
||||||
|
title="地图选点"
|
||||||
|
aria-label="地图选点"
|
||||||
|
@click="openMapPicker"
|
||||||
|
>
|
||||||
|
<NIcon size="20">
|
||||||
|
<MapIcon />
|
||||||
|
</NIcon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-slate-400">选填,可手动输入,也可以点击右侧地图按钮选点。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="oc-field-label">位置名称</label>
|
||||||
|
<input
|
||||||
|
v-model="activeItem.locationName"
|
||||||
|
type="text"
|
||||||
|
placeholder="如:北京、成都"
|
||||||
|
class="oc-field-control"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="oc-field-label">图片说明</label>
|
||||||
|
<textarea
|
||||||
|
v-model="activeItem.description"
|
||||||
|
rows="3"
|
||||||
|
placeholder="描述一下这张图片..."
|
||||||
|
class="oc-field-control resize-none"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex items-center justify-between gap-4 border border-slate-200 bg-slate-50 px-4 py-3">
|
||||||
|
<span>
|
||||||
|
<span class="block text-sm font-medium text-slate-800">公开展示</span>
|
||||||
|
<span class="mt-1 block text-xs text-slate-500">关闭后不会出现在公开地图和画廊中。</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
:checked="!activeItem.isHidden"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-5 w-5 border-slate-300 text-sky-600 focus:ring-sky-500"
|
||||||
|
@change="updatePublicState(($event.target as HTMLInputElement).checked)"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NAlert v-if="errorMsg" class="mt-4" type="error" :show-icon="false" title="上传失败">
|
||||||
|
{{ errorMsg }}
|
||||||
|
</NAlert>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-end gap-3 border-t border-slate-200 bg-slate-50 px-5 py-4">
|
||||||
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" :disabled="uploading" @click="close">关闭</NButton>
|
||||||
|
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky" :disabled="uploading || items.length === 0" @click="handleSubmit">
|
||||||
|
{{ uploading ? '上传中...' : '提交审核' }}
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<MapPickerModal
|
||||||
|
:open="mapPickerOpen"
|
||||||
|
:latitude="activeItem?.latitude ?? null"
|
||||||
|
:longitude="activeItem?.longitude ?? null"
|
||||||
|
description="点击地图即可回填当前图片的经纬度,也可以继续手动修改。"
|
||||||
|
footer-text="建议点选大致拍摄位置,上传时会继续做模糊化处理。"
|
||||||
|
@close="closeMapPicker"
|
||||||
|
@confirm="confirmMapPicker"
|
||||||
|
/>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.quick-upload-close.quick-upload-close {
|
||||||
|
border-radius: 9999px !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+309
-413
@@ -1,14 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { NIcon, NSpace, useMessage } from 'naive-ui'
|
import { NIcon, NSpace } from 'naive-ui'
|
||||||
import { ChevronDown, Logout, Settings, Shield, User } from '@vicons/tabler'
|
import { Logout, Settings, Shield, User } from '@vicons/tabler'
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const message = useMessage()
|
|
||||||
const isMapRoute = computed(() => route.name === 'map')
|
const isMapRoute = computed(() => route.name === 'map')
|
||||||
|
|
||||||
const headerHidden = ref(false)
|
const headerHidden = ref(false)
|
||||||
@@ -17,477 +16,374 @@ const accountCardOpen = ref(false)
|
|||||||
const activeNavClass = 'bg-teal-50 text-teal-800 ring-1 ring-teal-100'
|
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 inactiveNavClass = 'text-slate-600 hover:bg-teal-50 hover:text-teal-800'
|
||||||
|
|
||||||
const displayUsername = computed(
|
const displayUsername = computed(() => authStore.profile?.username || authStore.user?.email || 'OpenCloud 用户')
|
||||||
() => authStore.profile?.username || authStore.user?.email || 'OpenCloud 用户',
|
|
||||||
)
|
|
||||||
const userEmail = computed(() => authStore.user?.email || '未绑定邮箱')
|
const userEmail = computed(() => authStore.user?.email || '未绑定邮箱')
|
||||||
const avatarFailed = ref(false)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => authStore.user?.image,
|
|
||||||
() => {
|
|
||||||
avatarFailed.value = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
let lastScrollY = 0
|
let lastScrollY = 0
|
||||||
let accountCloseTimer: number | null = null
|
let accountCloseTimer: number | null = null
|
||||||
const HIDE_HEADER_EVENT = 'opencloud:hide-header'
|
const HIDE_HEADER_EVENT = 'opencloud:hide-header'
|
||||||
|
|
||||||
function showHeader(pin = false) {
|
function showHeader(pin = false) {
|
||||||
headerHidden.value = false
|
headerHidden.value = false
|
||||||
headerPinnedOpen.value = pin
|
headerPinnedOpen.value = pin
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideHeader() {
|
function hideHeader() {
|
||||||
if (window.scrollY <= 96) return
|
if (window.scrollY <= 96) return
|
||||||
if (headerPinnedOpen.value) return
|
if (headerPinnedOpen.value) return
|
||||||
headerHidden.value = true
|
headerHidden.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function releasePinnedHeader() {
|
function releasePinnedHeader() {
|
||||||
headerPinnedOpen.value = false
|
headerPinnedOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
function forceHideHeader() {
|
function forceHideHeader() {
|
||||||
headerPinnedOpen.value = false
|
headerPinnedOpen.value = false
|
||||||
headerHidden.value = true
|
headerHidden.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleScroll() {
|
function handleScroll() {
|
||||||
const currentScrollY = window.scrollY
|
const currentScrollY = window.scrollY
|
||||||
const delta = currentScrollY - lastScrollY
|
const delta = currentScrollY - lastScrollY
|
||||||
|
|
||||||
if (isMapRoute.value) {
|
|
||||||
lastScrollY = currentScrollY
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentScrollY <= 64) {
|
|
||||||
showHeader(false)
|
|
||||||
lastScrollY = currentScrollY
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (delta > 12) {
|
|
||||||
hideHeader()
|
|
||||||
} else if (delta < -8) {
|
|
||||||
showHeader(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
if (isMapRoute.value) {
|
||||||
lastScrollY = currentScrollY
|
lastScrollY = currentScrollY
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentScrollY <= 64) {
|
||||||
|
showHeader(false)
|
||||||
|
lastScrollY = currentScrollY
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (delta > 12) {
|
||||||
|
hideHeader()
|
||||||
|
} else if (delta < -8) {
|
||||||
|
showHeader(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastScrollY = currentScrollY
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
lastScrollY = window.scrollY
|
lastScrollY = window.scrollY
|
||||||
window.addEventListener('scroll', handleScroll, { passive: true })
|
window.addEventListener('scroll', handleScroll, { passive: true })
|
||||||
window.addEventListener(HIDE_HEADER_EVENT, forceHideHeader as EventListener)
|
window.addEventListener(HIDE_HEADER_EVENT, forceHideHeader as EventListener)
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('scroll', handleScroll)
|
window.removeEventListener('scroll', handleScroll)
|
||||||
window.removeEventListener(HIDE_HEADER_EVENT, forceHideHeader as EventListener)
|
window.removeEventListener(HIDE_HEADER_EVENT, forceHideHeader as EventListener)
|
||||||
if (accountCloseTimer !== null) {
|
if (accountCloseTimer !== null) {
|
||||||
window.clearTimeout(accountCloseTimer)
|
window.clearTimeout(accountCloseTimer)
|
||||||
accountCloseTimer = null
|
accountCloseTimer = null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function syncHeaderForRoute() {
|
function syncHeaderForRoute() {
|
||||||
headerPinnedOpen.value = false
|
headerPinnedOpen.value = false
|
||||||
headerHidden.value = false
|
headerHidden.value = false
|
||||||
accountCardOpen.value = false
|
accountCardOpen.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(() => route.fullPath, () => {
|
||||||
() => route.fullPath,
|
syncHeaderForRoute()
|
||||||
() => {
|
})
|
||||||
syncHeaderForRoute()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(isMapRoute, () => {
|
watch(isMapRoute, () => {
|
||||||
syncHeaderForRoute()
|
syncHeaderForRoute()
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
syncHeaderForRoute()
|
syncHeaderForRoute()
|
||||||
})
|
})
|
||||||
|
|
||||||
function openAccountCard() {
|
function openAccountCard() {
|
||||||
if (accountCloseTimer !== null) {
|
if (accountCloseTimer !== null) {
|
||||||
window.clearTimeout(accountCloseTimer)
|
window.clearTimeout(accountCloseTimer)
|
||||||
accountCloseTimer = null
|
accountCloseTimer = null
|
||||||
}
|
}
|
||||||
accountCardOpen.value = true
|
accountCardOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeAccountCard() {
|
function closeAccountCard() {
|
||||||
if (accountCloseTimer !== null) {
|
if (accountCloseTimer !== null) {
|
||||||
window.clearTimeout(accountCloseTimer)
|
window.clearTimeout(accountCloseTimer)
|
||||||
}
|
}
|
||||||
accountCloseTimer = window.setTimeout(() => {
|
accountCloseTimer = window.setTimeout(() => {
|
||||||
accountCardOpen.value = false
|
accountCardOpen.value = false
|
||||||
accountCloseTimer = null
|
accountCloseTimer = null
|
||||||
}, 100)
|
}, 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
try {
|
await authStore.logout()
|
||||||
await authStore.logout()
|
closeAccountCard()
|
||||||
closeAccountCard()
|
if (route.meta.requiresAuth) {
|
||||||
if (route.meta.requiresAuth) {
|
router.push({ name: 'map' })
|
||||||
void router.push({ name: 'map' })
|
}
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
message.error(error instanceof Error ? error.message : '退出登录失败,请稍后重试。')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="sticky top-0 z-50" :class="isMapRoute ? 'h-0' : 'h-28 md:h-16'">
|
<div class="sticky top-0 z-50" :class="isMapRoute ? 'h-0' : 'h-28 md:h-16'">
|
||||||
<header
|
<header
|
||||||
class="absolute inset-x-0 top-0 border-b border-slate-200/80 bg-white/88 backdrop-blur-xl transition-transform duration-300"
|
class="absolute inset-x-0 top-0 border-b border-slate-200/80 bg-white/88 backdrop-blur-xl transition-transform duration-300"
|
||||||
:class="headerHidden ? '-translate-y-full' : 'translate-y-0'"
|
:class="headerHidden ? '-translate-y-full' : 'translate-y-0'"
|
||||||
@mouseenter="showHeader(true)"
|
@mouseenter="showHeader(true)"
|
||||||
@mouseleave="releasePinnedHeader"
|
@mouseleave="releasePinnedHeader"
|
||||||
>
|
>
|
||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div class="flex h-16 items-center justify-between">
|
<div class="flex h-16 items-center justify-between">
|
||||||
<RouterLink to="/" class="flex items-center gap-3">
|
<RouterLink to="/" class="flex items-center gap-3">
|
||||||
<div class="oc-brand-mark">
|
<div class="flex h-10 w-10 items-center justify-center border border-sky-200 bg-sky-100 shadow-[4px_4px_0_0_rgba(15,23,42,0.08)]">
|
||||||
<svg
|
<svg
|
||||||
class="h-8 w-8"
|
class="h-8 w-8"
|
||||||
viewBox="0 0 16 16"
|
viewBox="0 0 16 16"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
shape-rendering="crispEdges"
|
shape-rendering="crispEdges"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
>
|
>
|
||||||
<rect x="11" y="3" width="1" height="1" fill="#f59e0b" />
|
<rect x="11" y="3" width="1" height="1" fill="#f59e0b" />
|
||||||
<rect x="10" y="4" width="3" height="1" fill="#fbbf24" />
|
<rect x="10" y="4" width="3" height="1" fill="#fbbf24" />
|
||||||
<rect x="11" y="5" width="1" height="1" fill="#f59e0b" />
|
<rect x="11" y="5" width="1" height="1" fill="#f59e0b" />
|
||||||
|
|
||||||
<rect x="5" y="7" width="7" height="1" fill="#38bdf8" />
|
<rect x="5" y="7" width="7" height="1" fill="#38bdf8" />
|
||||||
<rect x="4" y="8" width="8" height="1" fill="#38bdf8" />
|
<rect x="4" y="8" width="8" height="1" fill="#38bdf8" />
|
||||||
<rect x="4" y="9" width="7" height="1" fill="#38bdf8" />
|
<rect x="4" y="9" width="7" height="1" fill="#38bdf8" />
|
||||||
<rect x="5" y="10" width="4" height="1" fill="#38bdf8" />
|
<rect x="5" y="10" width="4" height="1" fill="#38bdf8" />
|
||||||
|
|
||||||
<rect x="5" y="5" width="4" height="1" fill="#ffffff" />
|
<rect x="5" y="5" width="4" height="1" fill="#ffffff" />
|
||||||
<rect x="4" y="6" width="7" height="1" fill="#ffffff" />
|
<rect x="4" y="6" width="7" height="1" fill="#ffffff" />
|
||||||
<rect x="3" y="7" width="8" height="1" fill="#ffffff" />
|
<rect x="3" y="7" width="8" height="1" fill="#ffffff" />
|
||||||
<rect x="3" y="8" width="7" height="1" fill="#ffffff" />
|
<rect x="3" y="8" width="7" height="1" fill="#ffffff" />
|
||||||
<rect x="4" y="9" width="5" height="1" fill="#ffffff" />
|
<rect x="4" y="9" width="5" height="1" fill="#ffffff" />
|
||||||
|
|
||||||
<rect
|
<rect x="4" y="11" width="8" height="1" fill="#7dd3fc" opacity="0.55" />
|
||||||
x="4"
|
</svg>
|
||||||
y="11"
|
</div>
|
||||||
width="8"
|
<div>
|
||||||
height="1"
|
<div class="text-[11px] uppercase tracking-[0.22em] text-slate-500">Live Sky Atlas</div>
|
||||||
fill="#7dd3fc"
|
<div class="text-lg font-bold text-slate-900">OpenCloud</div>
|
||||||
opacity="0.55"
|
</div>
|
||||||
/>
|
</RouterLink>
|
||||||
</svg>
|
|
||||||
|
<nav class="hidden items-center gap-2 border border-slate-200 bg-white px-2 py-2 shadow-[6px_6px_0_0_rgba(15,23,42,0.05)] md:flex">
|
||||||
|
<RouterLink
|
||||||
|
to="/"
|
||||||
|
class="oc-nav-link"
|
||||||
|
:class="route.name === 'map' ? activeNavClass : inactiveNavClass"
|
||||||
|
>
|
||||||
|
地图
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
to="/community"
|
||||||
|
class="oc-nav-link"
|
||||||
|
:class="route.name === 'community' ? activeNavClass : inactiveNavClass"
|
||||||
|
>
|
||||||
|
社区
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
to="/gallery"
|
||||||
|
class="oc-nav-link"
|
||||||
|
:class="route.name === 'gallery' ? activeNavClass : inactiveNavClass"
|
||||||
|
>
|
||||||
|
画廊
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
v-if="authStore.isLoggedIn"
|
||||||
|
to="/encyclopedia"
|
||||||
|
class="oc-nav-link"
|
||||||
|
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? activeNavClass : inactiveNavClass"
|
||||||
|
>
|
||||||
|
图鉴
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<NSpace align="center" :size="8" class="shrink-0 md:[&_.n-button]:px-4">
|
||||||
|
<template v-if="!authStore.loading">
|
||||||
|
<RouterLink
|
||||||
|
v-if="authStore.isLoggedIn"
|
||||||
|
to="/upload"
|
||||||
|
class="no-underline"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="oc-header-button oc-header-button--sky uppercase tracking-[0.12em]"
|
||||||
|
:class="route.name === 'upload' ? 'ring-1 ring-sky-300' : ''"
|
||||||
|
>
|
||||||
|
上传
|
||||||
|
</span>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<template v-if="authStore.isLoggedIn">
|
||||||
|
<div
|
||||||
|
class="relative"
|
||||||
|
@mouseenter="openAccountCard"
|
||||||
|
@mouseleave="closeAccountCard"
|
||||||
|
@focusin="openAccountCard"
|
||||||
|
@focusout="closeAccountCard"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-header-button oc-header-button--teal max-w-[8.5rem] gap-2 md:max-w-none"
|
||||||
|
:class="route.name === 'profile' || route.name === 'profile-settings' ? 'border-teal-200 bg-teal-50/80 text-teal-800 ring-1 ring-teal-200 shadow-[4px_4px_0_0_rgba(52,116,93,0.16)]' : ''"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
:aria-expanded="accountCardOpen"
|
||||||
|
>
|
||||||
|
<NIcon size="16" class="shrink-0">
|
||||||
|
<User />
|
||||||
|
</NIcon>
|
||||||
|
<span class="truncate">@{{ displayUsername }}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="accountCardOpen"
|
||||||
|
class="absolute right-0 top-full z-50 w-72 pt-3"
|
||||||
|
role="menu"
|
||||||
|
>
|
||||||
|
<div class="border border-slate-200 bg-white shadow-[8px_8px_0_0_rgba(15,23,42,0.08)]">
|
||||||
|
<div class="border-b border-slate-200 bg-[linear-gradient(180deg,#f2faf6_0%,#ffffff_100%)] px-4 py-4">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="flex h-11 w-11 shrink-0 items-center justify-center border border-teal-200 bg-[linear-gradient(135deg,#eef9f3_0%,#d9efe3_100%)] text-lg font-bold text-teal-800">
|
||||||
|
{{ displayUsername.slice(0, 1).toUpperCase() }}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="min-w-0">
|
||||||
<div class="text-[11px] uppercase tracking-[0.22em] text-slate-500">
|
<p class="truncate text-sm font-semibold text-slate-950">@{{ displayUsername }}</p>
|
||||||
Live Sky Atlas
|
<p class="mt-1 truncate text-xs text-slate-500">{{ userEmail }}</p>
|
||||||
</div>
|
|
||||||
<div class="text-lg font-bold text-slate-900">OpenCloud</div>
|
|
||||||
</div>
|
</div>
|
||||||
</RouterLink>
|
</div>
|
||||||
|
<div class="mt-3 flex items-center gap-2 text-xs text-slate-500">
|
||||||
<nav
|
<!-- <span class="border border-teal-100 bg-teal-50 px-2 py-1 text-teal-700">
|
||||||
class="oc-panel-card hidden items-center gap-2 border border-slate-200 bg-white px-2 py-2 md:flex"
|
|
||||||
>
|
|
||||||
<RouterLink
|
|
||||||
to="/"
|
|
||||||
class="oc-nav-link"
|
|
||||||
:class="route.name === 'map' ? activeNavClass : inactiveNavClass"
|
|
||||||
>
|
|
||||||
地图
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink
|
|
||||||
to="/community"
|
|
||||||
class="oc-nav-link"
|
|
||||||
:class="route.name === 'community' ? activeNavClass : inactiveNavClass"
|
|
||||||
>
|
|
||||||
社区
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink
|
|
||||||
to="/gallery"
|
|
||||||
class="oc-nav-link"
|
|
||||||
:class="route.name === 'gallery' ? activeNavClass : inactiveNavClass"
|
|
||||||
>
|
|
||||||
画廊
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink
|
|
||||||
v-if="authStore.isLoggedIn"
|
|
||||||
to="/encyclopedia"
|
|
||||||
class="oc-nav-link"
|
|
||||||
:class="
|
|
||||||
route.name === 'encyclopedia' || route.name === 'cloud-type'
|
|
||||||
? activeNavClass
|
|
||||||
: inactiveNavClass
|
|
||||||
"
|
|
||||||
>
|
|
||||||
百科
|
|
||||||
</RouterLink>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<NSpace align="center" :size="8" class="shrink-0 md:[&_.n-button]:px-4">
|
|
||||||
<template v-if="!authStore.loading">
|
|
||||||
<RouterLink
|
|
||||||
v-if="authStore.isLoggedIn"
|
|
||||||
to="/upload"
|
|
||||||
class="no-underline"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="oc-header-button oc-header-button--sky uppercase tracking-[0.12em]"
|
|
||||||
:class="route.name === 'upload' ? 'ring-1 ring-sky-300' : ''"
|
|
||||||
>
|
|
||||||
上传
|
|
||||||
</span>
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<template v-if="authStore.isLoggedIn">
|
|
||||||
<div
|
|
||||||
class="relative"
|
|
||||||
@mouseenter="openAccountCard"
|
|
||||||
@mouseleave="closeAccountCard"
|
|
||||||
@focusin="openAccountCard"
|
|
||||||
@focusout="closeAccountCard"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-header-button oc-header-button--teal max-w-[8.5rem] gap-2 md:max-w-none"
|
|
||||||
:class="
|
|
||||||
route.name === 'profile' ||
|
|
||||||
route.name === 'profile-settings'
|
|
||||||
? 'border-teal-200 bg-teal-50/80 text-teal-800 ring-1 ring-teal-200'
|
|
||||||
: ''
|
|
||||||
"
|
|
||||||
aria-haspopup="menu"
|
|
||||||
:aria-expanded="accountCardOpen"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
v-if="authStore.user?.image && !avatarFailed"
|
|
||||||
:src="authStore.user.image"
|
|
||||||
alt=""
|
|
||||||
class="h-5 w-5 shrink-0 object-cover"
|
|
||||||
@error="avatarFailed = true"
|
|
||||||
/>
|
|
||||||
<NIcon v-else size="16" class="shrink-0"><User /></NIcon>
|
|
||||||
<span class="truncate">@{{ displayUsername }}</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="accountCardOpen"
|
|
||||||
class="absolute right-0 top-full z-50 w-72 pt-3"
|
|
||||||
role="menu"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="oc-floating-panel border border-slate-200 bg-white"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="border-b border-slate-200 bg-[linear-gradient(180deg,#f2faf6_0%,#ffffff_100%)] px-4 py-4"
|
|
||||||
>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
class="flex h-11 w-11 shrink-0 items-center justify-center border border-teal-200 bg-[linear-gradient(135deg,#eef9f3_0%,#d9efe3_100%)] text-lg font-bold text-teal-800"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
v-if="
|
|
||||||
authStore.user?.image &&
|
|
||||||
!avatarFailed
|
|
||||||
"
|
|
||||||
:src="authStore.user.image"
|
|
||||||
:alt="displayUsername"
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
@error="avatarFailed = true"
|
|
||||||
/>
|
|
||||||
<template v-else>{{
|
|
||||||
displayUsername
|
|
||||||
.slice(0, 1)
|
|
||||||
.toUpperCase()
|
|
||||||
}}</template>
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p
|
|
||||||
class="truncate text-sm font-semibold text-slate-950"
|
|
||||||
>
|
|
||||||
@{{ displayUsername }}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class="mt-1 truncate text-xs text-slate-500"
|
|
||||||
>
|
|
||||||
{{ userEmail }}
|
|
||||||
</p>
|
|
||||||
</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">
|
|
||||||
{{ authStore.isAdmin ? '管理员' : '观测者' }}
|
{{ authStore.isAdmin ? '管理员' : '观测者' }}
|
||||||
</span>
|
</span>
|
||||||
<span>OpenCloud 账号</span> -->
|
<span>OpenCloud 账号</span> -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/profile"
|
to="/profile"
|
||||||
class="oc-menu-item"
|
class="oc-menu-item"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
<NIcon size="18"><User /></NIcon>
|
<NIcon size="18"><User /></NIcon>
|
||||||
<span>个人中心</span>
|
<span>个人中心</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/profile/settings"
|
to="/profile/settings"
|
||||||
class="oc-menu-item"
|
class="oc-menu-item"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
<NIcon size="18"><Settings /></NIcon>
|
<NIcon size="18"><Settings /></NIcon>
|
||||||
<span>账号设置</span>
|
<span>账号设置</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-if="authStore.isAdmin"
|
v-if="authStore.isAdmin"
|
||||||
to="/admin"
|
to="/admin"
|
||||||
class="oc-menu-item oc-menu-item--amber"
|
class="oc-menu-item oc-menu-item--amber"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
<NIcon size="18"><Shield /></NIcon>
|
<NIcon size="18"><Shield /></NIcon>
|
||||||
<span>管理后台</span>
|
<span>管理后台</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-slate-200 p-2">
|
<div class="border-t border-slate-200 p-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="oc-menu-item oc-menu-item--danger px-2"
|
class="oc-menu-item oc-menu-item--danger px-2"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
@click="handleLogout"
|
@click="handleLogout"
|
||||||
>
|
>
|
||||||
<NIcon size="18"><Logout /></NIcon>
|
<NIcon size="18"><Logout /></NIcon>
|
||||||
<span>登出</span>
|
<span>登出</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<RouterLink to="/login" class="no-underline">
|
|
||||||
<span
|
|
||||||
class="oc-header-button oc-header-button--neutral"
|
|
||||||
:class="
|
|
||||||
route.name === 'login'
|
|
||||||
? 'bg-teal-50 ring-0 ring-teal-100 text-teal-800'
|
|
||||||
: ''
|
|
||||||
"
|
|
||||||
>
|
|
||||||
登录
|
|
||||||
</span>
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink to="/register" class="no-underline">
|
|
||||||
<span
|
|
||||||
class="oc-header-button oc-header-button--sky"
|
|
||||||
:class="
|
|
||||||
route.name === 'register' ? 'ring-0 ring-sky-300' : ''
|
|
||||||
"
|
|
||||||
>
|
|
||||||
注册
|
|
||||||
</span>
|
|
||||||
</RouterLink>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</NSpace>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<nav
|
<template v-else>
|
||||||
class="flex items-center gap-2 overflow-x-auto border-t border-slate-200/80 py-2 md:hidden"
|
<RouterLink
|
||||||
|
to="/login"
|
||||||
|
class="no-underline"
|
||||||
>
|
>
|
||||||
<RouterLink
|
<span
|
||||||
to="/"
|
class="oc-header-button oc-header-button--neutral"
|
||||||
class="oc-nav-link shrink-0"
|
:class="route.name === 'login' ? 'bg-teal-50 ring-0 ring-teal-100 text-teal-800' : ''"
|
||||||
:class="route.name === 'map' ? activeNavClass : inactiveNavClass"
|
>
|
||||||
>
|
登录
|
||||||
地图
|
</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/community"
|
to="/register"
|
||||||
class="oc-nav-link shrink-0"
|
class="no-underline"
|
||||||
:class="route.name === 'community' ? activeNavClass : inactiveNavClass"
|
>
|
||||||
>
|
<span
|
||||||
社区
|
class="oc-header-button oc-header-button--sky"
|
||||||
</RouterLink>
|
:class="route.name === 'register' ? 'ring-0 ring-sky-300' : ''"
|
||||||
<RouterLink
|
>
|
||||||
to="/gallery"
|
注册
|
||||||
class="oc-nav-link shrink-0"
|
</span>
|
||||||
:class="route.name === 'gallery' ? activeNavClass : inactiveNavClass"
|
</RouterLink>
|
||||||
>
|
</template>
|
||||||
画廊
|
</template>
|
||||||
</RouterLink>
|
</NSpace>
|
||||||
<RouterLink
|
</div>
|
||||||
v-if="authStore.isLoggedIn"
|
|
||||||
to="/encyclopedia"
|
|
||||||
class="oc-nav-link shrink-0"
|
|
||||||
:class="
|
|
||||||
route.name === 'encyclopedia' || route.name === 'cloud-type'
|
|
||||||
? activeNavClass
|
|
||||||
: inactiveNavClass
|
|
||||||
"
|
|
||||||
>
|
|
||||||
百科
|
|
||||||
</RouterLink>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<Transition name="header-reveal">
|
<nav class="flex items-center gap-2 overflow-x-auto border-t border-slate-200/80 py-2 md:hidden">
|
||||||
<div
|
<RouterLink
|
||||||
v-if="headerHidden"
|
to="/"
|
||||||
class="pointer-events-none absolute inset-x-0 top-3 flex justify-center"
|
class="oc-nav-link shrink-0"
|
||||||
>
|
:class="route.name === 'map' ? activeNavClass : inactiveNavClass"
|
||||||
<button
|
>
|
||||||
type="button"
|
地图
|
||||||
class="oc-header-button oc-header-button--neutral pointer-events-auto gap-2 bg-white/94 text-xs tracking-[0.16em] backdrop-blur"
|
</RouterLink>
|
||||||
@click="showHeader(true)"
|
<RouterLink
|
||||||
>
|
to="/community"
|
||||||
<NIcon size="15"><ChevronDown /></NIcon>
|
class="oc-nav-link shrink-0"
|
||||||
<span>展开导航</span>
|
:class="route.name === 'community' ? activeNavClass : inactiveNavClass"
|
||||||
</button>
|
>
|
||||||
</div>
|
社区
|
||||||
</Transition>
|
</RouterLink>
|
||||||
</div>
|
<RouterLink
|
||||||
|
to="/gallery"
|
||||||
|
class="oc-nav-link shrink-0"
|
||||||
|
:class="route.name === 'gallery' ? activeNavClass : inactiveNavClass"
|
||||||
|
>
|
||||||
|
画廊
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
v-if="authStore.isLoggedIn"
|
||||||
|
to="/encyclopedia"
|
||||||
|
class="oc-nav-link shrink-0"
|
||||||
|
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? activeNavClass : inactiveNavClass"
|
||||||
|
>
|
||||||
|
图鉴
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
v-if="!authStore.loading && authStore.isLoggedIn"
|
||||||
|
to="/profile"
|
||||||
|
class="oc-nav-link shrink-0"
|
||||||
|
:class="route.name === 'profile' ? activeNavClass : inactiveNavClass"
|
||||||
|
>
|
||||||
|
主页
|
||||||
|
</RouterLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<button
|
||||||
|
v-if="headerHidden"
|
||||||
|
type="button"
|
||||||
|
class="absolute left-1/2 top-3 flex -translate-x-1/2 items-center gap-2 border border-slate-300 bg-white/94 px-3 py-1.5 text-xs font-medium tracking-[0.16em] text-slate-700 shadow-[4px_4px_0_0_rgba(15,23,42,0.08)] backdrop-blur transition-colors hover:border-teal-300 hover:bg-teal-50 hover:text-teal-900 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-600"
|
||||||
|
@click="showHeader(true)"
|
||||||
|
>
|
||||||
|
<span>▼</span>
|
||||||
|
<span>展开导航</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.header-reveal-enter-active,
|
|
||||||
.header-reveal-leave-active {
|
|
||||||
transition:
|
|
||||||
opacity 240ms ease,
|
|
||||||
transform 240ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-reveal-enter-from,
|
|
||||||
.header-reveal-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(-0.75rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
.header-reveal-enter-active,
|
|
||||||
.header-reveal-leave-active {
|
|
||||||
transition-duration: 1ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-reveal-enter-from,
|
|
||||||
.header-reveal-leave-to {
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -0,0 +1,407 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { NButton, NSelect } from 'naive-ui'
|
||||||
|
|
||||||
|
export interface ContributionHeatmapCell {
|
||||||
|
date: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MonthStat {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
totalCount: number
|
||||||
|
activeDays: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const currentYear = today.getFullYear()
|
||||||
|
const currentMonth = today.getMonth() + 1
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<{
|
||||||
|
cells: ContributionHeatmapCell[]
|
||||||
|
totalCount: number
|
||||||
|
activeDays: number
|
||||||
|
currentStreak: number
|
||||||
|
bestStreak: number
|
||||||
|
activeDate?: string | null
|
||||||
|
title?: string
|
||||||
|
subtitle?: string
|
||||||
|
}>(), {
|
||||||
|
activeDate: null,
|
||||||
|
title: '上传活跃度',
|
||||||
|
subtitle: '按上传日期统计',
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
selectDate: [date: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const viewMode = ref<'year' | 'month'>('year')
|
||||||
|
const selectedYear = ref(currentYear)
|
||||||
|
const selectedMonth = ref(currentMonth)
|
||||||
|
const hoveredMonthKey = ref<string | null>(null)
|
||||||
|
|
||||||
|
function parseLocalDate(dateString: string) {
|
||||||
|
const [year, month, day] = dateString.split('-').map(Number)
|
||||||
|
return new Date(year, (month || 1) - 1, day || 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDayKey(date: Date) {
|
||||||
|
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
||||||
|
const day = `${date.getDate()}`.padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMonthKey(dateString: string) {
|
||||||
|
const date = parseLocalDate(dateString)
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfDay(date: Date) {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||||
|
}
|
||||||
|
|
||||||
|
function startOfWeek(date: Date) {
|
||||||
|
const result = startOfDay(date)
|
||||||
|
const weekday = result.getDay()
|
||||||
|
const offset = weekday === 0 ? 6 : weekday - 1
|
||||||
|
result.setDate(result.getDate() - offset)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
const dailyCounts = computed(() => {
|
||||||
|
const counts = new Map<string, number>()
|
||||||
|
for (const cell of props.cells) {
|
||||||
|
counts.set(cell.date, cell.count)
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
})
|
||||||
|
|
||||||
|
const availableYears = computed(() => {
|
||||||
|
const years = new Set<number>([currentYear])
|
||||||
|
for (const cell of props.cells) {
|
||||||
|
years.add(parseLocalDate(cell.date).getFullYear())
|
||||||
|
}
|
||||||
|
return Array.from(years).sort((a, b) => b - a)
|
||||||
|
})
|
||||||
|
|
||||||
|
const yearOptions = computed(() => {
|
||||||
|
return availableYears.value.map(year => ({
|
||||||
|
label: `${year}年`,
|
||||||
|
value: year,
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
const monthOptions = Array.from({ length: 12 }, (_, index) => ({
|
||||||
|
label: `${index + 1}月`,
|
||||||
|
value: index + 1,
|
||||||
|
}))
|
||||||
|
|
||||||
|
watch(availableYears, years => {
|
||||||
|
if (!years.includes(selectedYear.value)) {
|
||||||
|
selectedYear.value = years[0] || currentYear
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const visibleCells = computed(() => {
|
||||||
|
if (viewMode.value === 'year') {
|
||||||
|
const start = new Date(selectedYear.value, 0, 1)
|
||||||
|
const end = new Date(selectedYear.value, 11, 31)
|
||||||
|
const alignedStart = startOfWeek(start)
|
||||||
|
const alignedEnd = startOfWeek(end)
|
||||||
|
alignedEnd.setDate(alignedEnd.getDate() + 6)
|
||||||
|
|
||||||
|
const cells: Array<ContributionHeatmapCell & { inRange: boolean; isToday: boolean }> = []
|
||||||
|
const cursor = new Date(alignedStart)
|
||||||
|
|
||||||
|
while (cursor <= alignedEnd) {
|
||||||
|
const key = formatDayKey(cursor)
|
||||||
|
const inRange = cursor >= start && cursor <= end
|
||||||
|
cells.push({
|
||||||
|
date: key,
|
||||||
|
count: dailyCounts.value.get(key) || 0,
|
||||||
|
inRange,
|
||||||
|
isToday: key === formatDayKey(today),
|
||||||
|
})
|
||||||
|
cursor.setDate(cursor.getDate() + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(selectedYear.value, selectedMonth.value - 1, 1)
|
||||||
|
const end = new Date(selectedYear.value, selectedMonth.value, 0)
|
||||||
|
const alignedStart = startOfWeek(start)
|
||||||
|
const alignedEnd = startOfWeek(end)
|
||||||
|
alignedEnd.setDate(alignedEnd.getDate() + 6)
|
||||||
|
|
||||||
|
const cells: Array<ContributionHeatmapCell & { inRange: boolean; isToday: boolean }> = []
|
||||||
|
const cursor = new Date(alignedStart)
|
||||||
|
|
||||||
|
while (cursor <= alignedEnd) {
|
||||||
|
const key = formatDayKey(cursor)
|
||||||
|
const inRange = cursor >= start && cursor <= end
|
||||||
|
cells.push({
|
||||||
|
date: key,
|
||||||
|
count: dailyCounts.value.get(key) || 0,
|
||||||
|
inRange,
|
||||||
|
isToday: key === formatDayKey(today),
|
||||||
|
})
|
||||||
|
cursor.setDate(cursor.getDate() + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cells
|
||||||
|
})
|
||||||
|
|
||||||
|
const maxCount = computed(() => {
|
||||||
|
return visibleCells.value.reduce((max, cell) => Math.max(max, cell.count), 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
function getLevel(count: number) {
|
||||||
|
if (count <= 0) return 0
|
||||||
|
|
||||||
|
const max = Math.max(maxCount.value, 1)
|
||||||
|
const ratio = count / max
|
||||||
|
|
||||||
|
if (ratio >= 0.75 || count >= 8) return 4
|
||||||
|
if (ratio >= 0.5 || count >= 5) return 3
|
||||||
|
if (ratio >= 0.25 || count >= 3) return 2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCellClass(cell: { count: number; inRange: boolean }) {
|
||||||
|
if (!cell.inRange) return 'bg-transparent border-transparent'
|
||||||
|
|
||||||
|
const level = getLevel(cell.count)
|
||||||
|
if (level === 0) return 'bg-zinc-100 border-zinc-200'
|
||||||
|
if (level === 1) return 'bg-[#e0f2fe] border-[#bae6fd]'
|
||||||
|
if (level === 2) return 'bg-[#7dd3fc] border-[#38bdf8]'
|
||||||
|
if (level === 3) return 'bg-[#0ea5e9] border-[#0284c7]'
|
||||||
|
return 'bg-[#0f3d63] border-[#0b2f4d]'
|
||||||
|
}
|
||||||
|
|
||||||
|
const weeks = computed(() => {
|
||||||
|
const buckets: Array<typeof visibleCells.value> = []
|
||||||
|
for (let index = 0; index < visibleCells.value.length; index += 7) {
|
||||||
|
buckets.push(visibleCells.value.slice(index, index + 7))
|
||||||
|
}
|
||||||
|
return buckets
|
||||||
|
})
|
||||||
|
|
||||||
|
const monthStats = computed(() => {
|
||||||
|
const stats = new Map<string, MonthStat>()
|
||||||
|
|
||||||
|
for (const cell of visibleCells.value) {
|
||||||
|
if (!cell.inRange) continue
|
||||||
|
const monthKey = toMonthKey(cell.date)
|
||||||
|
const date = parseLocalDate(cell.date)
|
||||||
|
const label = `${date.getMonth() + 1}月`
|
||||||
|
const existing = stats.get(monthKey)
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
existing.totalCount += cell.count
|
||||||
|
if (cell.count > 0) existing.activeDays += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.set(monthKey, {
|
||||||
|
key: monthKey,
|
||||||
|
label,
|
||||||
|
totalCount: cell.count,
|
||||||
|
activeDays: cell.count > 0 ? 1 : 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
})
|
||||||
|
|
||||||
|
const monthLabels = computed(() => {
|
||||||
|
if (viewMode.value !== 'year') return []
|
||||||
|
|
||||||
|
let lastMonth = -1
|
||||||
|
return weeks.value.map(week => {
|
||||||
|
const firstInRange = week.find(cell => cell.inRange)
|
||||||
|
if (!firstInRange) return null
|
||||||
|
|
||||||
|
const date = parseLocalDate(firstInRange.date)
|
||||||
|
const month = date.getMonth()
|
||||||
|
const labelMonth = month + 1
|
||||||
|
const monthKey = toMonthKey(firstInRange.date)
|
||||||
|
|
||||||
|
if (month === lastMonth) return null
|
||||||
|
|
||||||
|
lastMonth = month
|
||||||
|
return {
|
||||||
|
key: monthKey,
|
||||||
|
label: labelMonth === 1 ? `${selectedYear.value}/1月` : `${labelMonth}月`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const monthPreview = computed(() => {
|
||||||
|
if (!hoveredMonthKey.value) return null
|
||||||
|
return monthStats.value.get(hoveredMonthKey.value) || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const panelSummary = computed(() => {
|
||||||
|
if (viewMode.value === 'year') {
|
||||||
|
return `${selectedYear.value} 年完整视图,包含尚未来到的日期。`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${selectedYear.value} 年 ${selectedMonth.value} 月完整视图。`
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatTooltip(cell: ContributionHeatmapCell & { inRange: boolean; isToday: boolean }) {
|
||||||
|
const date = parseLocalDate(cell.date)
|
||||||
|
const label = date.toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!cell.inRange) {
|
||||||
|
return `${label}:不在当前视图范围内`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cell.count === 0) {
|
||||||
|
return `${label}${cell.isToday ? '(今天)' : ''}:当天没有上传`
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${label}${cell.isToday ? '(今天)' : ''}:上传 ${cell.count} 张`
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCellClick(cell: ContributionHeatmapCell & { inRange: boolean }) {
|
||||||
|
if (!cell.inRange) return
|
||||||
|
emit('selectDate', cell.date)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonthTooltip(monthKey: string) {
|
||||||
|
const stats = monthStats.value.get(monthKey)
|
||||||
|
if (!stats) return ''
|
||||||
|
return `${stats.label}:共上传 ${stats.totalCount} 张,活跃于 ${stats.activeDays} 天`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="oc-panel-card border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-6">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<h3 class="text-2xl font-bold text-slate-900">{{ title }}</h3>
|
||||||
|
<p class="mt-3 text-sm leading-7 text-slate-500">
|
||||||
|
共上传 {{ totalCount }} 张,活跃于 {{ activeDays }} 天。颜色越深,代表当天上传的照片越多。
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">{{ panelSummary }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-end gap-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
:class="viewMode === 'year' ? 'oc-panel-button oc-panel-button--teal' : 'oc-panel-button oc-panel-button--neutral'"
|
||||||
|
@click="viewMode = 'year'"
|
||||||
|
>
|
||||||
|
年度视图
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
:class="viewMode === 'month' ? 'oc-panel-button oc-panel-button--teal' : 'oc-panel-button oc-panel-button--neutral'"
|
||||||
|
@click="viewMode = 'month'"
|
||||||
|
>
|
||||||
|
月度视图
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
<NSelect
|
||||||
|
v-model:value="selectedYear"
|
||||||
|
:options="yearOptions"
|
||||||
|
class="w-28"
|
||||||
|
/>
|
||||||
|
<NSelect
|
||||||
|
v-if="viewMode === 'month'"
|
||||||
|
v-model:value="selectedMonth"
|
||||||
|
:options="monthOptions"
|
||||||
|
class="w-24"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 flex min-h-6 items-center justify-between gap-4 text-xs text-slate-500">
|
||||||
|
<span>点击某一天可筛选当天上传记录。</span>
|
||||||
|
<span v-if="monthPreview" class="text-right">
|
||||||
|
{{ monthPreview.label }}:{{ monthPreview.totalCount }} 张 / {{ monthPreview.activeDays }} 天
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-right">
|
||||||
|
<template v-if="viewMode === 'year'">悬停月份标签可预览当月上传摘要。</template>
|
||||||
|
<template v-else>当前显示整个月的上传情况。</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 overflow-x-auto">
|
||||||
|
<div :class="viewMode === 'year' ? 'min-w-[980px]' : 'min-w-[360px]'">
|
||||||
|
<div
|
||||||
|
v-if="viewMode === 'year'"
|
||||||
|
class="mb-2 grid grid-flow-col auto-cols-[14px] gap-1 pl-8 text-[11px] text-slate-400"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="(month, index) in monthLabels"
|
||||||
|
:key="`${month?.key || 'empty'}-${index}`"
|
||||||
|
class="w-[14px] whitespace-nowrap"
|
||||||
|
:title="month ? formatMonthTooltip(month.key) : ''"
|
||||||
|
@mouseenter="hoveredMonthKey = month?.key || null"
|
||||||
|
@mouseleave="hoveredMonthKey = null"
|
||||||
|
>
|
||||||
|
{{ month?.label || '' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<div class="grid grid-rows-7 gap-1 pt-[2px] text-[11px] text-slate-400">
|
||||||
|
<span>一</span>
|
||||||
|
<span></span>
|
||||||
|
<span>三</span>
|
||||||
|
<span></span>
|
||||||
|
<span>五</span>
|
||||||
|
<span></span>
|
||||||
|
<span>日</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-flow-col auto-cols-[14px] gap-1">
|
||||||
|
<div
|
||||||
|
v-for="(week, weekIndex) in weeks"
|
||||||
|
:key="weekIndex"
|
||||||
|
class="grid grid-rows-7 gap-1"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="cell in week"
|
||||||
|
:key="cell.date"
|
||||||
|
type="button"
|
||||||
|
class="oc-heatmap-cell relative h-3.5 w-3.5 border hover:scale-125"
|
||||||
|
:class="[
|
||||||
|
getCellClass(cell),
|
||||||
|
props.activeDate === cell.date ? 'ring-2 ring-slate-900 ring-offset-1' : '',
|
||||||
|
cell.isToday && cell.inRange ? 'outline outline-1 outline-slate-900 outline-offset-1' : '',
|
||||||
|
]"
|
||||||
|
:title="formatTooltip(cell)"
|
||||||
|
@click="handleCellClick(cell)"
|
||||||
|
></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 flex items-center justify-end gap-2 text-xs text-slate-500">
|
||||||
|
<span>少</span>
|
||||||
|
<span class="h-3 w-3 border border-slate-200 bg-slate-100"></span>
|
||||||
|
<span class="h-3 w-3 border border-sky-200 bg-sky-100"></span>
|
||||||
|
<span class="h-3 w-3 border border-sky-400 bg-sky-300"></span>
|
||||||
|
<span class="h-3 w-3 border border-teal-600 bg-teal-500"></span>
|
||||||
|
<span class="h-3 w-3 border border-slate-900 bg-slate-900"></span>
|
||||||
|
<span>多</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
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 {
|
||||||
|
id: string
|
||||||
|
file: File
|
||||||
|
preview: string
|
||||||
|
cloudCategoryId: number | 'other' | null
|
||||||
|
customCloudType: string
|
||||||
|
locationName: string
|
||||||
|
description: string
|
||||||
|
isHidden: boolean
|
||||||
|
latitude: number | null
|
||||||
|
longitude: number | null
|
||||||
|
capturedAt: string
|
||||||
|
errors: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UnlockedBadge {
|
||||||
|
cloudTypeId: number
|
||||||
|
cloudName: string
|
||||||
|
cloudNameEn: string
|
||||||
|
rarity: CloudType['rarity']
|
||||||
|
unlockedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UploadResult {
|
||||||
|
ok: boolean
|
||||||
|
unlockedBadges: UnlockedBadge[]
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextId = 0
|
||||||
|
|
||||||
|
function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(reader.result as ArrayBuffer)
|
||||||
|
reader.onerror = reject
|
||||||
|
reader.readAsArrayBuffer(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractExifDate(buffer: ArrayBuffer): string | null {
|
||||||
|
const view = new DataView(buffer)
|
||||||
|
if (view.getUint16(0, false) !== 0xffd8) return null
|
||||||
|
|
||||||
|
let offset = 2
|
||||||
|
while (offset < view.byteLength - 2) {
|
||||||
|
const marker = view.getUint16(offset, false)
|
||||||
|
offset += 2
|
||||||
|
if ((marker & 0xff00) !== 0xff00) break
|
||||||
|
const length = view.getUint16(offset, false)
|
||||||
|
offset += 2
|
||||||
|
|
||||||
|
if (marker === 0xffe1) {
|
||||||
|
const exifStart = offset
|
||||||
|
const header = String.fromCharCode(
|
||||||
|
view.getUint8(exifStart),
|
||||||
|
view.getUint8(exifStart + 1),
|
||||||
|
view.getUint8(exifStart + 2),
|
||||||
|
view.getUint8(exifStart + 3),
|
||||||
|
)
|
||||||
|
if (header !== 'Exif') break
|
||||||
|
|
||||||
|
const tiffStart = exifStart + 6
|
||||||
|
const isLE = view.getUint16(tiffStart, false) === 0x4949
|
||||||
|
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
|
||||||
|
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
|
||||||
|
}
|
||||||
|
return 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)
|
||||||
|
const overallProgress = ref(0)
|
||||||
|
const currentItemIndex = ref(0)
|
||||||
|
const totalItems = ref(0)
|
||||||
|
|
||||||
|
async function addFiles(files: File[]) {
|
||||||
|
const imageFiles = files.filter(file => file.type.startsWith('image/'))
|
||||||
|
for (const file of imageFiles) {
|
||||||
|
let capturedAt = new Date().toISOString()
|
||||||
|
try {
|
||||||
|
const exifDate = extractExifDate(await readFileAsArrayBuffer(file))
|
||||||
|
if (exifDate) capturedAt = exifDate
|
||||||
|
} catch {
|
||||||
|
// 无 EXIF 时使用当前时间。
|
||||||
|
}
|
||||||
|
|
||||||
|
items.value.push({
|
||||||
|
id: String(++nextId),
|
||||||
|
file,
|
||||||
|
preview: URL.createObjectURL(file),
|
||||||
|
cloudCategoryId: null,
|
||||||
|
customCloudType: '',
|
||||||
|
locationName: '',
|
||||||
|
description: '',
|
||||||
|
isHidden: false,
|
||||||
|
latitude: null,
|
||||||
|
longitude: null,
|
||||||
|
capturedAt,
|
||||||
|
errors: {},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(id: string) {
|
||||||
|
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() {
|
||||||
|
items.value.forEach(item => URL.revokeObjectURL(item.preview))
|
||||||
|
items.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateItem(item: UploadItem) {
|
||||||
|
const errors: Record<string, string> = {}
|
||||||
|
if (!item.cloudCategoryId) errors.cloudCategory = '请选择类别'
|
||||||
|
if (item.cloudCategoryId === 'other' && !item.customCloudType.trim()) {
|
||||||
|
errors.cloudCategory = '请输入自定义云型名称'
|
||||||
|
}
|
||||||
|
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 = '经纬度必须同时填写'
|
||||||
|
item.errors = errors
|
||||||
|
return Object.keys(errors).length === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateAll() {
|
||||||
|
return items.value.map(validateItem).every(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadAll(): Promise<UploadResult> {
|
||||||
|
if (!validateAll() || !authStore.user) return { ok: false, unlockedBadges: [] }
|
||||||
|
|
||||||
|
uploading.value = true
|
||||||
|
totalItems.value = items.value.length
|
||||||
|
currentItemIndex.value = 0
|
||||||
|
overallProgress.value = 0
|
||||||
|
const badges = new Map<number, UnlockedBadge>()
|
||||||
|
|
||||||
|
try {
|
||||||
|
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 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))
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearAll()
|
||||||
|
profileStore.invalidateUser(authStore.user.id)
|
||||||
|
return { ok: true, unlockedBadges: [...badges.values()] }
|
||||||
|
} catch {
|
||||||
|
return { ok: false, unlockedBadges: [] }
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
uploading,
|
||||||
|
overallProgress,
|
||||||
|
currentItemIndex,
|
||||||
|
totalItems,
|
||||||
|
addFiles,
|
||||||
|
removeItem,
|
||||||
|
clearAll,
|
||||||
|
validateItem,
|
||||||
|
validateAll,
|
||||||
|
uploadAll,
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,35 +0,0 @@
|
|||||||
import { createAuthClient } from 'better-auth/client'
|
|
||||||
import { API_URL } from '@/lib/api'
|
|
||||||
|
|
||||||
export const authClient = createAuthClient({
|
|
||||||
baseURL: `${API_URL}/auth`,
|
|
||||||
fetchOptions: { credentials: 'include' },
|
|
||||||
})
|
|
||||||
|
|
||||||
interface AuthClientFailure {
|
|
||||||
code?: string
|
|
||||||
message?: string
|
|
||||||
status?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AuthClientError extends Error {
|
|
||||||
code?: string
|
|
||||||
status?: number
|
|
||||||
|
|
||||||
constructor(failure: AuthClientFailure, fallback: string) {
|
|
||||||
super(
|
|
||||||
failure.code === 'EMAIL_NOT_VERIFIED'
|
|
||||||
? '邮箱尚未验证,请先查收验证邮件。'
|
|
||||||
: failure.status === 429
|
|
||||||
? '操作过于频繁,请稍后重试。'
|
|
||||||
: fallback,
|
|
||||||
)
|
|
||||||
this.name = 'AuthClientError'
|
|
||||||
this.code = failure.code
|
|
||||||
this.status = failure.status
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function assertAuthResult(result: { error?: AuthClientFailure | null }, fallback: string) {
|
|
||||||
if (result.error) throw new AuthClientError(result.error, fallback)
|
|
||||||
}
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
import { computed, ref } from 'vue'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { AUTH_EXPIRED_EVENT, apiRequest, toAuthUser } from '@/lib/api'
|
|
||||||
import { assertAuthResult, authClient } from '@/features/auth/authClient'
|
|
||||||
import type { BackendUserProfile } from '@/types/api'
|
|
||||||
import type { Profile } from '@/types/database'
|
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
|
||||||
const user = ref<ReturnType<typeof toAuthUser> | null>(null)
|
|
||||||
const profile = ref<Profile | null>(null)
|
|
||||||
const loading = ref(true)
|
|
||||||
let listenerAttached = false
|
|
||||||
|
|
||||||
const isLoggedIn = computed(() => !!user.value)
|
|
||||||
const isAdmin = computed(() => profile.value?.role === 'admin')
|
|
||||||
|
|
||||||
function applyProfile(backendProfile: BackendUserProfile) {
|
|
||||||
const normalized = toAuthUser(backendProfile)
|
|
||||||
user.value = normalized
|
|
||||||
profile.value = {
|
|
||||||
id: normalized.id,
|
|
||||||
username: normalized.username,
|
|
||||||
image: normalized.image,
|
|
||||||
role: normalized.role,
|
|
||||||
is_disabled: normalized.is_disabled,
|
|
||||||
created_at: normalized.created_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearAuth() {
|
|
||||||
user.value = null
|
|
||||||
profile.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchMe() {
|
|
||||||
const backendProfile = await apiRequest<BackendUserProfile>('/profile/me')
|
|
||||||
applyProfile(backendProfile)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function login(email: string, password: string) {
|
|
||||||
assertAuthResult(
|
|
||||||
await authClient.signIn.email({ email, password }),
|
|
||||||
'登录失败,请检查邮箱和密码。',
|
|
||||||
)
|
|
||||||
const session = await authClient.getSession()
|
|
||||||
assertAuthResult(session, '获取登录会话失败,请稍后重试。')
|
|
||||||
if (!session.data) throw new Error('未能建立登录会话,请稍后重试。')
|
|
||||||
await fetchMe()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function register(name: string, email: string, password: string) {
|
|
||||||
assertAuthResult(
|
|
||||||
await authClient.signUp.email({ name, email, password }),
|
|
||||||
'注册失败,请稍后重试。',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resendConfirmation(email: string) {
|
|
||||||
assertAuthResult(
|
|
||||||
await authClient.sendVerificationEmail({ email }),
|
|
||||||
'验证邮件发送失败,请稍后重试。',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmEmail(token: string) {
|
|
||||||
assertAuthResult(
|
|
||||||
await authClient.$fetch('/verify-email', {
|
|
||||||
method: 'GET',
|
|
||||||
query: { token },
|
|
||||||
}),
|
|
||||||
'确认链接无效或已过期。',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendPasswordReset(email: string) {
|
|
||||||
assertAuthResult(
|
|
||||||
await authClient.requestPasswordReset({ email }),
|
|
||||||
'重置邮件发送失败,请稍后重试。',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resetPassword(token: string, newPassword: string) {
|
|
||||||
assertAuthResult(
|
|
||||||
await authClient.resetPassword({ token, newPassword }),
|
|
||||||
'密码重置失败,请重新申请重置邮件。',
|
|
||||||
)
|
|
||||||
clearAuth()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout() {
|
|
||||||
const result = await authClient.signOut()
|
|
||||||
if (result.error?.status === 401) {
|
|
||||||
clearAuth()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
assertAuthResult(result, '退出登录失败,请稍后重试。')
|
|
||||||
clearAuth()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateUsername(username: string) {
|
|
||||||
const backendProfile = await apiRequest<BackendUserProfile>('/profile/me', {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: { user_name: username },
|
|
||||||
})
|
|
||||||
applyProfile(backendProfile)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updatePassword(currentPassword: string, newPassword: string) {
|
|
||||||
assertAuthResult(
|
|
||||||
await authClient.changePassword({
|
|
||||||
currentPassword,
|
|
||||||
newPassword,
|
|
||||||
revokeOtherSessions: true,
|
|
||||||
}),
|
|
||||||
'密码更新失败,请检查当前密码。',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initialize() {
|
|
||||||
if (!listenerAttached) {
|
|
||||||
window.addEventListener(AUTH_EXPIRED_EVENT, clearAuth)
|
|
||||||
listenerAttached = true
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const session = await authClient.getSession()
|
|
||||||
assertAuthResult(session, '获取登录会话失败,请稍后重试。')
|
|
||||||
if (session.data) await fetchMe()
|
|
||||||
else clearAuth()
|
|
||||||
} catch {
|
|
||||||
clearAuth()
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
user,
|
|
||||||
profile,
|
|
||||||
loading,
|
|
||||||
isLoggedIn,
|
|
||||||
isAdmin,
|
|
||||||
login,
|
|
||||||
register,
|
|
||||||
resendConfirmation,
|
|
||||||
confirmEmail,
|
|
||||||
sendPasswordReset,
|
|
||||||
resetPassword,
|
|
||||||
logout,
|
|
||||||
updateUsername,
|
|
||||||
refreshProfile: fetchMe,
|
|
||||||
updatePassword,
|
|
||||||
initialize,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { NButton, NCard, NResult, NSpin } from 'naive-ui'
|
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth'
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const state = ref<'loading' | 'success' | 'failed'>('loading')
|
|
||||||
const error = ref('')
|
|
||||||
const countdown = ref(5)
|
|
||||||
const token = typeof route.query.token === 'string' ? route.query.token : ''
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
if (route.query.token !== undefined) {
|
|
||||||
window.history.replaceState(window.history.state, '', route.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
function startCountdown() {
|
|
||||||
timer = setInterval(() => {
|
|
||||||
countdown.value--
|
|
||||||
if (countdown.value <= 0) {
|
|
||||||
if (timer) clearInterval(timer)
|
|
||||||
void router.push('/login')
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
if (!token) {
|
|
||||||
error.value = '确认链接缺少验证信息'
|
|
||||||
state.value = 'failed'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await authStore.confirmEmail(token)
|
|
||||||
state.value = 'success'
|
|
||||||
startCountdown()
|
|
||||||
} catch (e: unknown) {
|
|
||||||
error.value = e instanceof Error ? e.message : '确认链接无效或已过期'
|
|
||||||
state.value = 'failed'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (timer) clearInterval(timer)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
|
||||||
<div class="mx-auto max-w-3xl">
|
|
||||||
<NCard class="oc-panel-card-xl">
|
|
||||||
<NResult
|
|
||||||
v-if="state === 'success'"
|
|
||||||
status="success"
|
|
||||||
title="邮箱确认成功"
|
|
||||||
description="现在可以使用邮箱和密码登录。"
|
|
||||||
>
|
|
||||||
<template #footer>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<p class="text-sm text-slate-500">
|
|
||||||
{{ countdown }} 秒后自动前往登录页面
|
|
||||||
</p>
|
|
||||||
<RouterLink to="/login" class="no-underline"
|
|
||||||
><NButton
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--teal"
|
|
||||||
>立即登录</NButton
|
|
||||||
></RouterLink
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</NResult>
|
|
||||||
<NResult
|
|
||||||
v-else-if="state === 'failed'"
|
|
||||||
status="error"
|
|
||||||
title="邮箱确认失败"
|
|
||||||
:description="error"
|
|
||||||
>
|
|
||||||
<template #footer>
|
|
||||||
<RouterLink to="/resend-confirmation" class="no-underline"
|
|
||||||
><NButton type="default" class="oc-panel-button oc-panel-button--sky"
|
|
||||||
>重新发送验证邮件</NButton
|
|
||||||
></RouterLink
|
|
||||||
>
|
|
||||||
</template>
|
|
||||||
</NResult>
|
|
||||||
<div v-else class="flex flex-col items-center justify-center py-12 text-center">
|
|
||||||
<NSpin size="large" />
|
|
||||||
<h1 class="mt-6 text-2xl font-bold text-slate-900">正在确认邮箱...</h1>
|
|
||||||
<p class="mt-2 text-slate-500">请稍候</p>
|
|
||||||
</div>
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onUnmounted, ref } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth'
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const route = useRoute()
|
|
||||||
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
|
|
||||||
const error = ref('')
|
|
||||||
const loading = ref(false)
|
|
||||||
const submitted = ref(false)
|
|
||||||
const cooldown = ref(0)
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
const buttonLabel = computed(() =>
|
|
||||||
loading.value
|
|
||||||
? '发送中...'
|
|
||||||
: cooldown.value > 0
|
|
||||||
? `重新发送(${cooldown.value}s)`
|
|
||||||
: '重新发送',
|
|
||||||
)
|
|
||||||
|
|
||||||
function startCooldown() {
|
|
||||||
if (timer) clearInterval(timer)
|
|
||||||
cooldown.value = 60
|
|
||||||
timer = setInterval(() => {
|
|
||||||
cooldown.value--
|
|
||||||
if (cooldown.value <= 0 && timer) {
|
|
||||||
clearInterval(timer)
|
|
||||||
timer = null
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
error.value = ''
|
|
||||||
const value = email.value.trim()
|
|
||||||
if (!value || value.length > 64) {
|
|
||||||
error.value = '请输入不超过 64 个字符的有效邮箱'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
await authStore.sendPasswordReset(value)
|
|
||||||
email.value = value
|
|
||||||
submitted.value = true
|
|
||||||
startCooldown()
|
|
||||||
} catch (e: unknown) {
|
|
||||||
error.value = e instanceof Error ? e.message : '重置邮件发送失败,请稍后重试'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (timer) clearInterval(timer)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
|
||||||
<div class="mx-auto max-w-2xl">
|
|
||||||
<NCard class="oc-panel-card-xl">
|
|
||||||
<NResult
|
|
||||||
v-if="submitted"
|
|
||||||
status="success"
|
|
||||||
title="请求已受理"
|
|
||||||
description="如果该邮箱符合条件,我们会发送密码重置邮件。"
|
|
||||||
>
|
|
||||||
<template #footer>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<p class="text-sm text-slate-500">
|
|
||||||
目标邮箱:<span class="font-semibold text-slate-700">{{
|
|
||||||
email
|
|
||||||
}}</span>
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-wrap justify-center gap-3">
|
|
||||||
<NButton
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--sky"
|
|
||||||
:disabled="cooldown > 0"
|
|
||||||
:loading="loading"
|
|
||||||
@click="submit"
|
|
||||||
>{{ buttonLabel }}</NButton
|
|
||||||
>
|
|
||||||
<RouterLink
|
|
||||||
:to="{ path: '/login', query: { email } }"
|
|
||||||
class="no-underline"
|
|
||||||
><NButton
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
>返回登录</NButton
|
|
||||||
></RouterLink
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</NResult>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="mb-8">
|
|
||||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">
|
|
||||||
Password Recovery
|
|
||||||
</div>
|
|
||||||
<h1 class="mt-3 text-3xl font-bold text-slate-900">忘记密码</h1>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">
|
|
||||||
输入注册邮箱,我们会发送有效期为 1 小时的重置链接。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<NForm @submit.prevent="submit">
|
|
||||||
<NFormItem label="邮箱">
|
|
||||||
<NInput
|
|
||||||
v-model:value="email"
|
|
||||||
maxlength="64"
|
|
||||||
type="text"
|
|
||||||
placeholder="your@email.com"
|
|
||||||
:input-props="{
|
|
||||||
autocomplete: 'email',
|
|
||||||
inputmode: 'email',
|
|
||||||
name: 'email',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</NFormItem>
|
|
||||||
<NAlert v-if="error" type="error" class="mb-4">{{ error }}</NAlert>
|
|
||||||
<NButton
|
|
||||||
attr-type="submit"
|
|
||||||
type="primary"
|
|
||||||
block
|
|
||||||
size="large"
|
|
||||||
class="oc-primary-button oc-primary-button--sky"
|
|
||||||
:loading="loading"
|
|
||||||
>{{ loading ? '发送中...' : '发送重置邮件' }}</NButton
|
|
||||||
>
|
|
||||||
</NForm>
|
|
||||||
<p class="mt-6 text-sm text-slate-500">
|
|
||||||
<RouterLink
|
|
||||||
:to="{ path: '/login', query: { email } }"
|
|
||||||
class="font-semibold text-sky-700 hover:text-sky-800"
|
|
||||||
>返回登录</RouterLink
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
|
||||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput } from 'naive-ui'
|
|
||||||
import { AuthClientError } from '@/features/auth/authClient'
|
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth'
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const router = useRouter()
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
|
|
||||||
const password = ref('')
|
|
||||||
const error = ref('')
|
|
||||||
const loading = ref(false)
|
|
||||||
const needsEmailConfirmation = ref(false)
|
|
||||||
|
|
||||||
function getSafeRedirect(value: unknown) {
|
|
||||||
if (typeof value !== 'string' || !value.startsWith('/')) return '/'
|
|
||||||
|
|
||||||
try {
|
|
||||||
const target = new URL(value, window.location.origin)
|
|
||||||
return target.origin === window.location.origin
|
|
||||||
? `${target.pathname}${target.search}${target.hash}`
|
|
||||||
: '/'
|
|
||||||
} catch {
|
|
||||||
return '/'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleLogin() {
|
|
||||||
error.value = ''
|
|
||||||
needsEmailConfirmation.value = false
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
await authStore.login(email.value, password.value)
|
|
||||||
void router.push(getSafeRedirect(route.query.redirect))
|
|
||||||
} catch (e: unknown) {
|
|
||||||
needsEmailConfirmation.value =
|
|
||||||
e instanceof AuthClientError && e.code === 'EMAIL_NOT_VERIFIED'
|
|
||||||
error.value = e instanceof Error ? e.message : '登录失败,请稍后重试'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
|
||||||
<div
|
|
||||||
class="mx-auto grid min-w-0 max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-stretch"
|
|
||||||
>
|
|
||||||
<section
|
|
||||||
class="oc-panel-card-xl flex min-w-0 h-full flex-col justify-between overflow-hidden border border-slate-200 bg-[linear-gradient(135deg,#f2faf6_0%,#ffffff_48%,#eff6ff_100%)] p-6 sm:p-8"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm uppercase tracking-[0.26em] text-teal-700">Sky Log In</p>
|
|
||||||
<h1
|
|
||||||
class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl"
|
|
||||||
>
|
|
||||||
登录后继续记录
|
|
||||||
<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 grid min-w-0 grid-cols-2 gap-4">
|
|
||||||
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
|
||||||
<div class="oc-stat-label">Collection</div>
|
|
||||||
<div class="oc-stat-value">10</div>
|
|
||||||
<div class="oc-field-help">基础云属资料</div>
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
|
||||||
<div class="oc-stat-label">Mode</div>
|
|
||||||
<div class="oc-stat-value">Atlas</div>
|
|
||||||
<div class="oc-field-help">地图、百科、画廊一体</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<NCard class="oc-panel-card-xl min-w-0 h-full">
|
|
||||||
<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>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">输入邮箱和密码,继续你的天空档案。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NForm @submit.prevent="handleLogin">
|
|
||||||
<NFormItem label="邮箱">
|
|
||||||
<NInput
|
|
||||||
v-model:value="email"
|
|
||||||
type="text"
|
|
||||||
:input-props="{
|
|
||||||
autocomplete: 'username',
|
|
||||||
inputmode: 'email',
|
|
||||||
name: 'username',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
placeholder="your@email.com"
|
|
||||||
/>
|
|
||||||
</NFormItem>
|
|
||||||
|
|
||||||
<NFormItem label="密码">
|
|
||||||
<NInput
|
|
||||||
v-model:value="password"
|
|
||||||
type="password"
|
|
||||||
show-password-on="click"
|
|
||||||
:input-props="{
|
|
||||||
autocomplete: 'current-password',
|
|
||||||
name: 'current-password',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
placeholder="输入密码"
|
|
||||||
/>
|
|
||||||
</NFormItem>
|
|
||||||
|
|
||||||
<NAlert v-if="error" type="error" class="mb-4">
|
|
||||||
{{ error }}
|
|
||||||
<div v-if="needsEmailConfirmation" class="mt-2">
|
|
||||||
<RouterLink
|
|
||||||
:to="{ path: '/resend-confirmation', query: { email } }"
|
|
||||||
class="font-semibold text-rose-700 underline underline-offset-2"
|
|
||||||
>
|
|
||||||
重新发送验证邮件
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<NButton
|
|
||||||
attr-type="submit"
|
|
||||||
type="primary"
|
|
||||||
block
|
|
||||||
size="large"
|
|
||||||
class="oc-primary-button oc-primary-button--teal"
|
|
||||||
:loading="loading"
|
|
||||||
>
|
|
||||||
{{ loading ? '登录中...' : '登录' }}
|
|
||||||
</NButton>
|
|
||||||
</NForm>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="mt-6 flex flex-wrap items-center justify-between gap-3 text-sm text-slate-500"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
还没有账号?
|
|
||||||
<RouterLink
|
|
||||||
to="/register"
|
|
||||||
class="font-semibold text-sky-700 hover:text-sky-800"
|
|
||||||
>立即注册</RouterLink
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
<RouterLink
|
|
||||||
:to="{ path: '/forgot-password', query: { email } }"
|
|
||||||
class="font-semibold text-teal-700 hover:text-teal-800"
|
|
||||||
>
|
|
||||||
忘记密码
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
|
||||||
import { AuthClientError } from '@/features/auth/authClient'
|
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth'
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const name = ref('')
|
|
||||||
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
|
|
||||||
const password = ref('')
|
|
||||||
const confirmPassword = ref('')
|
|
||||||
const error = ref('')
|
|
||||||
const loading = ref(false)
|
|
||||||
const registered = ref(false)
|
|
||||||
const registerConflict = ref(false)
|
|
||||||
|
|
||||||
async function handleRegister() {
|
|
||||||
error.value = ''
|
|
||||||
registerConflict.value = false
|
|
||||||
const trimmedName = name.value.trim()
|
|
||||||
const trimmedEmail = email.value.trim()
|
|
||||||
|
|
||||||
if (!trimmedName) {
|
|
||||||
error.value = '请输入用户名'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (trimmedName.length > 16) {
|
|
||||||
error.value = '用户名不能超过 16 个字符'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!trimmedEmail || trimmedEmail.length > 64) {
|
|
||||||
error.value = '请输入不超过 64 个字符的有效邮箱'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (password.value.length < 8) {
|
|
||||||
error.value = '密码至少需要 8 位'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (password.value !== confirmPassword.value) {
|
|
||||||
error.value = '两次输入的密码不一致'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
await authStore.register(trimmedName, trimmedEmail, password.value)
|
|
||||||
email.value = trimmedEmail
|
|
||||||
registered.value = true
|
|
||||||
} catch (e: unknown) {
|
|
||||||
if (
|
|
||||||
e instanceof AuthClientError &&
|
|
||||||
(e.status === 409 || e.code === 'USER_ALREADY_EXISTS')
|
|
||||||
) {
|
|
||||||
registerConflict.value = true
|
|
||||||
error.value = '邮箱或用户名已被占用;若邮箱尚未验证,可以尝试重发验证邮件。'
|
|
||||||
} else {
|
|
||||||
error.value = e instanceof Error ? e.message : '注册失败,请稍后重试'
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
|
||||||
<div
|
|
||||||
class="mx-auto grid min-w-0 max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-stretch"
|
|
||||||
>
|
|
||||||
<section
|
|
||||||
class="oc-panel-card-xl flex min-w-0 h-full flex-col justify-between overflow-hidden border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_42%,#f2faf6_100%)] p-6 sm:p-8"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Observer Join</p>
|
|
||||||
<h1
|
|
||||||
class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl"
|
|
||||||
>
|
|
||||||
加入天空探索者
|
|
||||||
<span class="block text-sky-700">建立你的云图档案</span>
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
<div class="mt-8 grid min-w-0 grid-cols-2 gap-4">
|
|
||||||
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
|
||||||
<div class="oc-stat-label">Step 1</div>
|
|
||||||
<div class="oc-stat-value">Join</div>
|
|
||||||
<div class="oc-field-help">填写账号资料</div>
|
|
||||||
</div>
|
|
||||||
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
|
||||||
<div class="oc-stat-label">Step 2</div>
|
|
||||||
<div class="oc-stat-value">Verify</div>
|
|
||||||
<div class="oc-field-help">通过邮件确认邮箱</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<NCard class="oc-panel-card-xl min-w-0 h-full">
|
|
||||||
<NResult
|
|
||||||
v-if="registered"
|
|
||||||
status="success"
|
|
||||||
title="确认你的邮箱"
|
|
||||||
description="如该邮箱可以注册,请查收验证邮件并点击链接。验证后请重新登录。"
|
|
||||||
>
|
|
||||||
<template #footer>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<p class="text-sm text-slate-500">
|
|
||||||
目标邮箱:<span class="font-semibold text-slate-700">{{
|
|
||||||
email
|
|
||||||
}}</span>
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-wrap justify-center gap-3">
|
|
||||||
<RouterLink
|
|
||||||
:to="{ path: '/resend-confirmation', query: { email } }"
|
|
||||||
class="no-underline"
|
|
||||||
>
|
|
||||||
<NButton
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--sky"
|
|
||||||
>重发验证邮件</NButton
|
|
||||||
>
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink
|
|
||||||
:to="{ path: '/login', query: { email } }"
|
|
||||||
class="no-underline"
|
|
||||||
>
|
|
||||||
<NButton
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--teal"
|
|
||||||
>去登录</NButton
|
|
||||||
>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</NResult>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="mb-8">
|
|
||||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">
|
|
||||||
Create Account
|
|
||||||
</div>
|
|
||||||
<h2 class="mt-3 text-3xl font-bold text-slate-900">注册 OpenCloud</h2>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">
|
|
||||||
注册后,请先通过邮件确认邮箱再登录。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NForm @submit.prevent="handleRegister">
|
|
||||||
<NFormItem label="用户名">
|
|
||||||
<NInput
|
|
||||||
v-model:value="name"
|
|
||||||
maxlength="16"
|
|
||||||
show-count
|
|
||||||
placeholder="最多 16 个字符"
|
|
||||||
:input-props="{
|
|
||||||
autocomplete: 'username',
|
|
||||||
name: 'username',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</NFormItem>
|
|
||||||
<NFormItem label="邮箱">
|
|
||||||
<NInput
|
|
||||||
v-model:value="email"
|
|
||||||
maxlength="64"
|
|
||||||
type="text"
|
|
||||||
placeholder="your@email.com"
|
|
||||||
:input-props="{
|
|
||||||
autocomplete: 'email',
|
|
||||||
inputmode: 'email',
|
|
||||||
name: 'email',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</NFormItem>
|
|
||||||
<NFormItem label="密码">
|
|
||||||
<NInput
|
|
||||||
v-model:value="password"
|
|
||||||
type="password"
|
|
||||||
show-password-on="click"
|
|
||||||
placeholder="至少 8 位"
|
|
||||||
:input-props="{
|
|
||||||
autocomplete: 'new-password',
|
|
||||||
name: 'new-password',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</NFormItem>
|
|
||||||
<NFormItem label="确认密码">
|
|
||||||
<NInput
|
|
||||||
v-model:value="confirmPassword"
|
|
||||||
type="password"
|
|
||||||
show-password-on="click"
|
|
||||||
placeholder="再次输入密码"
|
|
||||||
:input-props="{
|
|
||||||
autocomplete: 'new-password',
|
|
||||||
name: 'confirm-new-password',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</NFormItem>
|
|
||||||
|
|
||||||
<NAlert v-if="error" type="error" class="mb-4">
|
|
||||||
{{ error }}
|
|
||||||
<div v-if="registerConflict" class="mt-2">
|
|
||||||
<RouterLink
|
|
||||||
:to="{ path: '/resend-confirmation', query: { email } }"
|
|
||||||
class="font-semibold text-rose-700 underline underline-offset-2"
|
|
||||||
>重发验证邮件</RouterLink
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<NButton
|
|
||||||
attr-type="submit"
|
|
||||||
type="primary"
|
|
||||||
block
|
|
||||||
size="large"
|
|
||||||
class="oc-primary-button oc-primary-button--sky"
|
|
||||||
:loading="loading"
|
|
||||||
>
|
|
||||||
{{ loading ? '注册中...' : '注册' }}
|
|
||||||
</NButton>
|
|
||||||
</NForm>
|
|
||||||
|
|
||||||
<p class="mt-6 text-sm text-slate-500">
|
|
||||||
已有账号?
|
|
||||||
<RouterLink
|
|
||||||
:to="{ path: '/login', query: { email } }"
|
|
||||||
class="font-semibold text-sky-700 hover:text-sky-800"
|
|
||||||
>去登录</RouterLink
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onUnmounted, ref } from 'vue'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth'
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const route = useRoute()
|
|
||||||
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
|
|
||||||
const error = ref('')
|
|
||||||
const loading = ref(false)
|
|
||||||
const submitted = ref(false)
|
|
||||||
const cooldown = ref(0)
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
const buttonLabel = computed(() =>
|
|
||||||
loading.value
|
|
||||||
? '发送中...'
|
|
||||||
: cooldown.value > 0
|
|
||||||
? `重新发送(${cooldown.value}s)`
|
|
||||||
: '重新发送',
|
|
||||||
)
|
|
||||||
|
|
||||||
function startCooldown() {
|
|
||||||
if (timer) clearInterval(timer)
|
|
||||||
cooldown.value = 60
|
|
||||||
timer = setInterval(() => {
|
|
||||||
cooldown.value--
|
|
||||||
if (cooldown.value <= 0 && timer) {
|
|
||||||
clearInterval(timer)
|
|
||||||
timer = null
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
error.value = ''
|
|
||||||
const value = email.value.trim()
|
|
||||||
if (!value || value.length > 64) {
|
|
||||||
error.value = '请输入不超过 64 个字符的有效邮箱'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
await authStore.resendConfirmation(value)
|
|
||||||
email.value = value
|
|
||||||
submitted.value = true
|
|
||||||
startCooldown()
|
|
||||||
} catch (e: unknown) {
|
|
||||||
error.value = e instanceof Error ? e.message : '验证邮件发送失败,请稍后重试'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (timer) clearInterval(timer)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
|
||||||
<div class="mx-auto max-w-2xl">
|
|
||||||
<NCard class="oc-panel-card-xl">
|
|
||||||
<NResult
|
|
||||||
v-if="submitted"
|
|
||||||
status="success"
|
|
||||||
title="请求已受理"
|
|
||||||
description="如果该邮箱符合条件,我们会发送新的验证邮件。"
|
|
||||||
>
|
|
||||||
<template #footer>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<p class="text-sm text-slate-500">
|
|
||||||
目标邮箱:<span class="font-semibold text-slate-700">{{
|
|
||||||
email
|
|
||||||
}}</span>
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-wrap justify-center gap-3">
|
|
||||||
<NButton
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--sky"
|
|
||||||
:disabled="cooldown > 0"
|
|
||||||
:loading="loading"
|
|
||||||
@click="submit"
|
|
||||||
>{{ buttonLabel }}</NButton
|
|
||||||
>
|
|
||||||
<RouterLink
|
|
||||||
:to="{ path: '/login', query: { email } }"
|
|
||||||
class="no-underline"
|
|
||||||
><NButton
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--teal"
|
|
||||||
>返回登录</NButton
|
|
||||||
></RouterLink
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</NResult>
|
|
||||||
<template v-else>
|
|
||||||
<div class="mb-8">
|
|
||||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">
|
|
||||||
Email Verification
|
|
||||||
</div>
|
|
||||||
<h1 class="mt-3 text-3xl font-bold text-slate-900">重发验证邮件</h1>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">
|
|
||||||
输入注册邮箱。为保护账号,系统不会透露邮箱是否存在或已验证。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<NForm @submit.prevent="submit">
|
|
||||||
<NFormItem label="邮箱"
|
|
||||||
><NInput
|
|
||||||
v-model:value="email"
|
|
||||||
maxlength="64"
|
|
||||||
type="text"
|
|
||||||
placeholder="your@email.com"
|
|
||||||
:input-props="{
|
|
||||||
autocomplete: 'email',
|
|
||||||
inputmode: 'email',
|
|
||||||
name: 'email',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
/></NFormItem>
|
|
||||||
<NAlert v-if="error" type="error" class="mb-4">{{ error }}</NAlert>
|
|
||||||
<NButton
|
|
||||||
attr-type="submit"
|
|
||||||
type="primary"
|
|
||||||
block
|
|
||||||
size="large"
|
|
||||||
class="oc-primary-button oc-primary-button--sky"
|
|
||||||
:loading="loading"
|
|
||||||
>{{ loading ? '发送中...' : '发送验证邮件' }}</NButton
|
|
||||||
>
|
|
||||||
</NForm>
|
|
||||||
</template>
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onUnmounted, ref } from 'vue'
|
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
|
||||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
|
||||||
import { AuthClientError } from '@/features/auth/authClient'
|
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth'
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const route = useRoute()
|
|
||||||
const router = useRouter()
|
|
||||||
const email = typeof route.query.email === 'string' ? route.query.email : ''
|
|
||||||
const token = typeof route.query.token === 'string' ? route.query.token : ''
|
|
||||||
const password = ref('')
|
|
||||||
const confirmPassword = ref('')
|
|
||||||
const error = ref(!token ? '密码重置链接缺少必要信息,请重新发送邮件。' : '')
|
|
||||||
const invalid = ref(!token)
|
|
||||||
const success = ref(false)
|
|
||||||
const loading = ref(false)
|
|
||||||
const countdown = ref(5)
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null
|
|
||||||
|
|
||||||
if (route.query.token !== undefined || route.query.email !== undefined) {
|
|
||||||
window.history.replaceState(window.history.state, '', route.path)
|
|
||||||
}
|
|
||||||
|
|
||||||
const canSubmit = computed(
|
|
||||||
() => !invalid.value && password.value.length >= 8 && password.value === confirmPassword.value,
|
|
||||||
)
|
|
||||||
|
|
||||||
function startCountdown() {
|
|
||||||
timer = setInterval(() => {
|
|
||||||
countdown.value--
|
|
||||||
if (countdown.value <= 0) {
|
|
||||||
if (timer) clearInterval(timer)
|
|
||||||
void router.push({ path: '/login', query: { email } })
|
|
||||||
}
|
|
||||||
}, 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submit() {
|
|
||||||
error.value = ''
|
|
||||||
if (password.value.length < 8) {
|
|
||||||
error.value = '新密码至少需要 8 位'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (password.value !== confirmPassword.value) {
|
|
||||||
error.value = '两次输入的密码不一致'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
await authStore.resetPassword(token, password.value)
|
|
||||||
success.value = true
|
|
||||||
startCountdown()
|
|
||||||
} catch (e: unknown) {
|
|
||||||
error.value = e instanceof Error ? e.message : '密码重置失败,请稍后重试'
|
|
||||||
if (e instanceof AuthClientError && (e.status === 400 || e.code === 'INVALID_TOKEN'))
|
|
||||||
invalid.value = true
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (timer) clearInterval(timer)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
|
||||||
<div class="mx-auto max-w-2xl">
|
|
||||||
<NCard class="oc-panel-card-xl">
|
|
||||||
<NResult
|
|
||||||
v-if="success"
|
|
||||||
status="success"
|
|
||||||
title="密码已重置"
|
|
||||||
description="请使用新密码重新登录。"
|
|
||||||
>
|
|
||||||
<template #footer>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<p class="text-sm text-slate-500">
|
|
||||||
{{ countdown }} 秒后自动前往登录页面
|
|
||||||
</p>
|
|
||||||
<NButton
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--teal"
|
|
||||||
@click="router.push({ path: '/login', query: { email } })"
|
|
||||||
>立即登录</NButton
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</NResult>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="mb-8">
|
|
||||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">
|
|
||||||
Password Recovery
|
|
||||||
</div>
|
|
||||||
<h1 class="mt-3 text-3xl font-bold text-slate-900">设置新密码</h1>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">
|
|
||||||
提交成功后,此重置链接和现有登录会话都会失效。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<NForm @submit.prevent="submit">
|
|
||||||
<NFormItem label="新密码"
|
|
||||||
><NInput
|
|
||||||
v-model:value="password"
|
|
||||||
type="password"
|
|
||||||
show-password-on="click"
|
|
||||||
placeholder="至少 8 位"
|
|
||||||
:disabled="invalid"
|
|
||||||
:input-props="{
|
|
||||||
autocomplete: 'new-password',
|
|
||||||
name: 'new-password',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
/></NFormItem>
|
|
||||||
<NFormItem label="确认新密码"
|
|
||||||
><NInput
|
|
||||||
v-model:value="confirmPassword"
|
|
||||||
type="password"
|
|
||||||
show-password-on="click"
|
|
||||||
placeholder="再次输入新密码"
|
|
||||||
:disabled="invalid"
|
|
||||||
:input-props="{
|
|
||||||
autocomplete: 'new-password',
|
|
||||||
name: 'confirm-new-password',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
/></NFormItem>
|
|
||||||
<NAlert v-if="error" type="error" class="mb-4">{{ error }}</NAlert>
|
|
||||||
<RouterLink
|
|
||||||
v-if="invalid"
|
|
||||||
:to="{ path: '/forgot-password', query: { email } }"
|
|
||||||
class="mb-4 block text-sm font-semibold text-teal-700 hover:text-teal-800"
|
|
||||||
>重新发送密码重置邮件</RouterLink
|
|
||||||
>
|
|
||||||
<NButton
|
|
||||||
attr-type="submit"
|
|
||||||
type="primary"
|
|
||||||
block
|
|
||||||
size="large"
|
|
||||||
class="oc-primary-button oc-primary-button--teal"
|
|
||||||
:disabled="!canSubmit"
|
|
||||||
:loading="loading"
|
|
||||||
>保存新密码</NButton
|
|
||||||
>
|
|
||||||
</NForm>
|
|
||||||
</template>
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
export const CLOUD_TYPES = [
|
|
||||||
{ id: 1, name: '积云' },
|
|
||||||
{ id: 2, name: '层云' },
|
|
||||||
{ id: 3, name: '卷云' },
|
|
||||||
{ id: 4, name: '积雨云' },
|
|
||||||
{ id: 5, name: '层积云' },
|
|
||||||
{ id: 6, name: '高积云' },
|
|
||||||
{ id: 7, name: '高层云' },
|
|
||||||
{ id: 8, name: '雨层云' },
|
|
||||||
{ id: 9, name: '卷层云' },
|
|
||||||
{ id: 10, name: '卷积云' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export function getCloudTypeName(id: string | string[]) {
|
|
||||||
const cloudTypeId = Number(Array.isArray(id) ? id[0] : id)
|
|
||||||
return CLOUD_TYPES.find(item => item.id === cloudTypeId)?.name ?? '云型详情'
|
|
||||||
}
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, watch } from 'vue'
|
|
||||||
import { NAlert, NButton, NIcon } from 'naive-ui'
|
|
||||||
import { Map as MapIcon, X } from '@vicons/tabler'
|
|
||||||
import MapPickerModal from '@/shared/map/components/MapPickerModal.vue'
|
|
||||||
import type { CloudType } from '@/types/database'
|
|
||||||
|
|
||||||
export interface CloudEditFormValue {
|
|
||||||
cloudCategoryId: number | null
|
|
||||||
capturedAt: string
|
|
||||||
latitude: number | null
|
|
||||||
longitude: number | null
|
|
||||||
description: string
|
|
||||||
isPublic: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
open: boolean
|
|
||||||
saving?: boolean
|
|
||||||
error?: string
|
|
||||||
cloudTypes: CloudType[]
|
|
||||||
initialValue: CloudEditFormValue | null
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
saving: false,
|
|
||||||
error: '',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
close: []
|
|
||||||
save: [value: CloudEditFormValue]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const form = ref<CloudEditFormValue>(createEmptyForm())
|
|
||||||
const mapPickerOpen = ref(false)
|
|
||||||
|
|
||||||
function createEmptyForm(): CloudEditFormValue {
|
|
||||||
return {
|
|
||||||
cloudCategoryId: null,
|
|
||||||
capturedAt: '',
|
|
||||||
latitude: null,
|
|
||||||
longitude: null,
|
|
||||||
description: '',
|
|
||||||
isPublic: true,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cloneForm(value: CloudEditFormValue | null) {
|
|
||||||
return value ? { ...value } : createEmptyForm()
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCategoryChange(event: Event) {
|
|
||||||
const value = (event.target as HTMLSelectElement).value
|
|
||||||
if (!value) {
|
|
||||||
form.value.cloudCategoryId = null
|
|
||||||
} else {
|
|
||||||
form.value.cloudCategoryId = Number(value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLatInput(event: Event) {
|
|
||||||
const value = Number.parseFloat((event.target as HTMLInputElement).value)
|
|
||||||
form.value.latitude = Number.isNaN(value) ? null : value
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLngInput(event: Event) {
|
|
||||||
const value = Number.parseFloat((event.target as HTMLInputElement).value)
|
|
||||||
form.value.longitude = Number.isNaN(value) ? null : value
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmMapPicker(payload: { latitude: number; longitude: number }) {
|
|
||||||
form.value.latitude = payload.latitude
|
|
||||||
form.value.longitude = payload.longitude
|
|
||||||
mapPickerOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
|
|
||||||
function save() {
|
|
||||||
emit('save', { ...form.value })
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.open,
|
|
||||||
open => {
|
|
||||||
if (open) {
|
|
||||||
form.value = cloneForm(props.initialValue)
|
|
||||||
} else {
|
|
||||||
mapPickerOpen.value = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.initialValue,
|
|
||||||
value => {
|
|
||||||
if (props.open) {
|
|
||||||
form.value = cloneForm(value)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<div
|
|
||||||
v-if="open"
|
|
||||||
class="fixed inset-0 z-[130] flex items-center justify-center bg-slate-950/60 px-4 py-6"
|
|
||||||
@click="close"
|
|
||||||
>
|
|
||||||
<div class="oc-modal-shell w-full max-w-2xl" @click.stop>
|
|
||||||
<div class="flex items-start justify-between border-b border-slate-200 px-6 py-5">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-xl font-semibold text-slate-900">编辑图片信息</h3>
|
|
||||||
<p class="oc-field-help">修改后会同步影响个人主页、画廊和地图中的展示。</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-icon-button"
|
|
||||||
aria-label="关闭编辑窗口"
|
|
||||||
title="关闭编辑窗口"
|
|
||||||
@click="close"
|
|
||||||
>
|
|
||||||
<NIcon size="20"><X /></NIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="max-h-[70vh] overflow-y-auto px-6 py-5">
|
|
||||||
<NAlert
|
|
||||||
v-if="error"
|
|
||||||
class="mb-5"
|
|
||||||
type="error"
|
|
||||||
:show-icon="false"
|
|
||||||
:bordered="false"
|
|
||||||
>
|
|
||||||
{{ error }}
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<div class="grid gap-5">
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label">类别</label>
|
|
||||||
<select
|
|
||||||
:value="form.cloudCategoryId ?? ''"
|
|
||||||
class="oc-field-control"
|
|
||||||
@change="onCategoryChange"
|
|
||||||
>
|
|
||||||
<option value="" disabled>请选择</option>
|
|
||||||
<option v-for="ct in cloudTypes" :key="ct.id" :value="ct.id">
|
|
||||||
{{ ct.name }}{{ ct.genus ? '(' + ct.genus + ')' : '' }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label">拍摄时间</label>
|
|
||||||
<input
|
|
||||||
v-model="form.capturedAt"
|
|
||||||
type="datetime-local"
|
|
||||||
class="oc-field-control"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label"
|
|
||||||
>经纬度 <span class="oc-field-required">*</span></label
|
|
||||||
>
|
|
||||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
|
|
||||||
<input
|
|
||||||
:value="form.latitude !== null ? form.latitude : ''"
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
placeholder="纬度"
|
|
||||||
class="oc-field-control"
|
|
||||||
@input="onLatInput"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
:value="form.longitude !== null ? form.longitude : ''"
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
placeholder="经度"
|
|
||||||
class="oc-field-control"
|
|
||||||
@input="onLngInput"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-icon-button oc-icon-button--sky"
|
|
||||||
title="地图选点"
|
|
||||||
aria-label="地图选点"
|
|
||||||
@click="mapPickerOpen = true"
|
|
||||||
>
|
|
||||||
<NIcon size="20"><MapIcon /></NIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label">图片描述</label>
|
|
||||||
<textarea
|
|
||||||
v-model="form.description"
|
|
||||||
rows="4"
|
|
||||||
class="oc-field-control resize-none"
|
|
||||||
placeholder="描述一下这张图片..."
|
|
||||||
></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label
|
|
||||||
class="flex items-center justify-between gap-4 border border-slate-200 bg-slate-50 px-4 py-3"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<span class="block text-sm font-medium text-slate-800"
|
|
||||||
>公开展示</span
|
|
||||||
>
|
|
||||||
<span class="mt-1 block text-xs text-slate-500"
|
|
||||||
>关闭后不会出现在画廊、地图和公开主页中。</span
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
v-model="form.isPublic"
|
|
||||||
type="checkbox"
|
|
||||||
class="h-5 w-5 border-slate-300 text-sky-500 focus:ring-sky-500"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-end gap-3 border-t border-slate-200 px-6 py-4"
|
|
||||||
>
|
|
||||||
<NButton
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
@click="close"
|
|
||||||
>取消</NButton
|
|
||||||
>
|
|
||||||
<NButton
|
|
||||||
type="default"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
class="oc-panel-button oc-panel-button--sky"
|
|
||||||
:loading="saving"
|
|
||||||
@click="save"
|
|
||||||
>
|
|
||||||
保存修改
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
|
|
||||||
<MapPickerModal
|
|
||||||
:open="mapPickerOpen"
|
|
||||||
:latitude="form.latitude"
|
|
||||||
:longitude="form.longitude"
|
|
||||||
description="点击地图即可回填这张图片的经纬度。"
|
|
||||||
footer-text="保存时会继续做模糊化处理。"
|
|
||||||
@close="mapPickerOpen = false"
|
|
||||||
@confirm="confirmMapPicker"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import { NButton, NIcon, useMessage } from 'naive-ui'
|
|
||||||
import { Heart } from '@vicons/tabler'
|
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth'
|
|
||||||
import { useLikesStore } from '@/features/clouds/stores/likes'
|
|
||||||
|
|
||||||
const props = defineProps<{ cloudId: string; count: number }>()
|
|
||||||
const route = useRoute()
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const likesStore = useLikesStore()
|
|
||||||
const message = useMessage()
|
|
||||||
const loading = ref(false)
|
|
||||||
const error = ref('')
|
|
||||||
|
|
||||||
const liked = computed(() => likesStore.isLiked(props.cloudId))
|
|
||||||
const count = computed(() => likesStore.countFor(props.cloudId, props.count))
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [authStore.user?.id, props.cloudId],
|
|
||||||
async ([userId]) => {
|
|
||||||
error.value = ''
|
|
||||||
if (!userId) return
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
await likesStore.ensureLoaded()
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e instanceof Error ? e.message : '点赞状态加载失败'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
async function toggleLike() {
|
|
||||||
error.value = ''
|
|
||||||
try {
|
|
||||||
await likesStore.toggle(props.cloudId, props.count)
|
|
||||||
message.success(liked.value ? '已点赞' : '已取消点赞')
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e instanceof Error ? e.message : '点赞操作失败'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="mt-5 flex flex-wrap items-center gap-3">
|
|
||||||
<NButton
|
|
||||||
v-if="authStore.isLoggedIn"
|
|
||||||
type="default"
|
|
||||||
:class="[
|
|
||||||
'oc-panel-button',
|
|
||||||
liked ? 'oc-panel-button--teal' : 'oc-panel-button--neutral',
|
|
||||||
]"
|
|
||||||
:loading="loading || likesStore.isPending(cloudId)"
|
|
||||||
@click="toggleLike"
|
|
||||||
>
|
|
||||||
<template #icon
|
|
||||||
><NIcon><Heart /></NIcon
|
|
||||||
></template>
|
|
||||||
{{ liked ? '取消点赞' : '点赞' }}
|
|
||||||
</NButton>
|
|
||||||
<RouterLink
|
|
||||||
v-else
|
|
||||||
:to="{ path: '/login', query: { redirect: route.fullPath } }"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral no-underline"
|
|
||||||
>登录后点赞</RouterLink
|
|
||||||
>
|
|
||||||
<span class="text-sm text-slate-600">{{ count }} 次点赞</span>
|
|
||||||
<span v-if="error" class="text-sm text-rose-700">{{ error }}</span>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,322 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { InfoCircle, X } from '@vicons/tabler'
|
|
||||||
import { NIcon } from 'naive-ui'
|
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
open: boolean
|
|
||||||
imageUrl: string
|
|
||||||
thumbnailUrl?: string | null
|
|
||||||
imageAlt: string
|
|
||||||
title: string
|
|
||||||
subtitle?: string
|
|
||||||
badgeLabel?: string
|
|
||||||
badgeClass?: string
|
|
||||||
panelWidth?: string
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
subtitle: '',
|
|
||||||
thumbnailUrl: null,
|
|
||||||
badgeLabel: '',
|
|
||||||
badgeClass: '',
|
|
||||||
panelWidth: '',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
close: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const infoOpen = ref(false)
|
|
||||||
const imageLoaded = ref(false)
|
|
||||||
const previewLoaded = ref(false)
|
|
||||||
const naturalWidth = ref(0)
|
|
||||||
const naturalHeight = ref(0)
|
|
||||||
const viewportWidth = ref(0)
|
|
||||||
const viewportHeight = ref(0)
|
|
||||||
const modalVisible = ref(false)
|
|
||||||
const closing = ref(false)
|
|
||||||
let closeTimer: number | null = null
|
|
||||||
const previousBodyOverflow = ref<string | null>(null)
|
|
||||||
const previousDocumentOverflow = ref<string | null>(null)
|
|
||||||
|
|
||||||
function updateViewport() {
|
|
||||||
if (typeof window === 'undefined') return
|
|
||||||
viewportWidth.value = window.innerWidth
|
|
||||||
viewportHeight.value = window.innerHeight
|
|
||||||
}
|
|
||||||
|
|
||||||
const layout = computed(() => {
|
|
||||||
const aspectRatio =
|
|
||||||
naturalWidth.value > 0 && naturalHeight.value > 0
|
|
||||||
? naturalWidth.value / naturalHeight.value
|
|
||||||
: 4 / 3
|
|
||||||
const maxModalWidth =
|
|
||||||
viewportWidth.value > 0
|
|
||||||
? Math.max(Math.min(viewportWidth.value * 0.95, viewportWidth.value - 32), 280)
|
|
||||||
: 320
|
|
||||||
const maxImageHeight =
|
|
||||||
viewportHeight.value > 0 ? Math.max(viewportHeight.value * 0.9, 240) : 360
|
|
||||||
const imageWidth = Math.min(maxModalWidth, maxImageHeight * aspectRatio)
|
|
||||||
|
|
||||||
return {
|
|
||||||
imageWidth,
|
|
||||||
imageHeight: imageWidth / aspectRatio,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const imageStyle = computed(
|
|
||||||
() =>
|
|
||||||
({
|
|
||||||
width: `${Math.round(layout.value.imageWidth)}px`,
|
|
||||||
height: `${Math.round(layout.value.imageHeight)}px`,
|
|
||||||
}) as Record<string, string>,
|
|
||||||
)
|
|
||||||
|
|
||||||
function resetImageState() {
|
|
||||||
imageLoaded.value = false
|
|
||||||
previewLoaded.value = false
|
|
||||||
naturalWidth.value = 0
|
|
||||||
naturalHeight.value = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function lockPageScroll() {
|
|
||||||
if (typeof document === 'undefined' || previousBodyOverflow.value !== null) return
|
|
||||||
previousBodyOverflow.value = document.body.style.overflow
|
|
||||||
previousDocumentOverflow.value = document.documentElement.style.overflow
|
|
||||||
document.body.style.overflow = 'hidden'
|
|
||||||
document.documentElement.style.overflow = 'hidden'
|
|
||||||
}
|
|
||||||
|
|
||||||
function unlockPageScroll() {
|
|
||||||
if (typeof document === 'undefined' || previousBodyOverflow.value === null) return
|
|
||||||
document.body.style.overflow = previousBodyOverflow.value
|
|
||||||
document.documentElement.style.overflow = previousDocumentOverflow.value ?? ''
|
|
||||||
previousBodyOverflow.value = null
|
|
||||||
previousDocumentOverflow.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.open,
|
|
||||||
open => {
|
|
||||||
if (open) {
|
|
||||||
if (closeTimer !== null) {
|
|
||||||
window.clearTimeout(closeTimer)
|
|
||||||
closeTimer = null
|
|
||||||
}
|
|
||||||
closing.value = false
|
|
||||||
modalVisible.value = true
|
|
||||||
lockPageScroll()
|
|
||||||
infoOpen.value = false
|
|
||||||
resetImageState()
|
|
||||||
} else {
|
|
||||||
modalVisible.value = false
|
|
||||||
unlockPageScroll()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.imageUrl,
|
|
||||||
() => {
|
|
||||||
resetImageState()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
if (closing.value) return
|
|
||||||
closing.value = true
|
|
||||||
modalVisible.value = false
|
|
||||||
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
unlockPageScroll()
|
|
||||||
emit('close')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
closeTimer = window.setTimeout(() => {
|
|
||||||
closeTimer = null
|
|
||||||
unlockPageScroll()
|
|
||||||
emit('close')
|
|
||||||
}, 180)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleInfo() {
|
|
||||||
infoOpen.value = !infoOpen.value
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleImageLoad(event: Event) {
|
|
||||||
const target = event.target as HTMLImageElement | null
|
|
||||||
if (!target) return
|
|
||||||
naturalWidth.value = target.naturalWidth
|
|
||||||
naturalHeight.value = target.naturalHeight
|
|
||||||
imageLoaded.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function handlePreviewLoad(event: Event) {
|
|
||||||
const target = event.target as HTMLImageElement | null
|
|
||||||
if (!target) return
|
|
||||||
if (!naturalWidth.value || !naturalHeight.value) {
|
|
||||||
naturalWidth.value = target.naturalWidth
|
|
||||||
naturalHeight.value = target.naturalHeight
|
|
||||||
}
|
|
||||||
previewLoaded.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
updateViewport()
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.addEventListener('resize', updateViewport)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (closeTimer !== null) {
|
|
||||||
window.clearTimeout(closeTimer)
|
|
||||||
}
|
|
||||||
unlockPageScroll()
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
window.removeEventListener('resize', updateViewport)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<Transition appear name="image-detail-modal">
|
|
||||||
<div
|
|
||||||
v-if="modalVisible"
|
|
||||||
class="fixed inset-0 z-[110] flex items-center justify-center bg-black/85 px-4 py-6"
|
|
||||||
@click="close"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="image-detail-shell relative w-fit max-w-[92vw] overflow-hidden shadow-2xl"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-overlay-button absolute right-3 top-3 z-30 h-9 w-9 opacity-0 drop-shadow-[0_2px_8px_rgba(15,23,42,0.9)] transition-opacity duration-200 hover:opacity-100 focus-visible:opacity-100"
|
|
||||||
aria-label="关闭大图"
|
|
||||||
title="关闭大图"
|
|
||||||
@click="close"
|
|
||||||
>
|
|
||||||
<NIcon size="20">
|
|
||||||
<X />
|
|
||||||
</NIcon>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="relative flex min-h-0 items-center justify-center overflow-hidden bg-slate-950"
|
|
||||||
:style="imageStyle"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-if="!previewLoaded && !imageLoaded"
|
|
||||||
class="absolute inset-0 animate-pulse bg-[linear-gradient(110deg,#0f172a_0%,#1e293b_45%,#334155_55%,#0f172a_100%)] bg-[length:220%_100%]"
|
|
||||||
></div>
|
|
||||||
<img
|
|
||||||
v-if="thumbnailUrl"
|
|
||||||
:src="thumbnailUrl"
|
|
||||||
:alt="imageAlt"
|
|
||||||
class="absolute inset-0 block h-full w-full object-contain blur-[1px] transition-opacity duration-300"
|
|
||||||
:class="imageLoaded ? 'opacity-0' : 'opacity-100'"
|
|
||||||
@load="handlePreviewLoad"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
:src="imageUrl"
|
|
||||||
:alt="imageAlt"
|
|
||||||
class="relative block h-full w-full object-contain transition-opacity duration-300"
|
|
||||||
:class="imageLoaded ? 'opacity-100' : 'opacity-0'"
|
|
||||||
@load="handleImageLoad"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="image-detail-info-button oc-overlay-button absolute bottom-4 right-4 z-30 h-11 w-11 drop-shadow-[0_2px_8px_rgba(15,23,42,0.9)]"
|
|
||||||
:aria-label="infoOpen ? '隐藏详细信息' : '查看详细信息'"
|
|
||||||
:title="infoOpen ? '隐藏详细信息' : '查看详细信息'"
|
|
||||||
@click="toggleInfo"
|
|
||||||
>
|
|
||||||
<NIcon size="30">
|
|
||||||
<InfoCircle />
|
|
||||||
</NIcon>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="infoOpen"
|
|
||||||
class="absolute bottom-16 right-4 z-20 flex max-h-[min(70vh,560px)] w-[min(360px,calc(100%-2rem))] flex-col overflow-hidden border border-white/70 bg-white/95 shadow-2xl backdrop-blur"
|
|
||||||
>
|
|
||||||
<div class="border-b border-slate-200 px-5 py-4">
|
|
||||||
<div class="pr-8">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<h2 class="text-xl font-bold text-slate-900">
|
|
||||||
{{ title }}
|
|
||||||
</h2>
|
|
||||||
<span
|
|
||||||
v-if="badgeLabel"
|
|
||||||
class="border px-3 py-1 text-xs font-medium"
|
|
||||||
:class="badgeClass"
|
|
||||||
>
|
|
||||||
{{ badgeLabel }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p v-if="subtitle" class="mt-2 text-sm text-slate-500">
|
|
||||||
{{ subtitle }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="image-detail-panel-close oc-icon-button oc-icon-button--small absolute right-3 top-3"
|
|
||||||
aria-label="隐藏详细信息"
|
|
||||||
title="隐藏详细信息"
|
|
||||||
@click="infoOpen = false"
|
|
||||||
>
|
|
||||||
<NIcon size="16"><X /></NIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-y-auto px-5 py-4">
|
|
||||||
<slot />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="$slots.actions" class="border-t border-slate-200 px-5 py-4">
|
|
||||||
<slot name="actions" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.image-detail-modal-enter-active,
|
|
||||||
.image-detail-modal-leave-active {
|
|
||||||
transition: opacity 180ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-detail-modal-enter-active .image-detail-shell,
|
|
||||||
.image-detail-modal-leave-active .image-detail-shell {
|
|
||||||
transition:
|
|
||||||
opacity 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
||||||
transform 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-detail-modal-enter-from,
|
|
||||||
.image-detail-modal-leave-to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-detail-modal-enter-from .image-detail-shell,
|
|
||||||
.image-detail-modal-leave-to .image-detail-shell {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px) scale(0.97);
|
|
||||||
}
|
|
||||||
|
|
||||||
.image-detail-info-button.image-detail-info-button {
|
|
||||||
border-radius: 9999px !important;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
import { ref } from 'vue'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { apiRequest, toCloudType } from '@/lib/api'
|
|
||||||
import type { BackendCloudType } from '@/types/api'
|
|
||||||
import type { CloudType } from '@/types/database'
|
|
||||||
|
|
||||||
export const useCloudsStore = defineStore('clouds', () => {
|
|
||||||
const cloudTypes = ref<CloudType[]>([])
|
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
async function fetchCloudTypes() {
|
|
||||||
if (cloudTypes.value.length > 0) return
|
|
||||||
loading.value = true
|
|
||||||
try {
|
|
||||||
const rows = await apiRequest<BackendCloudType[]>('/info/cloudtype', { auth: false })
|
|
||||||
cloudTypes.value = rows.map(toCloudType)
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCloudTypes(types: CloudType[]) {
|
|
||||||
cloudTypes.value = types
|
|
||||||
}
|
|
||||||
|
|
||||||
return { cloudTypes, loading, fetchCloudTypes, setCloudTypes }
|
|
||||||
})
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
import { ref, watch } from 'vue'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { apiRequest } from '@/lib/api'
|
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth'
|
|
||||||
import type { ActionResponse, BackendCloudInfo } from '@/types/api'
|
|
||||||
|
|
||||||
const PAGE_SIZE = 100
|
|
||||||
|
|
||||||
export const useLikesStore = defineStore('likes', () => {
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const likedIds = ref<Set<string>>(new Set())
|
|
||||||
const counts = ref<Record<string, number>>({})
|
|
||||||
const pendingIds = ref<Set<string>>(new Set())
|
|
||||||
const loadedForUser = ref<string | null>(null)
|
|
||||||
let loadPromise: Promise<void> | null = null
|
|
||||||
let loadingForUser: string | null = null
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => authStore.user?.id,
|
|
||||||
() => {
|
|
||||||
likedIds.value = new Set()
|
|
||||||
counts.value = {}
|
|
||||||
pendingIds.value = new Set()
|
|
||||||
loadedForUser.value = null
|
|
||||||
loadPromise = null
|
|
||||||
loadingForUser = null
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
function isLiked(cloudId: string) {
|
|
||||||
return likedIds.value.has(cloudId)
|
|
||||||
}
|
|
||||||
|
|
||||||
function countFor(cloudId: string, initialCount: number) {
|
|
||||||
return counts.value[cloudId] ?? initialCount
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPending(cloudId: string) {
|
|
||||||
return pendingIds.value.has(cloudId)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureLoaded() {
|
|
||||||
const userId = authStore.user?.id
|
|
||||||
if (!userId || loadedForUser.value === userId) return
|
|
||||||
if (loadPromise && loadingForUser === userId) return loadPromise
|
|
||||||
|
|
||||||
loadingForUser = userId
|
|
||||||
const request = (async () => {
|
|
||||||
const nextIds = new Set<string>()
|
|
||||||
for (let page = 1; ; page++) {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
page: String(page),
|
|
||||||
page_size: String(PAGE_SIZE),
|
|
||||||
})
|
|
||||||
const rows = await apiRequest<BackendCloudInfo[]>(`/profile/me/likes?${params}`)
|
|
||||||
rows.forEach(row => nextIds.add(row.id))
|
|
||||||
if (rows.length < PAGE_SIZE) break
|
|
||||||
}
|
|
||||||
if (authStore.user?.id === userId) {
|
|
||||||
likedIds.value = nextIds
|
|
||||||
loadedForUser.value = userId
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
loadPromise = request
|
|
||||||
try {
|
|
||||||
await request
|
|
||||||
} finally {
|
|
||||||
if (loadPromise === request) {
|
|
||||||
loadPromise = null
|
|
||||||
loadingForUser = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggle(cloudId: string, initialCount: number) {
|
|
||||||
const userId = authStore.user?.id
|
|
||||||
if (!userId) throw new Error('请先登录后点赞。')
|
|
||||||
await ensureLoaded()
|
|
||||||
if (authStore.user?.id !== userId) throw new Error('登录状态已改变,请重试。')
|
|
||||||
if (isPending(cloudId)) return
|
|
||||||
|
|
||||||
const wasLiked = isLiked(cloudId)
|
|
||||||
pendingIds.value = new Set(pendingIds.value).add(cloudId)
|
|
||||||
try {
|
|
||||||
await apiRequest<ActionResponse>(`/cloud/${cloudId}/like`, {
|
|
||||||
method: wasLiked ? 'DELETE' : 'PUT',
|
|
||||||
})
|
|
||||||
if (authStore.user?.id !== userId) return
|
|
||||||
|
|
||||||
const nextIds = new Set(likedIds.value)
|
|
||||||
if (wasLiked) nextIds.delete(cloudId)
|
|
||||||
else nextIds.add(cloudId)
|
|
||||||
likedIds.value = nextIds
|
|
||||||
counts.value[cloudId] = Math.max(
|
|
||||||
0,
|
|
||||||
countFor(cloudId, initialCount) + (wasLiked ? -1 : 1),
|
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const cloud = await apiRequest<BackendCloudInfo>(`/cloud/${cloudId}`, {
|
|
||||||
auth: false,
|
|
||||||
})
|
|
||||||
counts.value[cloudId] = cloud.received_like_count
|
|
||||||
if (cloud.owner.id === authStore.user?.id) await authStore.refreshProfile()
|
|
||||||
} catch {
|
|
||||||
// The mutation succeeded; keep the local count if a refresh fails.
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
const nextPending = new Set(pendingIds.value)
|
|
||||||
nextPending.delete(cloudId)
|
|
||||||
pendingIds.value = nextPending
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return { isLiked, countFor, isPending, ensureLoaded, toggle }
|
|
||||||
})
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
<script setup lang="ts"></script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#f2faf6_0%,#f8fafc_100%)] px-4 py-16"
|
|
||||||
>
|
|
||||||
<section class="oc-panel-card-xl mx-auto max-w-4xl border border-slate-200 bg-white p-10">
|
|
||||||
<p class="text-sm font-semibold uppercase tracking-[0.24em] text-teal-700">Community</p>
|
|
||||||
<h1 class="mt-4 text-4xl font-bold text-slate-900">社区</h1>
|
|
||||||
<p class="mt-5 max-w-2xl text-sm leading-7 text-slate-600">
|
|
||||||
社区模块正在规划中。后续这里会承载话题讨论、观测活动和用户互动内容。
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
import { ref } from 'vue'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { apiRequest, toCloudType } from '@/lib/api'
|
|
||||||
import type { BackendCloudType } from '@/types/api'
|
|
||||||
import type { CloudType } from '@/types/database'
|
|
||||||
|
|
||||||
export const useEncyclopediaStore = defineStore('encyclopedia', () => {
|
|
||||||
const cloudTypes = ref<CloudType[]>([])
|
|
||||||
const loadingCloudTypes = ref(false)
|
|
||||||
const cloudTypesLoaded = ref(false)
|
|
||||||
|
|
||||||
async function fetchCloudTypes(force = false) {
|
|
||||||
if (cloudTypesLoaded.value && !force) return
|
|
||||||
loadingCloudTypes.value = true
|
|
||||||
try {
|
|
||||||
const rows = await apiRequest<BackendCloudType[]>('/info/cloudtype', { auth: false })
|
|
||||||
cloudTypes.value = rows.map(toCloudType)
|
|
||||||
cloudTypesLoaded.value = true
|
|
||||||
} finally {
|
|
||||||
loadingCloudTypes.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCloudTypes(types: CloudType[]) {
|
|
||||||
cloudTypes.value = types
|
|
||||||
cloudTypesLoaded.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
cloudTypes,
|
|
||||||
loadingCloudTypes,
|
|
||||||
fetchCloudTypes,
|
|
||||||
setCloudTypes,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
|
||||||
import { NAlert, NButton, NEmpty, NSkeleton } from 'naive-ui'
|
|
||||||
import { RouterLink, useRoute } from 'vue-router'
|
|
||||||
import ImageDetailModal from '@/features/clouds/components/ImageDetailModal.vue'
|
|
||||||
import CloudLikeButton from '@/features/clouds/components/CloudLikeButton.vue'
|
|
||||||
import MiniLocationMap from '@/shared/map/components/MiniLocationMap.vue'
|
|
||||||
import { apiRequest, imageUrl, toApiCloud } from '@/lib/api'
|
|
||||||
import { useEncyclopediaStore } from '@/features/encyclopedia/stores/encyclopedia'
|
|
||||||
import type { ApiCloud, BackendCloudInfo } from '@/types/api'
|
|
||||||
|
|
||||||
const PAGE_SIZE = 24
|
|
||||||
const route = useRoute()
|
|
||||||
const encyclopediaStore = useEncyclopediaStore()
|
|
||||||
const loading = ref(true)
|
|
||||||
const loadError = ref('')
|
|
||||||
const gallery = ref<ApiCloud[]>([])
|
|
||||||
const selectedItem = ref<ApiCloud | null>(null)
|
|
||||||
const currentPage = ref(1)
|
|
||||||
const hasNextPage = ref(false)
|
|
||||||
|
|
||||||
const cloudTypeId = computed(() => Number(route.params.id))
|
|
||||||
const cloudType = computed(
|
|
||||||
() => encyclopediaStore.cloudTypes.find(item => item.id === cloudTypeId.value) ?? null,
|
|
||||||
)
|
|
||||||
const heroImage = computed(() =>
|
|
||||||
cloudType.value?.icon_id ? imageUrl(cloudType.value.icon_id, 'original') : null,
|
|
||||||
)
|
|
||||||
|
|
||||||
function formatDateTime(iso: string | null) {
|
|
||||||
if (!iso) return '未记录拍摄时间'
|
|
||||||
return new Date(iso).toLocaleString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'numeric',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadGallery(page: number) {
|
|
||||||
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
|
|
||||||
const rows = await apiRequest<BackendCloudInfo[]>(
|
|
||||||
`/cloud/type/${cloudTypeId.value}?${params}`,
|
|
||||||
{
|
|
||||||
auth: false,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
gallery.value = rows.map(toApiCloud)
|
|
||||||
currentPage.value = page
|
|
||||||
hasNextPage.value = rows.length === PAGE_SIZE
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPage(page = 1) {
|
|
||||||
if (!Number.isInteger(cloudTypeId.value) || cloudTypeId.value <= 0) {
|
|
||||||
loadError.value = '云类型不存在'
|
|
||||||
loading.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
loadError.value = ''
|
|
||||||
selectedItem.value = null
|
|
||||||
try {
|
|
||||||
await encyclopediaStore.fetchCloudTypes()
|
|
||||||
if (!cloudType.value) {
|
|
||||||
loadError.value = '云类型不存在'
|
|
||||||
gallery.value = []
|
|
||||||
return
|
|
||||||
}
|
|
||||||
await loadGallery(page)
|
|
||||||
} catch (error) {
|
|
||||||
loadError.value = error instanceof Error ? error.message : '详情加载失败'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => loadPage())
|
|
||||||
watch(
|
|
||||||
() => route.params.id,
|
|
||||||
() => loadPage(),
|
|
||||||
)
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="oc-container oc-container--content py-8">
|
|
||||||
<RouterLink to="/encyclopedia">
|
|
||||||
<NButton
|
|
||||||
type="default"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
>← 返回百科总览</NButton
|
|
||||||
>
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<div v-if="loading" class="mt-6 space-y-6">
|
|
||||||
<NSkeleton class="h-80 w-full" />
|
|
||||||
<div class="grid gap-4 md:grid-cols-3">
|
|
||||||
<NSkeleton v-for="n in 6" :key="n" class="h-56 w-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NAlert
|
|
||||||
v-else-if="loadError"
|
|
||||||
class="mt-6"
|
|
||||||
type="error"
|
|
||||||
:show-icon="false"
|
|
||||||
:bordered="false"
|
|
||||||
title="详情加载失败"
|
|
||||||
>
|
|
||||||
{{ loadError }}
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<template v-else-if="cloudType">
|
|
||||||
<section class="oc-panel-card mt-6 overflow-hidden border border-slate-200 bg-white">
|
|
||||||
<div class="grid lg:grid-cols-[1.05fr_0.95fr]">
|
|
||||||
<div class="relative min-h-80 bg-sky-50">
|
|
||||||
<img
|
|
||||||
v-if="heroImage"
|
|
||||||
:src="heroImage"
|
|
||||||
:alt="cloudType.name"
|
|
||||||
class="absolute inset-0 h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="absolute inset-0 flex items-center justify-center pb-20 text-sm font-medium text-slate-500"
|
|
||||||
>
|
|
||||||
无图片
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="heroImage"
|
|
||||||
class="absolute inset-0 bg-gradient-to-t from-slate-950/75 via-slate-950/10 to-transparent"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
class="absolute bottom-7 left-7 right-7"
|
|
||||||
:class="heroImage ? 'text-white' : 'text-slate-900'"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="border px-2.5 py-1 text-xs"
|
|
||||||
:class="
|
|
||||||
heroImage
|
|
||||||
? 'border-white/35 bg-slate-950/25 backdrop-blur'
|
|
||||||
: 'border-slate-200 bg-white'
|
|
||||||
"
|
|
||||||
>稀有度 {{ cloudType.rarity_value }}</span
|
|
||||||
>
|
|
||||||
<h1 class="mt-4 text-4xl font-bold">{{ cloudType.name }}</h1>
|
|
||||||
<p class="mt-2" :class="heroImage ? 'text-white/80' : 'text-slate-600'">
|
|
||||||
{{ cloudType.genus || '未提供云属名称' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="p-8">
|
|
||||||
<p class="oc-section-label">Meteorology Note</p>
|
|
||||||
<p class="mt-5 text-base leading-8 text-slate-700">
|
|
||||||
{{ cloudType.description || '暂未提供识别说明。' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="mt-8">
|
|
||||||
<div class="mb-5 flex items-end justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-bold text-slate-900">公开观测</h2>
|
|
||||||
<p class="oc-field-help">
|
|
||||||
第 {{ currentPage }} 页,每页最多 {{ PAGE_SIZE }} 张。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<NButton
|
|
||||||
type="default"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
:disabled="currentPage === 1"
|
|
||||||
@click="loadPage(currentPage - 1)"
|
|
||||||
>上一页</NButton
|
|
||||||
>
|
|
||||||
<NButton
|
|
||||||
type="default"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
class="oc-panel-button oc-panel-button--sky"
|
|
||||||
:disabled="!hasNextPage"
|
|
||||||
@click="loadPage(currentPage + 1)"
|
|
||||||
>下一页</NButton
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="gallery.length"
|
|
||||||
class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-for="item in gallery"
|
|
||||||
:key="item.id"
|
|
||||||
type="button"
|
|
||||||
class="oc-interactive-card overflow-hidden border border-slate-200 bg-white text-left"
|
|
||||||
@click="selectedItem = item"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
:src="item.thumbnail_url || item.image_url"
|
|
||||||
:alt="cloudType.name"
|
|
||||||
class="h-48 w-full object-cover"
|
|
||||||
/>
|
|
||||||
<div class="p-4">
|
|
||||||
<p class="font-medium text-slate-900">@{{ item.username }}</p>
|
|
||||||
<p class="mt-1 text-xs text-slate-500">
|
|
||||||
{{ formatDateTime(item.captured_at || item.created_at) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="oc-empty-card border border-dashed border-slate-300 bg-white p-10"
|
|
||||||
>
|
|
||||||
<NEmpty description="这一页暂无公开云图" />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<ImageDetailModal
|
|
||||||
v-if="selectedItem && cloudType"
|
|
||||||
:open="true"
|
|
||||||
:image-url="selectedItem.image_url"
|
|
||||||
:thumbnail-url="selectedItem.thumbnail_url"
|
|
||||||
:image-alt="cloudType.name"
|
|
||||||
:title="cloudType.name"
|
|
||||||
:subtitle="
|
|
||||||
selectedItem.captured_at
|
|
||||||
? `拍摄于 ${formatDateTime(selectedItem.captured_at)}`
|
|
||||||
: `上传于 ${formatDateTime(selectedItem.created_at)}`
|
|
||||||
"
|
|
||||||
:badge-label="`稀有度 ${cloudType.rarity_value}`"
|
|
||||||
badge-class="border-slate-200 bg-slate-50 text-slate-600"
|
|
||||||
@close="selectedItem = null"
|
|
||||||
>
|
|
||||||
<p class="text-sm leading-7 text-slate-700">
|
|
||||||
{{ selectedItem.description || '上传者没有留下额外说明。' }}
|
|
||||||
</p>
|
|
||||||
<MiniLocationMap
|
|
||||||
:latitude="selectedItem.latitude"
|
|
||||||
:longitude="selectedItem.longitude"
|
|
||||||
/>
|
|
||||||
<CloudLikeButton
|
|
||||||
:cloud-id="selectedItem.id"
|
|
||||||
:count="selectedItem.received_like_count"
|
|
||||||
/>
|
|
||||||
</ImageDetailModal>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { onMounted } from 'vue'
|
|
||||||
import { NEmpty, NSkeleton } from 'naive-ui'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { imageUrl } from '@/lib/api'
|
|
||||||
import { useEncyclopediaStore } from '@/features/encyclopedia/stores/encyclopedia'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const encyclopediaStore = useEncyclopediaStore()
|
|
||||||
|
|
||||||
function typeImageUrl(iconId: string | null) {
|
|
||||||
return iconId ? imageUrl(iconId, 'preview') : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => encyclopediaStore.fetchCloudTypes())
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="oc-page-hero">
|
|
||||||
<div class="oc-container oc-container--content oc-page-hero__inner">
|
|
||||||
<p class="oc-page-eyebrow">Cloud Encyclopedia</p>
|
|
||||||
<h1 class="oc-page-title">云朵百科</h1>
|
|
||||||
<p class="oc-page-description">
|
|
||||||
浏览后端提供的云类型资料,了解云属名称、识别说明和社区公开观测记录。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="oc-container oc-container--content oc-page-content">
|
|
||||||
<div
|
|
||||||
v-if="encyclopediaStore.loadingCloudTypes"
|
|
||||||
class="grid gap-5 md:grid-cols-2 lg:grid-cols-3"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="n in 6"
|
|
||||||
:key="n"
|
|
||||||
class="oc-panel-card overflow-hidden border border-slate-200 bg-white"
|
|
||||||
>
|
|
||||||
<NSkeleton class="h-80 w-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else-if="encyclopediaStore.cloudTypes.length"
|
|
||||||
class="grid gap-5 md:grid-cols-2 lg:grid-cols-3"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-for="cloudType in encyclopediaStore.cloudTypes"
|
|
||||||
:key="cloudType.id"
|
|
||||||
type="button"
|
|
||||||
class="oc-interactive-card oc-panel-card relative isolate min-h-80 overflow-hidden border border-slate-200 bg-sky-50 text-left"
|
|
||||||
@click="router.push(`/encyclopedia/${cloudType.id}`)"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
v-if="cloudType.icon_id"
|
|
||||||
:src="typeImageUrl(cloudType.icon_id)"
|
|
||||||
:alt="`${cloudType.name}介绍图`"
|
|
||||||
class="absolute inset-0 -z-20 h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="cloudType.icon_id"
|
|
||||||
class="absolute inset-0 -z-10 bg-gradient-to-t from-slate-950/90 via-slate-950/55 to-slate-950/25"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="absolute inset-0 -z-10 flex items-center justify-center text-sm font-medium text-slate-500"
|
|
||||||
>
|
|
||||||
无图片
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex min-h-80 flex-col p-5">
|
|
||||||
<div class="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p
|
|
||||||
class="oc-section-label"
|
|
||||||
:class="cloudType.icon_id ? 'text-white/70' : 'text-slate-500'"
|
|
||||||
>
|
|
||||||
Cloud Genus
|
|
||||||
</p>
|
|
||||||
<h2
|
|
||||||
class="mt-2 text-xl font-bold"
|
|
||||||
:class="cloudType.icon_id ? 'text-white' : 'text-slate-900'"
|
|
||||||
>
|
|
||||||
{{ cloudType.name }}
|
|
||||||
</h2>
|
|
||||||
<p
|
|
||||||
class="mt-1 font-mono text-xs uppercase tracking-[0.12em]"
|
|
||||||
:class="cloudType.icon_id ? 'text-white/75' : 'text-slate-500'"
|
|
||||||
>
|
|
||||||
{{ cloudType.genus || '未提供云属名称' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
class="shrink-0 border px-2.5 py-1 text-xs font-medium"
|
|
||||||
:class="
|
|
||||||
cloudType.icon_id
|
|
||||||
? 'border-white/35 bg-slate-950/25 text-white backdrop-blur'
|
|
||||||
: 'border-slate-200 bg-white text-slate-600'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
稀有度 {{ cloudType.rarity_value }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="mt-auto line-clamp-3 pt-16 text-sm leading-6"
|
|
||||||
:class="cloudType.icon_id ? 'text-white/85' : 'text-slate-600'"
|
|
||||||
>
|
|
||||||
{{ cloudType.description || '暂未提供识别说明。' }}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class="mt-4 border-t pt-4 text-xs font-semibold"
|
|
||||||
:class="
|
|
||||||
cloudType.icon_id
|
|
||||||
? 'border-white/25 text-white'
|
|
||||||
: 'border-slate-200 text-sky-700'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
查看类型资料与公开云图 →
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="oc-empty-card border border-dashed border-slate-300 bg-white px-6 py-12"
|
|
||||||
>
|
|
||||||
<NEmpty description="暂时还没有云类型资料" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,658 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
||||||
import { RouterLink } from 'vue-router'
|
|
||||||
import { NAlert, NButton, NDropdown, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
|
|
||||||
import { Clock, Location, Search, Settings, User, X } from '@vicons/tabler'
|
|
||||||
import CloudEditModal, {
|
|
||||||
type CloudEditFormValue,
|
|
||||||
} from '@/features/clouds/components/CloudEditModal.vue'
|
|
||||||
import CloudLikeButton from '@/features/clouds/components/CloudLikeButton.vue'
|
|
||||||
import ImageDetailModal from '@/features/clouds/components/ImageDetailModal.vue'
|
|
||||||
import MiniLocationMap from '@/shared/map/components/MiniLocationMap.vue'
|
|
||||||
import { ApiError, apiRequest, toApiCloud } from '@/lib/api'
|
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth'
|
|
||||||
import { useCloudsStore } from '@/features/clouds/stores/clouds'
|
|
||||||
import { useProfileStore } from '@/features/profile/stores/profile'
|
|
||||||
|
|
||||||
import type { ApiCloud, BackendCloudInfo } from '@/types/api'
|
|
||||||
|
|
||||||
interface GalleryCloud {
|
|
||||||
id: string
|
|
||||||
user_id: string
|
|
||||||
cloud_type_id: number | null
|
|
||||||
image_url: string
|
|
||||||
thumbnail_url: string | null
|
|
||||||
description: string | null
|
|
||||||
latitude: number | null
|
|
||||||
longitude: number | null
|
|
||||||
captured_at: string | null
|
|
||||||
created_at: string
|
|
||||||
status: 'pending' | 'approved' | 'rejected'
|
|
||||||
is_hidden: boolean
|
|
||||||
cloudTypeName: string
|
|
||||||
cloudTypeRarity: number
|
|
||||||
username: string
|
|
||||||
receivedLikeCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const PAGE_SIZE = 50
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const cloudsStore = useCloudsStore()
|
|
||||||
const profileStore = useProfileStore()
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const loading = ref(true)
|
|
||||||
const loadError = ref('')
|
|
||||||
const galleryItems = ref<GalleryCloud[]>([])
|
|
||||||
const selectedTypeId = ref<number | 'all'>('all')
|
|
||||||
const searchQuery = ref('')
|
|
||||||
const selectedCloud = ref<GalleryCloud | null>(null)
|
|
||||||
const currentPage = ref(1)
|
|
||||||
const hasNextPage = ref(false)
|
|
||||||
const manageError = ref('')
|
|
||||||
const editModalOpen = ref(false)
|
|
||||||
const editSaving = ref(false)
|
|
||||||
const editInitialValue = ref<CloudEditFormValue | null>(null)
|
|
||||||
|
|
||||||
let searchTimer: number | null = null
|
|
||||||
|
|
||||||
const rarityChip = 'border-slate-200 bg-slate-50 text-slate-600'
|
|
||||||
|
|
||||||
function formatDateTime(iso: string | null) {
|
|
||||||
if (!iso) return '未知时间'
|
|
||||||
return new Date(iso).toLocaleString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'numeric',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUploadTime(cloud: GalleryCloud) {
|
|
||||||
return formatDateTime(cloud.created_at)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCapturedTime(cloud: GalleryCloud) {
|
|
||||||
return formatDateTime(cloud.captured_at)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCoordinate(value: number | null) {
|
|
||||||
return value === null ? '未记录' : value.toFixed(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedSearch = computed(() => searchQuery.value.trim())
|
|
||||||
const isUserSearch = computed(() => normalizedSearch.value.startsWith('@'))
|
|
||||||
|
|
||||||
function toGalleryCloud(row: ApiCloud) {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
user_id: row.user_id,
|
|
||||||
cloud_type_id: row.cloud_type_id,
|
|
||||||
image_url: row.image_url,
|
|
||||||
thumbnail_url: row.thumbnail_url,
|
|
||||||
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 || '未知',
|
|
||||||
cloudTypeRarity: row.cloud_type_rarity_value ?? 0,
|
|
||||||
username: row.username || '匿名',
|
|
||||||
receivedLikeCount: row.received_like_count,
|
|
||||||
} satisfies GalleryCloud
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadPage(page: number) {
|
|
||||||
loading.value = true
|
|
||||||
loadError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
|
|
||||||
if (normalizedSearch.value) {
|
|
||||||
params.set('filter', isUserSearch.value ? 'owner' : 'type')
|
|
||||||
params.set('value', normalizedSearch.value)
|
|
||||||
} else if (selectedTypeId.value !== 'all') {
|
|
||||||
const typeName = cloudsStore.cloudTypes.find(
|
|
||||||
type => type.id === selectedTypeId.value,
|
|
||||||
)?.name
|
|
||||||
if (typeName) {
|
|
||||||
params.set('filter', 'type')
|
|
||||||
params.set('value', typeName)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = await apiRequest<BackendCloudInfo[]>(`/cloud?${params}`, { auth: false })
|
|
||||||
galleryItems.value = rows.map(toApiCloud).map(toGalleryCloud)
|
|
||||||
hasNextPage.value = rows.length === PAGE_SIZE
|
|
||||||
currentPage.value = page
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ApiError && error.status === 404 && isUserSearch.value) {
|
|
||||||
galleryItems.value = []
|
|
||||||
hasNextPage.value = false
|
|
||||||
currentPage.value = page
|
|
||||||
return
|
|
||||||
}
|
|
||||||
loadError.value = error instanceof Error ? error.message : '画廊加载失败'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function goToPage(page: number) {
|
|
||||||
if (page < 1 || page === currentPage.value || (page > currentPage.value && !hasNextPage.value))
|
|
||||||
return
|
|
||||||
await loadPage(page)
|
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
|
||||||
}
|
|
||||||
|
|
||||||
function openDetail(cloud: GalleryCloud) {
|
|
||||||
selectedCloud.value = cloud
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeDetail() {
|
|
||||||
selectedCloud.value = null
|
|
||||||
editModalOpen.value = false
|
|
||||||
manageError.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedCloudIsMine = computed(() => {
|
|
||||||
return !!selectedCloud.value && selectedCloud.value.user_id === authStore.user?.id
|
|
||||||
})
|
|
||||||
|
|
||||||
const cloudActionOptions = computed(() => [
|
|
||||||
{ label: '编辑图片信息', key: 'edit' },
|
|
||||||
{ label: '删除', key: 'delete' },
|
|
||||||
{ label: '更改为私密', key: 'toggle-privacy' },
|
|
||||||
])
|
|
||||||
|
|
||||||
function formatDatetimeLocal(iso: string | null) {
|
|
||||||
if (!iso) return ''
|
|
||||||
const date = new Date(iso)
|
|
||||||
const pad = (value: number) => String(value).padStart(2, '0')
|
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function blurCoordinate(value: number): number {
|
|
||||||
return Math.round(value * 100) / 100
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditModal() {
|
|
||||||
if (!selectedCloud.value) return
|
|
||||||
|
|
||||||
manageError.value = ''
|
|
||||||
editInitialValue.value = {
|
|
||||||
cloudCategoryId: selectedCloud.value.cloud_type_id,
|
|
||||||
capturedAt: formatDatetimeLocal(selectedCloud.value.captured_at),
|
|
||||||
latitude: selectedCloud.value.latitude,
|
|
||||||
longitude: selectedCloud.value.longitude,
|
|
||||||
description: selectedCloud.value.description || '',
|
|
||||||
isPublic: !selectedCloud.value.is_hidden,
|
|
||||||
}
|
|
||||||
editModalOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeEditModal() {
|
|
||||||
editModalOpen.value = false
|
|
||||||
editSaving.value = false
|
|
||||||
manageError.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchGalleryCloud(updated: GalleryCloud) {
|
|
||||||
galleryItems.value = galleryItems.value.map(item => (item.id === updated.id ? updated : item))
|
|
||||||
if (selectedCloud.value?.id === updated.id) {
|
|
||||||
selectedCloud.value = updated
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitEditModal(value: CloudEditFormValue) {
|
|
||||||
if (!selectedCloud.value || !authStore.user?.id) return
|
|
||||||
|
|
||||||
if (!value.cloudCategoryId) {
|
|
||||||
manageError.value = '请选择云型类别。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const hasLat = typeof value.latitude === 'number' && !Number.isNaN(value.latitude)
|
|
||||||
const hasLng = typeof value.longitude === 'number' && !Number.isNaN(value.longitude)
|
|
||||||
if (!hasLat || !hasLng) {
|
|
||||||
manageError.value = '经纬度为必填项。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (value.description.trim().length > 128) {
|
|
||||||
manageError.value = '图片描述不能超过 128 个字符。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = selectedCloud.value
|
|
||||||
editSaving.value = true
|
|
||||||
manageError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updated = await profileStore.updateCloud(authStore.user.id, current.id, {
|
|
||||||
type_id: value.cloudCategoryId,
|
|
||||||
latitude: blurCoordinate(value.latitude as number),
|
|
||||||
longitude: blurCoordinate(value.longitude as number),
|
|
||||||
description: value.description.trim(),
|
|
||||||
...(value.capturedAt ? { captured_at: new Date(value.capturedAt).toISOString() } : {}),
|
|
||||||
is_hidden: !value.isPublic,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (updated.is_hidden || updated.status !== 'approved') {
|
|
||||||
removeGalleryCloud(updated.id)
|
|
||||||
} else {
|
|
||||||
patchGalleryCloud({
|
|
||||||
...current,
|
|
||||||
...updated,
|
|
||||||
user_id: current.user_id,
|
|
||||||
username: current.username,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
closeEditModal()
|
|
||||||
message.success('图片信息已更新')
|
|
||||||
} catch (error) {
|
|
||||||
manageError.value = error instanceof Error ? error.message : '图片信息更新失败'
|
|
||||||
message.error(manageError.value)
|
|
||||||
} finally {
|
|
||||||
editSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeGalleryCloud(cloudId: string) {
|
|
||||||
galleryItems.value = galleryItems.value.filter(item => item.id !== cloudId)
|
|
||||||
if (selectedCloud.value?.id === cloudId) {
|
|
||||||
selectedCloud.value = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteSelectedCloud() {
|
|
||||||
if (!selectedCloud.value || !authStore.user?.id) return
|
|
||||||
const cloudId = selectedCloud.value.id
|
|
||||||
const confirmed = window.confirm('确定删除这张图片吗?删除后无法在页面中恢复。')
|
|
||||||
if (!confirmed) return
|
|
||||||
|
|
||||||
manageError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
const deletedCount = await profileStore.deleteClouds(authStore.user.id, [cloudId])
|
|
||||||
removeGalleryCloud(cloudId)
|
|
||||||
message.success(`已成功删除 ${deletedCount} 张照片`)
|
|
||||||
} catch (error) {
|
|
||||||
manageError.value = error instanceof Error ? error.message : '图片删除失败'
|
|
||||||
message.error(manageError.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hideSelectedCloud() {
|
|
||||||
if (!selectedCloud.value || !authStore.user?.id) return
|
|
||||||
const cloudId = selectedCloud.value.id
|
|
||||||
|
|
||||||
manageError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
await profileStore.updateCloudVisibility(authStore.user.id, cloudId, true)
|
|
||||||
removeGalleryCloud(cloudId)
|
|
||||||
message.success('已更改为私密图片')
|
|
||||||
} catch (error) {
|
|
||||||
manageError.value = error instanceof Error ? error.message : '私密状态更新失败'
|
|
||||||
message.error(manageError.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCloudAction(key: string | number) {
|
|
||||||
if (key === 'edit') openEditModal()
|
|
||||||
if (key === 'delete') deleteSelectedCloud()
|
|
||||||
if (key === 'toggle-privacy') hideSelectedCloud()
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSearch() {
|
|
||||||
searchQuery.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const filterTabs = computed(() => [
|
|
||||||
{ id: 'all' as const, label: '全部' },
|
|
||||||
...cloudsStore.cloudTypes.map(type => ({ id: type.id, label: type.name })),
|
|
||||||
])
|
|
||||||
|
|
||||||
onMounted(async () => {
|
|
||||||
await cloudsStore.fetchCloudTypes()
|
|
||||||
await loadPage(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(selectedTypeId, async () => {
|
|
||||||
selectedCloud.value = null
|
|
||||||
await loadPage(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(searchQuery, () => {
|
|
||||||
if (searchTimer !== null) {
|
|
||||||
window.clearTimeout(searchTimer)
|
|
||||||
}
|
|
||||||
searchTimer = window.setTimeout(async () => {
|
|
||||||
selectedCloud.value = null
|
|
||||||
await loadPage(1)
|
|
||||||
searchTimer = null
|
|
||||||
}, 250)
|
|
||||||
})
|
|
||||||
|
|
||||||
onUnmounted(() => {
|
|
||||||
if (searchTimer !== null) {
|
|
||||||
window.clearTimeout(searchTimer)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<section class="oc-page-hero">
|
|
||||||
<div class="oc-container oc-container--content oc-page-hero__inner">
|
|
||||||
<p class="oc-page-eyebrow">Community Gallery</p>
|
|
||||||
<h1 class="oc-page-title">云图画廊</h1>
|
|
||||||
<p class="oc-page-description">
|
|
||||||
按上传时间倒序浏览社区云图。瀑布流会尽量保留图片原始比例,悬停即可快速查看基本信息,点开可看大图和详细记录。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="oc-container oc-container--content oc-page-content">
|
|
||||||
<section>
|
|
||||||
<div class="oc-panel-card mb-4 border border-slate-200 bg-white p-3">
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<NIcon size="20" class="shrink-0 text-slate-400">
|
|
||||||
<Search />
|
|
||||||
</NIcon>
|
|
||||||
<input
|
|
||||||
v-model="searchQuery"
|
|
||||||
type="text"
|
|
||||||
class="min-w-0 flex-1 bg-transparent text-sm text-slate-800 outline-none placeholder:text-slate-400"
|
|
||||||
placeholder="输入完整云类型名称,或 @用户名进行精确搜索"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
v-if="searchQuery"
|
|
||||||
type="button"
|
|
||||||
class="oc-icon-button oc-icon-button--small"
|
|
||||||
title="清空搜索"
|
|
||||||
aria-label="清空搜索"
|
|
||||||
@click="clearSearch"
|
|
||||||
>
|
|
||||||
<NIcon size="18">
|
|
||||||
<X />
|
|
||||||
</NIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p v-if="normalizedSearch" class="mt-2 text-xs text-slate-500">
|
|
||||||
<template v-if="isUserSearch">
|
|
||||||
正在按上传者搜索:{{ normalizedSearch.slice(1) || '请输入用户名' }}
|
|
||||||
</template>
|
|
||||||
<template v-else> 正在精确匹配云型名称:{{ normalizedSearch }} </template>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3 overflow-x-auto pb-2">
|
|
||||||
<NButton
|
|
||||||
v-for="tab in filterTabs"
|
|
||||||
:key="tab.id"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
class="shrink-0"
|
|
||||||
:class="
|
|
||||||
selectedTypeId === tab.id
|
|
||||||
? 'oc-panel-button oc-panel-button--sky'
|
|
||||||
: 'oc-panel-button oc-panel-button--neutral'
|
|
||||||
"
|
|
||||||
type="default"
|
|
||||||
@click="selectedTypeId = tab.id"
|
|
||||||
>
|
|
||||||
{{ tab.label }}
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<NAlert
|
|
||||||
v-if="loadError"
|
|
||||||
class="mt-6"
|
|
||||||
type="error"
|
|
||||||
:show-icon="false"
|
|
||||||
:bordered="false"
|
|
||||||
title="画廊加载失败"
|
|
||||||
>
|
|
||||||
{{ loadError }}
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<NAlert
|
|
||||||
v-if="manageError"
|
|
||||||
class="mt-6"
|
|
||||||
type="error"
|
|
||||||
:show-icon="false"
|
|
||||||
:bordered="false"
|
|
||||||
title="操作失败"
|
|
||||||
>
|
|
||||||
{{ manageError }}
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<section
|
|
||||||
v-if="loading"
|
|
||||||
class="mt-6 columns-2 gap-3 md:columns-3 xl:columns-4 [column-gap:0.75rem]"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="n in 8"
|
|
||||||
:key="n"
|
|
||||||
class="oc-panel-card mb-3 break-inside-avoid border border-slate-200 bg-white p-3"
|
|
||||||
>
|
|
||||||
<NSkeleton class="h-44 w-full" />
|
|
||||||
<NSkeleton class="mt-4 h-4 w-3/5" />
|
|
||||||
<NSkeleton class="mt-3 h-3 w-2/5" />
|
|
||||||
<NSkeleton class="mt-2 h-3 w-4/5" />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
|
||||||
v-else-if="galleryItems.length"
|
|
||||||
class="mt-6 columns-2 gap-3 md:columns-3 xl:columns-4 [column-gap:0.75rem]"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-for="cloud in galleryItems"
|
|
||||||
:key="cloud.id"
|
|
||||||
type="button"
|
|
||||||
class="oc-interactive-card group relative mb-3 block w-full break-inside-avoid overflow-hidden border border-slate-200 bg-slate-200 text-left transition-transform duration-300 hover:-translate-y-1"
|
|
||||||
@click="openDetail(cloud)"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
:src="cloud.thumbnail_url || cloud.image_url"
|
|
||||||
:alt="cloud.cloudTypeName"
|
|
||||||
class="block h-auto w-full object-cover transition duration-500 group-hover:scale-[1.04]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-slate-950/88 via-slate-950/45 to-transparent opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
|
|
||||||
></div>
|
|
||||||
<div
|
|
||||||
class="absolute inset-x-0 bottom-0 p-4 text-white opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<p class="truncate text-sm font-semibold">
|
|
||||||
{{ cloud.cloudTypeName }}
|
|
||||||
</p>
|
|
||||||
<NTag
|
|
||||||
size="small"
|
|
||||||
:bordered="false"
|
|
||||||
class="shrink-0 bg-white/12 text-white backdrop-blur"
|
|
||||||
>
|
|
||||||
稀有度 {{ cloud.cloudTypeRarity }}
|
|
||||||
</NTag>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
class="mt-2 flex items-center gap-1.5 truncate text-xs text-white/82"
|
|
||||||
>
|
|
||||||
<NIcon size="14">
|
|
||||||
<User />
|
|
||||||
</NIcon>
|
|
||||||
<span class="truncate">{{ cloud.username }}</span>
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class="mt-1 flex items-center gap-1.5 truncate text-xs text-white/82"
|
|
||||||
>
|
|
||||||
<NIcon size="14">
|
|
||||||
<Clock />
|
|
||||||
</NIcon>
|
|
||||||
<span class="truncate">{{ formatUploadTime(cloud) }}</span>
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class="mt-1 flex items-center gap-1.5 truncate text-xs text-white/82"
|
|
||||||
>
|
|
||||||
<NIcon size="14">
|
|
||||||
<Location />
|
|
||||||
</NIcon>
|
|
||||||
<span class="truncate"
|
|
||||||
>{{ formatCoordinate(cloud.latitude) }},
|
|
||||||
{{ formatCoordinate(cloud.longitude) }}</span
|
|
||||||
>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section
|
|
||||||
v-else
|
|
||||||
class="oc-empty-card mt-6 border border-dashed border-slate-300 bg-white px-6 py-12"
|
|
||||||
>
|
|
||||||
<NEmpty description="还没有符合条件的云图">
|
|
||||||
<template #extra>
|
|
||||||
<p class="text-sm text-slate-500">
|
|
||||||
换个云型筛选试试,或者等社区上传更多作品。
|
|
||||||
</p>
|
|
||||||
</template>
|
|
||||||
</NEmpty>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="currentPage > 1 || hasNextPage"
|
|
||||||
class="mt-8 flex items-center justify-center gap-2"
|
|
||||||
>
|
|
||||||
<NButton
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
:disabled="currentPage <= 1"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
@click="goToPage(currentPage - 1)"
|
|
||||||
>
|
|
||||||
上一页
|
|
||||||
</NButton>
|
|
||||||
|
|
||||||
<span class="border border-slate-200 bg-white px-4 py-2 text-sm text-slate-600"
|
|
||||||
>第 {{ currentPage }} 页</span
|
|
||||||
>
|
|
||||||
|
|
||||||
<NButton
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
:disabled="!hasNextPage"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
@click="goToPage(currentPage + 1)"
|
|
||||||
>
|
|
||||||
下一页
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ImageDetailModal
|
|
||||||
v-if="selectedCloud"
|
|
||||||
:open="!!selectedCloud"
|
|
||||||
:image-url="selectedCloud.image_url"
|
|
||||||
:thumbnail-url="selectedCloud.thumbnail_url"
|
|
||||||
:image-alt="selectedCloud.cloudTypeName"
|
|
||||||
:title="selectedCloud.cloudTypeName"
|
|
||||||
:subtitle="`上传者:${selectedCloud.username}`"
|
|
||||||
:badge-label="`稀有度 ${selectedCloud.cloudTypeRarity}`"
|
|
||||||
:badge-class="rarityChip"
|
|
||||||
@close="closeDetail"
|
|
||||||
>
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">上传时间</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">
|
|
||||||
{{ formatUploadTime(selectedCloud) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">拍摄时间</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">
|
|
||||||
{{ formatCapturedTime(selectedCloud) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">位置</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">已记录经纬度</p>
|
|
||||||
</div>
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">模糊化经纬度</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">
|
|
||||||
纬度 {{ formatCoordinate(selectedCloud.latitude) }} / 经度
|
|
||||||
{{ formatCoordinate(selectedCloud.longitude) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5 border border-slate-200 bg-white p-5">
|
|
||||||
<p class="oc-section-label">图片说明</p>
|
|
||||||
<p class="mt-3 text-sm leading-7 text-slate-700">
|
|
||||||
{{ selectedCloud.description || '上传者没有留下额外说明。' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<RouterLink
|
|
||||||
:to="{ name: 'user-profile', params: { id: selectedCloud.username } }"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral mt-5 inline-flex"
|
|
||||||
>
|
|
||||||
查看 @{{ selectedCloud.username }} 的公开主页
|
|
||||||
</RouterLink>
|
|
||||||
<MiniLocationMap
|
|
||||||
:latitude="selectedCloud.latitude"
|
|
||||||
:longitude="selectedCloud.longitude"
|
|
||||||
/>
|
|
||||||
<CloudLikeButton
|
|
||||||
:cloud-id="selectedCloud.id"
|
|
||||||
:count="selectedCloud.receivedLikeCount"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<template v-if="selectedCloudIsMine" #actions>
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<NDropdown
|
|
||||||
trigger="click"
|
|
||||||
placement="top-start"
|
|
||||||
:options="cloudActionOptions"
|
|
||||||
@select="handleCloudAction"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-icon-button"
|
|
||||||
title="图片管理"
|
|
||||||
aria-label="图片管理"
|
|
||||||
>
|
|
||||||
<NIcon size="20">
|
|
||||||
<Settings />
|
|
||||||
</NIcon>
|
|
||||||
</button>
|
|
||||||
</NDropdown>
|
|
||||||
<!-- <span class="text-xs text-slate-500">这是你上传的公开图片</span> -->
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ImageDetailModal>
|
|
||||||
|
|
||||||
<CloudEditModal
|
|
||||||
:open="editModalOpen && !!selectedCloud"
|
|
||||||
:saving="editSaving"
|
|
||||||
:error="manageError"
|
|
||||||
:cloud-types="cloudsStore.cloudTypes"
|
|
||||||
:initial-value="editInitialValue"
|
|
||||||
@close="closeEditModal"
|
|
||||||
@save="submitEditModal"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,468 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, ref, watch } from 'vue'
|
|
||||||
import { NButton, NSelect } from 'naive-ui'
|
|
||||||
|
|
||||||
export interface ContributionHeatmapCell {
|
|
||||||
date: string
|
|
||||||
count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MonthStat {
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
totalCount: number
|
|
||||||
activeDays: number
|
|
||||||
}
|
|
||||||
|
|
||||||
const today = new Date()
|
|
||||||
const currentYear = today.getFullYear()
|
|
||||||
const currentMonth = today.getMonth() + 1
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
cells: ContributionHeatmapCell[]
|
|
||||||
totalCount: number
|
|
||||||
activeDays: number
|
|
||||||
activeDate?: string | null
|
|
||||||
title?: string
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
activeDate: null,
|
|
||||||
title: '上传活跃度',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
selectDate: [date: string]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const viewMode = ref<'year' | 'month'>('year')
|
|
||||||
const selectedYear = ref(currentYear)
|
|
||||||
const selectedMonth = ref(currentMonth)
|
|
||||||
const hoveredMonthKey = ref<string | null>(null)
|
|
||||||
|
|
||||||
function parseLocalDate(dateString: string) {
|
|
||||||
const [year, month, day] = dateString.split('-').map(Number)
|
|
||||||
return new Date(year, (month || 1) - 1, day || 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDayKey(date: Date) {
|
|
||||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
|
||||||
const day = `${date.getDate()}`.padStart(2, '0')
|
|
||||||
return `${date.getFullYear()}-${month}-${day}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function toMonthKey(dateString: string) {
|
|
||||||
const date = parseLocalDate(dateString)
|
|
||||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function startOfDay(date: Date) {
|
|
||||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
|
||||||
}
|
|
||||||
|
|
||||||
function startOfWeek(date: Date) {
|
|
||||||
const result = startOfDay(date)
|
|
||||||
const weekday = result.getDay()
|
|
||||||
const offset = weekday === 0 ? 6 : weekday - 1
|
|
||||||
result.setDate(result.getDate() - offset)
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
const dailyCounts = computed(() => {
|
|
||||||
const counts = new Map<string, number>()
|
|
||||||
for (const cell of props.cells) {
|
|
||||||
counts.set(cell.date, cell.count)
|
|
||||||
}
|
|
||||||
return counts
|
|
||||||
})
|
|
||||||
|
|
||||||
const availableYears = computed(() => {
|
|
||||||
const years = new Set<number>([currentYear])
|
|
||||||
for (const cell of props.cells) {
|
|
||||||
years.add(parseLocalDate(cell.date).getFullYear())
|
|
||||||
}
|
|
||||||
return Array.from(years).sort((a, b) => b - a)
|
|
||||||
})
|
|
||||||
|
|
||||||
const yearOptions = computed(() => {
|
|
||||||
return availableYears.value.map(year => ({
|
|
||||||
label: `${year}年`,
|
|
||||||
value: year,
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
const monthOptions = Array.from({ length: 12 }, (_, index) => ({
|
|
||||||
label: `${index + 1}月`,
|
|
||||||
value: index + 1,
|
|
||||||
}))
|
|
||||||
|
|
||||||
watch(
|
|
||||||
availableYears,
|
|
||||||
years => {
|
|
||||||
if (!years.includes(selectedYear.value)) {
|
|
||||||
selectedYear.value = years[0] || currentYear
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
const visibleCells = computed(() => {
|
|
||||||
if (viewMode.value === 'year') {
|
|
||||||
const start = new Date(selectedYear.value, 0, 1)
|
|
||||||
const end = new Date(selectedYear.value, 11, 31)
|
|
||||||
const alignedStart = startOfWeek(start)
|
|
||||||
const alignedEnd = startOfWeek(end)
|
|
||||||
alignedEnd.setDate(alignedEnd.getDate() + 6)
|
|
||||||
|
|
||||||
const cells: Array<ContributionHeatmapCell & { inRange: boolean; isToday: boolean }> = []
|
|
||||||
const cursor = new Date(alignedStart)
|
|
||||||
|
|
||||||
while (cursor <= alignedEnd) {
|
|
||||||
const key = formatDayKey(cursor)
|
|
||||||
const inRange = cursor >= start && cursor <= end
|
|
||||||
cells.push({
|
|
||||||
date: key,
|
|
||||||
count: dailyCounts.value.get(key) || 0,
|
|
||||||
inRange,
|
|
||||||
isToday: key === formatDayKey(today),
|
|
||||||
})
|
|
||||||
cursor.setDate(cursor.getDate() + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cells
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = new Date(selectedYear.value, selectedMonth.value - 1, 1)
|
|
||||||
const end = new Date(selectedYear.value, selectedMonth.value, 0)
|
|
||||||
const alignedStart = startOfWeek(start)
|
|
||||||
const alignedEnd = startOfWeek(end)
|
|
||||||
alignedEnd.setDate(alignedEnd.getDate() + 6)
|
|
||||||
|
|
||||||
const cells: Array<ContributionHeatmapCell & { inRange: boolean; isToday: boolean }> = []
|
|
||||||
const cursor = new Date(alignedStart)
|
|
||||||
|
|
||||||
while (cursor <= alignedEnd) {
|
|
||||||
const key = formatDayKey(cursor)
|
|
||||||
const inRange = cursor >= start && cursor <= end
|
|
||||||
cells.push({
|
|
||||||
date: key,
|
|
||||||
count: dailyCounts.value.get(key) || 0,
|
|
||||||
inRange,
|
|
||||||
isToday: key === formatDayKey(today),
|
|
||||||
})
|
|
||||||
cursor.setDate(cursor.getDate() + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return cells
|
|
||||||
})
|
|
||||||
|
|
||||||
const contributionQuartiles = computed(() => {
|
|
||||||
const counts = visibleCells.value
|
|
||||||
.filter(cell => cell.inRange && cell.count > 0)
|
|
||||||
.map(cell => cell.count)
|
|
||||||
.sort((a, b) => a - b)
|
|
||||||
|
|
||||||
if (!counts.length) return [0, 0, 0] as const
|
|
||||||
|
|
||||||
const percentile = (value: number) => {
|
|
||||||
const index = Math.ceil(counts.length * value) - 1
|
|
||||||
return counts[Math.max(0, index)] || 0
|
|
||||||
}
|
|
||||||
|
|
||||||
return [percentile(0.25), percentile(0.5), percentile(0.75)] as const
|
|
||||||
})
|
|
||||||
|
|
||||||
function getLevel(count: number) {
|
|
||||||
if (count <= 0) return 0
|
|
||||||
|
|
||||||
const [firstQuartile, secondQuartile, thirdQuartile] = contributionQuartiles.value
|
|
||||||
|
|
||||||
if (count <= firstQuartile) return 1
|
|
||||||
if (count <= secondQuartile) return 2
|
|
||||||
if (count <= thirdQuartile) return 3
|
|
||||||
return 4
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCellClass(cell: { count: number; inRange: boolean }) {
|
|
||||||
if (!cell.inRange) return 'bg-transparent border-transparent'
|
|
||||||
|
|
||||||
const level = getLevel(cell.count)
|
|
||||||
return `oc-heatmap-cell--level-${level}`
|
|
||||||
}
|
|
||||||
|
|
||||||
const weeks = computed(() => {
|
|
||||||
const buckets: Array<typeof visibleCells.value> = []
|
|
||||||
for (let index = 0; index < visibleCells.value.length; index += 7) {
|
|
||||||
buckets.push(visibleCells.value.slice(index, index + 7))
|
|
||||||
}
|
|
||||||
return buckets
|
|
||||||
})
|
|
||||||
|
|
||||||
const monthStats = computed(() => {
|
|
||||||
const stats = new Map<string, MonthStat>()
|
|
||||||
|
|
||||||
for (const cell of visibleCells.value) {
|
|
||||||
if (!cell.inRange) continue
|
|
||||||
const monthKey = toMonthKey(cell.date)
|
|
||||||
const date = parseLocalDate(cell.date)
|
|
||||||
const label = `${date.getMonth() + 1}月`
|
|
||||||
const existing = stats.get(monthKey)
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
existing.totalCount += cell.count
|
|
||||||
if (cell.count > 0) existing.activeDays += 1
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.set(monthKey, {
|
|
||||||
key: monthKey,
|
|
||||||
label,
|
|
||||||
totalCount: cell.count,
|
|
||||||
activeDays: cell.count > 0 ? 1 : 0,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats
|
|
||||||
})
|
|
||||||
|
|
||||||
const monthLabels = computed(() => {
|
|
||||||
if (viewMode.value !== 'year') return []
|
|
||||||
|
|
||||||
let lastMonth = -1
|
|
||||||
return weeks.value.map(week => {
|
|
||||||
const firstInRange = week.find(cell => cell.inRange)
|
|
||||||
if (!firstInRange) return null
|
|
||||||
|
|
||||||
const date = parseLocalDate(firstInRange.date)
|
|
||||||
const month = date.getMonth()
|
|
||||||
const labelMonth = month + 1
|
|
||||||
const monthKey = toMonthKey(firstInRange.date)
|
|
||||||
|
|
||||||
if (month === lastMonth) return null
|
|
||||||
|
|
||||||
lastMonth = month
|
|
||||||
return {
|
|
||||||
key: monthKey,
|
|
||||||
label: labelMonth === 1 ? `${selectedYear.value}/1月` : `${labelMonth}月`,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const monthPreview = computed(() => {
|
|
||||||
if (!hoveredMonthKey.value) return null
|
|
||||||
return monthStats.value.get(hoveredMonthKey.value) || null
|
|
||||||
})
|
|
||||||
|
|
||||||
const panelSummary = computed(() => {
|
|
||||||
if (viewMode.value === 'year') {
|
|
||||||
return `${selectedYear.value} 年完整视图,包含尚未来到的日期。`
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${selectedYear.value} 年 ${selectedMonth.value} 月完整视图。`
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatTooltip(cell: ContributionHeatmapCell & { inRange: boolean; isToday: boolean }) {
|
|
||||||
const date = parseLocalDate(cell.date)
|
|
||||||
const label = date.toLocaleDateString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!cell.inRange) {
|
|
||||||
return `${label}:不在当前视图范围内`
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cell.count === 0) {
|
|
||||||
return `${label}${cell.isToday ? '(今天)' : ''}:当天没有上传`
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${label}${cell.isToday ? '(今天)' : ''}:上传 ${cell.count} 张`
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCellClick(cell: ContributionHeatmapCell & { inRange: boolean }) {
|
|
||||||
if (!cell.inRange) return
|
|
||||||
emit('selectDate', cell.date)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatMonthTooltip(monthKey: string) {
|
|
||||||
const stats = monthStats.value.get(monthKey)
|
|
||||||
if (!stats) return ''
|
|
||||||
return `${stats.label}:共上传 ${stats.totalCount} 张,活跃于 ${stats.activeDays} 天`
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="oc-panel-card border border-slate-200 bg-white p-6">
|
|
||||||
<div class="flex flex-wrap items-start justify-between gap-6">
|
|
||||||
<div class="min-w-0 flex-1">
|
|
||||||
<h3 class="text-2xl font-bold text-slate-900">{{ title }}</h3>
|
|
||||||
<p class="mt-3 text-sm leading-7 text-slate-500">
|
|
||||||
共上传 {{ totalCount }} 张,活跃于
|
|
||||||
{{ activeDays }} 天。颜色越深,代表当天上传的照片越多。
|
|
||||||
</p>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">{{ panelSummary }}</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap items-center justify-end gap-3">
|
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
|
||||||
<NButton
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
:class="
|
|
||||||
viewMode === 'year'
|
|
||||||
? 'oc-panel-button oc-panel-button--teal'
|
|
||||||
: 'oc-panel-button oc-panel-button--neutral'
|
|
||||||
"
|
|
||||||
@click="viewMode = 'year'"
|
|
||||||
>
|
|
||||||
年度视图
|
|
||||||
</NButton>
|
|
||||||
<NButton
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
:class="
|
|
||||||
viewMode === 'month'
|
|
||||||
? 'oc-panel-button oc-panel-button--teal'
|
|
||||||
: 'oc-panel-button oc-panel-button--neutral'
|
|
||||||
"
|
|
||||||
@click="viewMode = 'month'"
|
|
||||||
>
|
|
||||||
月度视图
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
<NSelect v-model:value="selectedYear" :options="yearOptions" class="w-28" />
|
|
||||||
<NSelect
|
|
||||||
v-if="viewMode === 'month'"
|
|
||||||
v-model:value="selectedMonth"
|
|
||||||
:options="monthOptions"
|
|
||||||
class="w-24"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-4 flex min-h-6 items-center justify-between gap-4 text-xs text-slate-500">
|
|
||||||
<span>点击某一天可筛选当天上传记录。</span>
|
|
||||||
<span v-if="monthPreview" class="text-right">
|
|
||||||
{{ monthPreview.label }}:{{ monthPreview.totalCount }} 张 /
|
|
||||||
{{ monthPreview.activeDays }} 天
|
|
||||||
</span>
|
|
||||||
<span v-else class="text-right">
|
|
||||||
<template v-if="viewMode === 'year'">悬停月份标签可预览当月上传摘要。</template>
|
|
||||||
<template v-else>当前显示整个月的上传情况。</template>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8 overflow-x-auto">
|
|
||||||
<div :class="viewMode === 'year' ? 'min-w-[980px]' : 'min-w-[360px]'">
|
|
||||||
<div
|
|
||||||
v-if="viewMode === 'year'"
|
|
||||||
class="mb-2 grid grid-flow-col auto-cols-[14px] gap-1 pl-8 text-[11px] text-slate-400"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="(month, index) in monthLabels"
|
|
||||||
:key="`${month?.key || 'empty'}-${index}`"
|
|
||||||
class="w-[14px] whitespace-nowrap"
|
|
||||||
:title="month ? formatMonthTooltip(month.key) : ''"
|
|
||||||
@mouseenter="hoveredMonthKey = month?.key || null"
|
|
||||||
@mouseleave="hoveredMonthKey = null"
|
|
||||||
>
|
|
||||||
{{ month?.label || '' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<div class="grid grid-rows-7 gap-1 pt-[2px] text-[11px] text-slate-400">
|
|
||||||
<span>一</span>
|
|
||||||
<span></span>
|
|
||||||
<span>三</span>
|
|
||||||
<span></span>
|
|
||||||
<span>五</span>
|
|
||||||
<span></span>
|
|
||||||
<span>日</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid grid-flow-col auto-cols-[14px] gap-1">
|
|
||||||
<div
|
|
||||||
v-for="(week, weekIndex) in weeks"
|
|
||||||
:key="weekIndex"
|
|
||||||
class="grid grid-rows-7 gap-1"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
v-for="cell in week"
|
|
||||||
:key="cell.date"
|
|
||||||
type="button"
|
|
||||||
class="oc-heatmap-cell relative h-3.5 w-3.5 border hover:scale-125"
|
|
||||||
:class="[
|
|
||||||
getCellClass(cell),
|
|
||||||
props.activeDate === cell.date
|
|
||||||
? 'ring-2 ring-slate-900 ring-offset-1'
|
|
||||||
: '',
|
|
||||||
cell.isToday && cell.inRange
|
|
||||||
? 'outline outline-1 outline-slate-900 outline-offset-1'
|
|
||||||
: '',
|
|
||||||
]"
|
|
||||||
:title="formatTooltip(cell)"
|
|
||||||
@click="handleCellClick(cell)"
|
|
||||||
></button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5 flex items-center justify-end gap-2 text-xs text-slate-500">
|
|
||||||
<span>少</span>
|
|
||||||
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-0"></span>
|
|
||||||
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-1"></span>
|
|
||||||
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-2"></span>
|
|
||||||
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-3"></span>
|
|
||||||
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-4"></span>
|
|
||||||
<span>多</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.oc-heatmap-cell,
|
|
||||||
.oc-heatmap-legend-cell {
|
|
||||||
transition:
|
|
||||||
background-color var(--oc-duration-fast) var(--oc-ease-standard),
|
|
||||||
border-color var(--oc-duration-fast) var(--oc-ease-standard),
|
|
||||||
transform var(--oc-duration-fast) var(--oc-ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-heatmap-legend-cell {
|
|
||||||
width: 0.75rem;
|
|
||||||
height: 0.75rem;
|
|
||||||
border-width: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-heatmap-cell--level-0 {
|
|
||||||
background: var(--oc-heatmap-empty);
|
|
||||||
border-color: var(--oc-heatmap-empty-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-heatmap-cell--level-1 {
|
|
||||||
background: var(--oc-heatmap-level-1);
|
|
||||||
border-color: var(--oc-heatmap-level-1-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-heatmap-cell--level-2 {
|
|
||||||
background: var(--oc-heatmap-level-2);
|
|
||||||
border-color: var(--oc-heatmap-level-2-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-heatmap-cell--level-3 {
|
|
||||||
background: var(--oc-heatmap-level-3);
|
|
||||||
border-color: var(--oc-heatmap-level-3-border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-heatmap-cell--level-4 {
|
|
||||||
background: var(--oc-heatmap-level-4);
|
|
||||||
border-color: var(--oc-heatmap-level-4-border);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
import { ref } from 'vue'
|
|
||||||
import { defineStore } from 'pinia'
|
|
||||||
import { ApiError, apiRequest, toApiCloud } from '@/lib/api'
|
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth'
|
|
||||||
import type { ActionResponse, ApiCloud, BackendCloudInfo, BackendUserProfile } from '@/types/api'
|
|
||||||
import type { Profile } from '@/types/database'
|
|
||||||
|
|
||||||
export interface ProfileCloudItem {
|
|
||||||
id: string
|
|
||||||
cloud_type_id: number
|
|
||||||
image_url: string
|
|
||||||
thumbnail_url: string
|
|
||||||
latitude: number
|
|
||||||
longitude: number
|
|
||||||
description: string | null
|
|
||||||
captured_at: string | null
|
|
||||||
created_at: string
|
|
||||||
status: 'pending' | 'approved' | 'rejected'
|
|
||||||
is_hidden: boolean
|
|
||||||
cloudTypeName: string
|
|
||||||
cloudTypeRarity: number
|
|
||||||
receivedLikeCount: number
|
|
||||||
}
|
|
||||||
|
|
||||||
function toProfileCloud(row: ApiCloud): ProfileCloudItem {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
cloud_type_id: row.cloud_type_id as number,
|
|
||||||
image_url: row.image_url,
|
|
||||||
thumbnail_url: row.thumbnail_url as string,
|
|
||||||
latitude: row.latitude as number,
|
|
||||||
longitude: row.longitude as number,
|
|
||||||
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 || '未知',
|
|
||||||
cloudTypeRarity: row.cloud_type_rarity_value ?? 0,
|
|
||||||
receivedLikeCount: row.received_like_count,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useProfileStore = defineStore('profile-page', () => {
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const profilesByKey = ref<Record<string, Profile>>({})
|
|
||||||
const cloudsByKey = ref<Record<string, ProfileCloudItem[]>>({})
|
|
||||||
const loadingKeys = ref<Record<string, boolean>>({})
|
|
||||||
const errorByKey = ref<Record<string, string>>({})
|
|
||||||
|
|
||||||
const makeKey = (identifier: string, isOwnProfile: boolean) =>
|
|
||||||
`${identifier}:${isOwnProfile ? 'own' : 'public'}`
|
|
||||||
|
|
||||||
function getProfile(identifier: string | null) {
|
|
||||||
return identifier ? (profilesByKey.value[identifier] ?? null) : null
|
|
||||||
}
|
|
||||||
|
|
||||||
function getClouds(identifier: string | null, isOwnProfile: boolean) {
|
|
||||||
return identifier ? (cloudsByKey.value[makeKey(identifier, isOwnProfile)] ?? []) : []
|
|
||||||
}
|
|
||||||
|
|
||||||
function getError(identifier: string | null, isOwnProfile: boolean) {
|
|
||||||
return identifier ? (errorByKey.value[makeKey(identifier, isOwnProfile)] ?? '') : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLoaded(identifier: string | null, isOwnProfile: boolean) {
|
|
||||||
return !!identifier && makeKey(identifier, isOwnProfile) in cloudsByKey.value
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchAllPublicClouds(username: string) {
|
|
||||||
const rows: BackendCloudInfo[] = []
|
|
||||||
for (let page = 1; ; page++) {
|
|
||||||
const params = new URLSearchParams({
|
|
||||||
page: String(page),
|
|
||||||
page_size: '100',
|
|
||||||
filter: 'owner',
|
|
||||||
value: `@${username}`,
|
|
||||||
})
|
|
||||||
let current: BackendCloudInfo[]
|
|
||||||
try {
|
|
||||||
current = await apiRequest<BackendCloudInfo[]>(`/cloud?${params}`, { auth: false })
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ApiError && error.status === 404) return rows
|
|
||||||
throw error
|
|
||||||
}
|
|
||||||
rows.push(...current)
|
|
||||||
if (current.length < 100) return rows
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function fetchProfilePage(identifier: string, isOwnProfile: boolean, force = false) {
|
|
||||||
const key = makeKey(identifier, isOwnProfile)
|
|
||||||
if (!force && key in cloudsByKey.value && profilesByKey.value[identifier]) return
|
|
||||||
|
|
||||||
loadingKeys.value[key] = true
|
|
||||||
errorByKey.value[key] = ''
|
|
||||||
try {
|
|
||||||
const backendRows = isOwnProfile
|
|
||||||
? await apiRequest<BackendCloudInfo[]>('/profile/me/clouds')
|
|
||||||
: await fetchAllPublicClouds(identifier)
|
|
||||||
const cloudRows = backendRows.map(toApiCloud).map(toProfileCloud)
|
|
||||||
|
|
||||||
if (isOwnProfile && authStore.profile) {
|
|
||||||
profilesByKey.value[identifier] = authStore.profile
|
|
||||||
} else if (identifier === authStore.profile?.username && authStore.profile) {
|
|
||||||
profilesByKey.value[identifier] = authStore.profile
|
|
||||||
} else {
|
|
||||||
const owner = backendRows[0]?.owner
|
|
||||||
let publicProfile: BackendUserProfile | null = null
|
|
||||||
if (owner?.id && authStore.isLoggedIn) {
|
|
||||||
try {
|
|
||||||
publicProfile = await apiRequest<BackendUserProfile>(`/profile/${owner.id}`)
|
|
||||||
} catch {
|
|
||||||
// Public clouds can still be displayed if the profile lookup fails.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
profilesByKey.value[identifier] = {
|
|
||||||
id: owner?.id || identifier,
|
|
||||||
username: owner?.name || identifier,
|
|
||||||
image: publicProfile?.image ?? null,
|
|
||||||
role: 'user',
|
|
||||||
is_disabled: false,
|
|
||||||
created_at: '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cloudsByKey.value[key] = cloudRows
|
|
||||||
} catch (error) {
|
|
||||||
errorByKey.value[key] = error instanceof Error ? error.message : '个人主页加载失败'
|
|
||||||
cloudsByKey.value[key] = []
|
|
||||||
} finally {
|
|
||||||
loadingKeys.value[key] = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isLoading(identifier: string | null, isOwnProfile: boolean) {
|
|
||||||
return !!identifier && !!loadingKeys.value[makeKey(identifier, isOwnProfile)]
|
|
||||||
}
|
|
||||||
|
|
||||||
function patchCachedCloud(
|
|
||||||
identifier: string,
|
|
||||||
cloudId: string,
|
|
||||||
patch: Partial<ProfileCloudItem>,
|
|
||||||
) {
|
|
||||||
for (const isOwnProfile of [true, false]) {
|
|
||||||
const key = makeKey(identifier, isOwnProfile)
|
|
||||||
const current = cloudsByKey.value[key]
|
|
||||||
if (!current) continue
|
|
||||||
cloudsByKey.value[key] = current
|
|
||||||
.map(item => (item.id === cloudId ? { ...item, ...patch } : item))
|
|
||||||
.filter(item => isOwnProfile || (item.status === 'approved' && !item.is_hidden))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeCachedClouds(identifier: string, cloudIds: string[]) {
|
|
||||||
const ids = new Set(cloudIds)
|
|
||||||
for (const isOwnProfile of [true, false]) {
|
|
||||||
const key = makeKey(identifier, isOwnProfile)
|
|
||||||
const current = cloudsByKey.value[key]
|
|
||||||
if (current) cloudsByKey.value[key] = current.filter(item => !ids.has(item.id))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function invalidateUser(identifier: string) {
|
|
||||||
delete cloudsByKey.value[makeKey(identifier, true)]
|
|
||||||
delete cloudsByKey.value[makeKey(identifier, false)]
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateCloud(
|
|
||||||
identifier: string,
|
|
||||||
cloudId: string,
|
|
||||||
patch: {
|
|
||||||
type_id: number
|
|
||||||
latitude: number
|
|
||||||
longitude: number
|
|
||||||
description?: string
|
|
||||||
captured_at?: string
|
|
||||||
is_hidden: boolean
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
await apiRequest<ActionResponse>(`/cloud/${cloudId}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: patch,
|
|
||||||
})
|
|
||||||
const updated = toProfileCloud(
|
|
||||||
toApiCloud(await apiRequest<BackendCloudInfo>(`/cloud/${cloudId}`)),
|
|
||||||
)
|
|
||||||
patchCachedCloud(identifier, cloudId, updated)
|
|
||||||
return updated
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateCloudVisibility(identifier: string, cloudId: string, isHidden: boolean) {
|
|
||||||
await apiRequest<ActionResponse>(`/cloud/${cloudId}`, {
|
|
||||||
method: 'PATCH',
|
|
||||||
body: { is_hidden: isHidden },
|
|
||||||
})
|
|
||||||
patchCachedCloud(identifier, cloudId, { is_hidden: isHidden })
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteClouds(identifier: string, cloudIds: string[]) {
|
|
||||||
if (!cloudIds.length) return 0
|
|
||||||
|
|
||||||
if (cloudIds.length === 1) {
|
|
||||||
await apiRequest<ActionResponse>(`/cloud/${cloudIds[0]}`, { method: 'DELETE' })
|
|
||||||
} else {
|
|
||||||
for (let offset = 0; offset < cloudIds.length; offset += 100) {
|
|
||||||
await apiRequest<ActionResponse>('/cloud', {
|
|
||||||
method: 'DELETE',
|
|
||||||
body: {
|
|
||||||
cloud_ids: cloudIds
|
|
||||||
.slice(offset, offset + 100)
|
|
||||||
.map(cloudId => ({ cloud_id: cloudId })),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
removeCachedClouds(identifier, cloudIds)
|
|
||||||
return cloudIds.length
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
getProfile,
|
|
||||||
getClouds,
|
|
||||||
getError,
|
|
||||||
isLoaded,
|
|
||||||
isLoading,
|
|
||||||
fetchProfilePage,
|
|
||||||
invalidateUser,
|
|
||||||
updateCloud,
|
|
||||||
updateCloudVisibility,
|
|
||||||
deleteClouds,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { onMounted, ref } from 'vue'
|
|
||||||
import { RouterLink } from 'vue-router'
|
|
||||||
import { NAlert, NButton, NCard, NForm, NInput, useMessage } from 'naive-ui'
|
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth'
|
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const usernameDraft = ref('')
|
|
||||||
const currentPassword = ref('')
|
|
||||||
const newPassword = ref('')
|
|
||||||
const confirmNewPassword = ref('')
|
|
||||||
const error = ref('')
|
|
||||||
const success = ref('')
|
|
||||||
const usernameSaving = ref(false)
|
|
||||||
const passwordSaving = ref(false)
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
usernameDraft.value = authStore.profile?.username || ''
|
|
||||||
})
|
|
||||||
|
|
||||||
async function saveUsername() {
|
|
||||||
const nextUsername = usernameDraft.value.trim()
|
|
||||||
|
|
||||||
error.value = ''
|
|
||||||
success.value = ''
|
|
||||||
|
|
||||||
if (!nextUsername) {
|
|
||||||
error.value = '昵称不能为空。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (nextUsername.length > 16) {
|
|
||||||
error.value = '昵称不能超过 16 个字符。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (nextUsername === authStore.profile?.username) {
|
|
||||||
success.value = '昵称没有变化。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
usernameSaving.value = true
|
|
||||||
try {
|
|
||||||
await authStore.updateUsername(nextUsername)
|
|
||||||
success.value = '昵称已更新。'
|
|
||||||
message.success('昵称已更新')
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e instanceof Error ? e.message : '昵称更新失败'
|
|
||||||
} finally {
|
|
||||||
usernameSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function savePassword() {
|
|
||||||
error.value = ''
|
|
||||||
success.value = ''
|
|
||||||
|
|
||||||
if (newPassword.value.length < 8) {
|
|
||||||
error.value = '新密码至少需要 8 位。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (newPassword.value !== confirmNewPassword.value) {
|
|
||||||
error.value = '两次输入的新密码不一致。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!currentPassword.value) {
|
|
||||||
error.value = '请输入当前密码。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
passwordSaving.value = true
|
|
||||||
try {
|
|
||||||
await authStore.updatePassword(currentPassword.value, newPassword.value)
|
|
||||||
currentPassword.value = ''
|
|
||||||
newPassword.value = ''
|
|
||||||
confirmNewPassword.value = ''
|
|
||||||
success.value = '密码已更新。其他设备上的会话已退出。'
|
|
||||||
message.success('密码已更新')
|
|
||||||
} catch (e) {
|
|
||||||
error.value = e instanceof Error ? e.message : '密码更新失败'
|
|
||||||
} finally {
|
|
||||||
passwordSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)] py-10"
|
|
||||||
>
|
|
||||||
<div class="oc-container oc-container--content">
|
|
||||||
<RouterLink to="/profile">
|
|
||||||
<NButton
|
|
||||||
type="default"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
>← 返回个人主页</NButton
|
|
||||||
>
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<div class="mt-6">
|
|
||||||
<p class="oc-page-eyebrow">Account Settings</p>
|
|
||||||
<h1 class="oc-page-title">个人资料设置</h1>
|
|
||||||
<p class="oc-page-description">
|
|
||||||
修改公开昵称和登录密码。昵称会显示在画廊、百科和地图中,且不能与其他用户重复。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-8 grid gap-6">
|
|
||||||
<NAlert v-if="error" type="error" :show-icon="false" :bordered="false">
|
|
||||||
{{ error }}
|
|
||||||
</NAlert>
|
|
||||||
<NAlert v-if="success" type="success" :show-icon="false" :bordered="false">
|
|
||||||
{{ success }}
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<NCard class="oc-panel-card border border-slate-200 bg-white" :bordered="false">
|
|
||||||
<div class="grid gap-5 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-bold text-slate-900">公开昵称</h2>
|
|
||||||
<p class="mt-2 text-sm leading-6 text-slate-500">
|
|
||||||
用于公开展示你的上传记录。保存前会检查是否与其他用户重复。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto]">
|
|
||||||
<NInput
|
|
||||||
v-model:value="usernameDraft"
|
|
||||||
placeholder="输入新的昵称"
|
|
||||||
maxlength="16"
|
|
||||||
show-count
|
|
||||||
/>
|
|
||||||
<NButton
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--teal"
|
|
||||||
:loading="usernameSaving"
|
|
||||||
@click="saveUsername"
|
|
||||||
>
|
|
||||||
保存昵称
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</NCard>
|
|
||||||
|
|
||||||
<NCard class="oc-panel-card border border-slate-200 bg-white" :bordered="false">
|
|
||||||
<div class="grid gap-5 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-bold text-slate-900">登录密码</h2>
|
|
||||||
<p class="mt-2 text-sm leading-6 text-slate-500">
|
|
||||||
修改后下次登录需要使用新密码。建议使用至少 8 位且不易猜测的密码。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<NForm class="grid gap-3" @submit.prevent="savePassword">
|
|
||||||
<input
|
|
||||||
:value="authStore.user?.email || ''"
|
|
||||||
type="text"
|
|
||||||
name="username"
|
|
||||||
autocomplete="username"
|
|
||||||
class="sr-only"
|
|
||||||
tabindex="-1"
|
|
||||||
aria-label="账户邮箱"
|
|
||||||
readonly
|
|
||||||
/>
|
|
||||||
<NInput
|
|
||||||
v-model:value="currentPassword"
|
|
||||||
type="password"
|
|
||||||
show-password-on="click"
|
|
||||||
:input-props="{
|
|
||||||
autocomplete: 'current-password',
|
|
||||||
name: 'current-password',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
placeholder="当前密码"
|
|
||||||
/>
|
|
||||||
<NInput
|
|
||||||
v-model:value="newPassword"
|
|
||||||
type="password"
|
|
||||||
show-password-on="click"
|
|
||||||
:input-props="{
|
|
||||||
autocomplete: 'new-password',
|
|
||||||
name: 'new-password',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
placeholder="新密码"
|
|
||||||
/>
|
|
||||||
<NInput
|
|
||||||
v-model:value="confirmNewPassword"
|
|
||||||
type="password"
|
|
||||||
show-password-on="click"
|
|
||||||
:input-props="{
|
|
||||||
autocomplete: 'new-password',
|
|
||||||
name: 'confirm-new-password',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
placeholder="确认新密码"
|
|
||||||
/>
|
|
||||||
<div class="flex justify-end">
|
|
||||||
<NButton
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
attr-type="submit"
|
|
||||||
class="oc-panel-button oc-panel-button--teal"
|
|
||||||
:loading="passwordSaving"
|
|
||||||
>
|
|
||||||
保存密码
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
</NForm>
|
|
||||||
</div>
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,937 +0,0 @@
|
|||||||
<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, useMessage } from 'naive-ui'
|
|
||||||
import { Settings } from '@vicons/tabler'
|
|
||||||
import CloudEditModal, {
|
|
||||||
type CloudEditFormValue,
|
|
||||||
} from '@/features/clouds/components/CloudEditModal.vue'
|
|
||||||
import CloudLikeButton from '@/features/clouds/components/CloudLikeButton.vue'
|
|
||||||
import ImageDetailModal from '@/features/clouds/components/ImageDetailModal.vue'
|
|
||||||
import MiniLocationMap from '@/shared/map/components/MiniLocationMap.vue'
|
|
||||||
import ContributionHeatmap from '@/features/profile/components/ContributionHeatmap.vue'
|
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth'
|
|
||||||
import { useCloudsStore } from '@/features/clouds/stores/clouds'
|
|
||||||
import { useProfileStore, type ProfileCloudItem } from '@/features/profile/stores/profile'
|
|
||||||
import type { Profile } from '@/types/database'
|
|
||||||
|
|
||||||
interface TimelineGroup {
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
items: ProfileCloudItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const cloudsStore = useCloudsStore()
|
|
||||||
const profileStore = useProfileStore()
|
|
||||||
const message = useMessage()
|
|
||||||
|
|
||||||
const selectedCloud = ref<ProfileCloudItem | null>(null)
|
|
||||||
const selectedUploadDate = ref<string | null>(null)
|
|
||||||
const timelineSectionRef = ref<HTMLElement | null>(null)
|
|
||||||
const selectionMode = ref(false)
|
|
||||||
const selectedCloudIds = ref<Set<string>>(new Set())
|
|
||||||
const editModalOpen = ref(false)
|
|
||||||
const editSaving = ref(false)
|
|
||||||
const manageError = ref('')
|
|
||||||
const editInitialValue = ref<CloudEditFormValue | null>(null)
|
|
||||||
|
|
||||||
const rarityChip = 'border-slate-200 bg-slate-50 text-slate-600'
|
|
||||||
|
|
||||||
const statusMeta = {
|
|
||||||
approved: { label: '已通过', chip: 'bg-emerald-100 text-emerald-700 border-emerald-200' },
|
|
||||||
pending: { label: '待审核', chip: 'bg-amber-100 text-amber-700 border-amber-200' },
|
|
||||||
rejected: { label: '未通过', chip: 'bg-rose-100 text-rose-700 border-rose-200' },
|
|
||||||
} satisfies Record<ProfileCloudItem['status'], { label: string; chip: string }>
|
|
||||||
|
|
||||||
const viewedIdentifier = computed(() => {
|
|
||||||
const routeUsername = typeof route.params.id === 'string' ? route.params.id : null
|
|
||||||
return routeUsername || authStore.user?.id || null
|
|
||||||
})
|
|
||||||
|
|
||||||
const isOwnProfile = computed(() => typeof route.params.id !== 'string')
|
|
||||||
|
|
||||||
const loading = computed(() => profileStore.isLoading(viewedIdentifier.value, isOwnProfile.value))
|
|
||||||
const errorMsg = computed(() => {
|
|
||||||
if (!viewedIdentifier.value) return '未找到要查看的用户。'
|
|
||||||
return profileStore.getError(viewedIdentifier.value, isOwnProfile.value)
|
|
||||||
})
|
|
||||||
const profileData = computed<Profile | null>(() =>
|
|
||||||
isOwnProfile.value ? authStore.profile : profileStore.getProfile(viewedIdentifier.value),
|
|
||||||
)
|
|
||||||
const profileImageFailed = ref(false)
|
|
||||||
watch(
|
|
||||||
() => profileData.value?.image,
|
|
||||||
() => {
|
|
||||||
profileImageFailed.value = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
const clouds = computed<ProfileCloudItem[]>(() =>
|
|
||||||
profileStore.getClouds(viewedIdentifier.value, isOwnProfile.value),
|
|
||||||
)
|
|
||||||
const receivedLikes = computed(() =>
|
|
||||||
isOwnProfile.value ? authStore.user?.received_like_count || 0 : 0,
|
|
||||||
)
|
|
||||||
|
|
||||||
const pageTitle = computed(() => {
|
|
||||||
return isOwnProfile.value
|
|
||||||
? '我的天空日志'
|
|
||||||
: `${profileData.value?.username || '这位用户'}的天空日志`
|
|
||||||
})
|
|
||||||
|
|
||||||
const totalShots = computed(() => clouds.value.length)
|
|
||||||
|
|
||||||
const approvedShots = computed(() => {
|
|
||||||
return clouds.value.filter(item => item.status === 'approved').length
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedCloudCount = computed(() => selectedCloudIds.value.size)
|
|
||||||
|
|
||||||
const cloudActionOptions = computed(() => {
|
|
||||||
if (!selectedCloud.value) return []
|
|
||||||
|
|
||||||
return [
|
|
||||||
{ label: '编辑', key: 'edit' },
|
|
||||||
{ label: '删除', key: 'delete' },
|
|
||||||
{
|
|
||||||
label: selectedCloud.value.is_hidden ? '更改为公开' : '更改为私密',
|
|
||||||
key: 'toggle-privacy',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
const shootingDays = computed(() => {
|
|
||||||
return new Set(clouds.value.map(item => toDayKey(item.captured_at || item.created_at))).size
|
|
||||||
})
|
|
||||||
|
|
||||||
const uploadDayCounts = computed(() => {
|
|
||||||
const counts = new Map<string, number>()
|
|
||||||
|
|
||||||
for (const item of clouds.value) {
|
|
||||||
const key = toDayKey(item.created_at)
|
|
||||||
counts.set(key, (counts.get(key) || 0) + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
return counts
|
|
||||||
})
|
|
||||||
|
|
||||||
const mostCommonCloudType = computed(() => {
|
|
||||||
const counts = new Map<string, number>()
|
|
||||||
for (const item of clouds.value) {
|
|
||||||
counts.set(item.cloudTypeName, (counts.get(item.cloudTypeName) || 0) + 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
let winner = '还没有记录'
|
|
||||||
let maxCount = 0
|
|
||||||
for (const [name, count] of counts.entries()) {
|
|
||||||
if (count > maxCount) {
|
|
||||||
winner = name
|
|
||||||
maxCount = count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: winner,
|
|
||||||
count: maxCount,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const filteredClouds = computed(() => {
|
|
||||||
if (!selectedUploadDate.value) return clouds.value
|
|
||||||
|
|
||||||
return clouds.value.filter(item => toDayKey(item.created_at) === selectedUploadDate.value)
|
|
||||||
})
|
|
||||||
|
|
||||||
const timelineGroups = computed<TimelineGroup[]>(() => {
|
|
||||||
const groups = new Map<string, TimelineGroup>()
|
|
||||||
|
|
||||||
for (const item of filteredClouds.value) {
|
|
||||||
const date = new Date(item.captured_at || item.created_at)
|
|
||||||
const key = `${date.getFullYear()}-${date.getMonth() + 1}`
|
|
||||||
const existing = groups.get(key)
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
existing.items.push(item)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
groups.set(key, {
|
|
||||||
key,
|
|
||||||
label: `${date.getFullYear()}年${date.getMonth() + 1}月`,
|
|
||||||
items: [item],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(groups.values())
|
|
||||||
})
|
|
||||||
|
|
||||||
const selectedUploadCount = computed(() => {
|
|
||||||
if (!selectedUploadDate.value) return 0
|
|
||||||
return uploadDayCounts.value.get(selectedUploadDate.value) || 0
|
|
||||||
})
|
|
||||||
|
|
||||||
const contributionCells = computed(() => {
|
|
||||||
return Array.from(uploadDayCounts.value.entries())
|
|
||||||
.map(([date, count]) => ({ date, count }))
|
|
||||||
.sort((a, b) => a.date.localeCompare(b.date))
|
|
||||||
})
|
|
||||||
|
|
||||||
const activeUploadDays = computed(() => {
|
|
||||||
return contributionCells.value.filter(cell => cell.count > 0).length
|
|
||||||
})
|
|
||||||
|
|
||||||
function toDayKey(iso: string) {
|
|
||||||
const date = new Date(iso)
|
|
||||||
return toLocalDayKey(date)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toLocalDayKey(date: Date) {
|
|
||||||
const month = `${date.getMonth() + 1}`.padStart(2, '0')
|
|
||||||
const day = `${date.getDate()}`.padStart(2, '0')
|
|
||||||
return `${date.getFullYear()}-${month}-${day}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(iso: string | null) {
|
|
||||||
if (!iso) return '未知时间'
|
|
||||||
return new Date(iso).toLocaleDateString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDayKey(dayKey: string | null) {
|
|
||||||
if (!dayKey) return ''
|
|
||||||
const [year, month, day] = dayKey.split('-').map(Number)
|
|
||||||
return new Date(year, (month || 1) - 1, day || 1).toLocaleDateString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'long',
|
|
||||||
day: 'numeric',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(iso: string | null) {
|
|
||||||
if (!iso) return '未知时间'
|
|
||||||
return new Date(iso).toLocaleString('zh-CN', {
|
|
||||||
year: 'numeric',
|
|
||||||
month: 'numeric',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCoordinate(value: number | null) {
|
|
||||||
return value === null ? '未记录' : value.toFixed(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCloudTime(item: ProfileCloudItem) {
|
|
||||||
return item.captured_at
|
|
||||||
? `拍摄于 ${formatDateTime(item.captured_at)}`
|
|
||||||
: `上传于 ${formatDateTime(item.created_at)}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProfileInitial(profile: Profile | null) {
|
|
||||||
return profile?.username?.slice(0, 1).toUpperCase() || '云'
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSelectedUploadDate(date: string) {
|
|
||||||
selectedUploadDate.value = selectedUploadDate.value === date ? null : date
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearSelectedUploadDate() {
|
|
||||||
selectedUploadDate.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSelectedCloudIds(nextIds: Iterable<string>) {
|
|
||||||
selectedCloudIds.value = new Set(nextIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleCloudSelection(cloudId: string, checked: boolean) {
|
|
||||||
const nextIds = new Set(selectedCloudIds.value)
|
|
||||||
if (checked) {
|
|
||||||
nextIds.add(cloudId)
|
|
||||||
} else {
|
|
||||||
nextIds.delete(cloudId)
|
|
||||||
}
|
|
||||||
setSelectedCloudIds(nextIds)
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearCloudSelection() {
|
|
||||||
setSelectedCloudIds([])
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSelectionMode() {
|
|
||||||
selectionMode.value = !selectionMode.value
|
|
||||||
if (!selectionMode.value) {
|
|
||||||
clearCloudSelection()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDatetimeLocal(iso: string | null) {
|
|
||||||
if (!iso) return ''
|
|
||||||
const date = new Date(iso)
|
|
||||||
const pad = (value: number) => String(value).padStart(2, '0')
|
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function openEditModal(cloud: ProfileCloudItem) {
|
|
||||||
manageError.value = ''
|
|
||||||
editInitialValue.value = {
|
|
||||||
cloudCategoryId: cloud.cloud_type_id,
|
|
||||||
capturedAt: formatDatetimeLocal(cloud.captured_at),
|
|
||||||
latitude: cloud.latitude,
|
|
||||||
longitude: cloud.longitude,
|
|
||||||
description: cloud.description || '',
|
|
||||||
isPublic: !cloud.is_hidden,
|
|
||||||
}
|
|
||||||
editModalOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeEditModal() {
|
|
||||||
editModalOpen.value = false
|
|
||||||
editSaving.value = false
|
|
||||||
manageError.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function blurCoordinate(value: number): number {
|
|
||||||
return Math.round(value * 100) / 100
|
|
||||||
}
|
|
||||||
|
|
||||||
async function submitEditModal(value: CloudEditFormValue) {
|
|
||||||
if (!selectedCloud.value || !viewedIdentifier.value) return
|
|
||||||
|
|
||||||
if (!value.cloudCategoryId) {
|
|
||||||
manageError.value = '请选择云型类别。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const hasLat = typeof value.latitude === 'number' && !Number.isNaN(value.latitude)
|
|
||||||
const hasLng = typeof value.longitude === 'number' && !Number.isNaN(value.longitude)
|
|
||||||
if (!hasLat || !hasLng) {
|
|
||||||
manageError.value = '经纬度为必填项。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (value.description.trim().length > 128) {
|
|
||||||
manageError.value = '图片描述不能超过 128 个字符。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
editSaving.value = true
|
|
||||||
manageError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updated = await profileStore.updateCloud(
|
|
||||||
viewedIdentifier.value,
|
|
||||||
selectedCloud.value.id,
|
|
||||||
{
|
|
||||||
type_id: value.cloudCategoryId,
|
|
||||||
latitude: blurCoordinate(value.latitude as number),
|
|
||||||
longitude: blurCoordinate(value.longitude as number),
|
|
||||||
description: value.description.trim(),
|
|
||||||
...(value.capturedAt
|
|
||||||
? { captured_at: new Date(value.capturedAt).toISOString() }
|
|
||||||
: {}),
|
|
||||||
is_hidden: !value.isPublic,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
selectedCloud.value = updated
|
|
||||||
closeEditModal()
|
|
||||||
} catch (error) {
|
|
||||||
manageError.value = error instanceof Error ? error.message : '图片信息更新失败'
|
|
||||||
} finally {
|
|
||||||
editSaving.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function toggleSelectedCloudPrivacy() {
|
|
||||||
if (!selectedCloud.value || !viewedIdentifier.value) return
|
|
||||||
|
|
||||||
manageError.value = ''
|
|
||||||
const nextHidden = !selectedCloud.value.is_hidden
|
|
||||||
|
|
||||||
try {
|
|
||||||
await profileStore.updateCloudVisibility(
|
|
||||||
viewedIdentifier.value,
|
|
||||||
selectedCloud.value.id,
|
|
||||||
nextHidden,
|
|
||||||
)
|
|
||||||
selectedCloud.value = { ...selectedCloud.value, is_hidden: nextHidden }
|
|
||||||
} catch (error) {
|
|
||||||
manageError.value = error instanceof Error ? error.message : '私密状态更新失败'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteCloudByIds(cloudIds: string[]) {
|
|
||||||
if (!viewedIdentifier.value || !cloudIds.length) return
|
|
||||||
const confirmed = window.confirm(
|
|
||||||
`确定删除 ${cloudIds.length} 张图片吗?删除后无法在页面中恢复。`,
|
|
||||||
)
|
|
||||||
if (!confirmed) return
|
|
||||||
|
|
||||||
manageError.value = ''
|
|
||||||
|
|
||||||
try {
|
|
||||||
const deletedCount = await profileStore.deleteClouds(viewedIdentifier.value, cloudIds)
|
|
||||||
if (selectedCloud.value && cloudIds.includes(selectedCloud.value.id)) {
|
|
||||||
selectedCloud.value = null
|
|
||||||
closeEditModal()
|
|
||||||
}
|
|
||||||
const nextSelected = new Set(selectedCloudIds.value)
|
|
||||||
for (const cloudId of cloudIds) nextSelected.delete(cloudId)
|
|
||||||
setSelectedCloudIds(nextSelected)
|
|
||||||
message.success(`已成功删除 ${deletedCount} 张照片`)
|
|
||||||
} catch (error) {
|
|
||||||
manageError.value = error instanceof Error ? error.message : '图片删除失败'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleCloudAction(key: string | number) {
|
|
||||||
if (!selectedCloud.value) return
|
|
||||||
if (key === 'edit') openEditModal(selectedCloud.value)
|
|
||||||
if (key === 'delete') deleteCloudByIds([selectedCloud.value.id])
|
|
||||||
if (key === 'toggle-privacy') toggleSelectedCloudPrivacy()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadProfilePage(force = false) {
|
|
||||||
selectedCloud.value = null
|
|
||||||
selectedUploadDate.value = null
|
|
||||||
clearCloudSelection()
|
|
||||||
selectionMode.value = false
|
|
||||||
|
|
||||||
const targetIdentifier = viewedIdentifier.value
|
|
||||||
|
|
||||||
if (!targetIdentifier) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await profileStore.fetchProfilePage(targetIdentifier, isOwnProfile.value, force)
|
|
||||||
|
|
||||||
if (isOwnProfile.value) {
|
|
||||||
await cloudsStore.fetchCloudTypes()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => [route.params.id, authStore.user?.id],
|
|
||||||
() => {
|
|
||||||
loadProfilePage()
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(selectedUploadDate, async newValue => {
|
|
||||||
if (!newValue) return
|
|
||||||
await nextTick()
|
|
||||||
timelineSectionRef.value?.scrollIntoView({
|
|
||||||
behavior: 'smooth',
|
|
||||||
block: 'start',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="relative">
|
|
||||||
<div class="oc-page-hero">
|
|
||||||
<div class="oc-container oc-container--content oc-page-hero__inner">
|
|
||||||
<div v-if="loading" class="oc-page-hero__split">
|
|
||||||
<NCard class="oc-panel-card h-full border border-slate-200" :bordered="false">
|
|
||||||
<NSkeleton class="h-6 w-1/3" />
|
|
||||||
<NSkeleton class="mt-4 h-10 w-2/3" />
|
|
||||||
<NSkeleton class="mt-5 h-4 w-full" :repeat="2" />
|
|
||||||
</NCard>
|
|
||||||
<div
|
|
||||||
v-if="isOwnProfile"
|
|
||||||
class="grid grid-cols-2 gap-3 min-[900px]:h-full min-[900px]:grid-rows-2"
|
|
||||||
>
|
|
||||||
<NCard
|
|
||||||
v-for="n in 4"
|
|
||||||
:key="n"
|
|
||||||
class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur"
|
|
||||||
:bordered="false"
|
|
||||||
>
|
|
||||||
<NSkeleton class="h-3 w-2/3" />
|
|
||||||
<NSkeleton class="mt-3 h-7 w-1/2" />
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NCard
|
|
||||||
v-else
|
|
||||||
class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur"
|
|
||||||
:bordered="false"
|
|
||||||
>
|
|
||||||
<NSkeleton class="h-5 w-1/3" />
|
|
||||||
<NSkeleton class="mt-4 h-8 w-1/2" />
|
|
||||||
<NSkeleton class="mt-5 h-3 w-full" />
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="oc-page-hero__split oc-page-hero__split--stretch">
|
|
||||||
<NCard
|
|
||||||
class="oc-panel-card h-full border border-slate-200 bg-white/90 backdrop-blur"
|
|
||||||
:bordered="false"
|
|
||||||
>
|
|
||||||
<div class="flex gap-5">
|
|
||||||
<div
|
|
||||||
class="profile-avatar flex h-20 w-20 shrink-0 items-center justify-center border border-slate-300 bg-[linear-gradient(135deg,#eef9f3_0%,#d9efe3_100%)] text-3xl font-bold text-slate-900"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
v-if="profileData?.image && !profileImageFailed"
|
|
||||||
:src="profileData.image"
|
|
||||||
:alt="profileData.username"
|
|
||||||
class="h-full w-full object-cover"
|
|
||||||
@error="profileImageFailed = true"
|
|
||||||
/>
|
|
||||||
<template v-else>
|
|
||||||
{{ getProfileInitial(profileData) }}
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="oc-page-eyebrow">Sky Journal</p>
|
|
||||||
<h1 class="oc-page-title">
|
|
||||||
{{ pageTitle }}
|
|
||||||
</h1>
|
|
||||||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
|
||||||
<p class="text-lg font-medium text-slate-700">
|
|
||||||
@{{ profileData?.username || '未知用户' }}
|
|
||||||
</p>
|
|
||||||
<RouterLink v-if="isOwnProfile" to="/profile/settings">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-icon-button oc-icon-button--small"
|
|
||||||
title="个人资料设置"
|
|
||||||
aria-label="个人资料设置"
|
|
||||||
>
|
|
||||||
<NIcon size="17">
|
|
||||||
<Settings />
|
|
||||||
</NIcon>
|
|
||||||
</button>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="mt-5 flex flex-wrap items-center gap-3 text-sm text-slate-500"
|
|
||||||
>
|
|
||||||
<span v-if="isOwnProfile"
|
|
||||||
>注册于
|
|
||||||
{{ formatDate(profileData?.created_at || null) }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</NCard>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="isOwnProfile"
|
|
||||||
class="grid grid-cols-2 gap-3 min-[900px]:h-full min-[900px]:grid-rows-2"
|
|
||||||
>
|
|
||||||
<NCard
|
|
||||||
class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur"
|
|
||||||
:bordered="false"
|
|
||||||
>
|
|
||||||
<p class="oc-stat-label">总拍摄数</p>
|
|
||||||
<p class="oc-stat-value">{{ totalShots }}</p>
|
|
||||||
</NCard>
|
|
||||||
<NCard
|
|
||||||
class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur"
|
|
||||||
:bordered="false"
|
|
||||||
>
|
|
||||||
<p class="oc-stat-label">最常拍云型</p>
|
|
||||||
<p class="mt-2 truncate text-xl font-bold text-slate-900">
|
|
||||||
{{ mostCommonCloudType.name }}
|
|
||||||
</p>
|
|
||||||
</NCard>
|
|
||||||
<NCard
|
|
||||||
class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur"
|
|
||||||
:bordered="false"
|
|
||||||
>
|
|
||||||
<p class="oc-stat-label">拍摄天数</p>
|
|
||||||
<p class="oc-stat-value">{{ shootingDays }}</p>
|
|
||||||
</NCard>
|
|
||||||
<NCard
|
|
||||||
class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur"
|
|
||||||
:bordered="false"
|
|
||||||
>
|
|
||||||
<p class="oc-stat-label">被点赞数</p>
|
|
||||||
<p class="oc-stat-value">{{ receivedLikes }}</p>
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NCard
|
|
||||||
v-if="!isOwnProfile"
|
|
||||||
class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur"
|
|
||||||
:bordered="false"
|
|
||||||
>
|
|
||||||
<p class="text-sm text-slate-500">公开贡献</p>
|
|
||||||
<p class="mt-3 text-3xl font-bold text-slate-900">{{ approvedShots }}</p>
|
|
||||||
<p class="mt-2 text-sm text-slate-500">当前公开可见的云图数量</p>
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="oc-container oc-container--content oc-page-content">
|
|
||||||
<NAlert
|
|
||||||
v-if="errorMsg"
|
|
||||||
type="error"
|
|
||||||
:show-icon="false"
|
|
||||||
:bordered="false"
|
|
||||||
title="个人主页加载失败"
|
|
||||||
>
|
|
||||||
{{ errorMsg }}
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<NAlert
|
|
||||||
v-if="manageError"
|
|
||||||
class="mb-5"
|
|
||||||
type="error"
|
|
||||||
:show-icon="false"
|
|
||||||
:bordered="false"
|
|
||||||
title="操作失败"
|
|
||||||
>
|
|
||||||
{{ manageError }}
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<section ref="timelineSectionRef">
|
|
||||||
<ContributionHeatmap
|
|
||||||
:cells="contributionCells"
|
|
||||||
:total-count="totalShots"
|
|
||||||
:active-days="activeUploadDays"
|
|
||||||
:active-date="selectedUploadDate"
|
|
||||||
:title="isOwnProfile ? '上传活跃度' : '公开活跃度'"
|
|
||||||
@select-date="toggleSelectedUploadDate"
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="mt-8">
|
|
||||||
<div class="mb-5 flex items-end justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<h2 class="text-2xl font-bold text-slate-900">天空日志</h2>
|
|
||||||
<p class="oc-field-help">
|
|
||||||
按拍摄时间展示;未记录拍摄时间时回退为上传时间。热力图按上传日期计算。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center gap-3">
|
|
||||||
<NButton
|
|
||||||
v-if="isOwnProfile && selectedCloudCount"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--danger"
|
|
||||||
@click="deleteCloudByIds(Array.from(selectedCloudIds))"
|
|
||||||
>
|
|
||||||
删除已选 {{ selectedCloudCount }}
|
|
||||||
</NButton>
|
|
||||||
<NButton
|
|
||||||
v-if="isOwnProfile"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
@click="toggleSelectionMode"
|
|
||||||
>
|
|
||||||
{{ selectionMode ? '取消选择' : '选择图片' }}
|
|
||||||
</NButton>
|
|
||||||
<NButton
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
@click="loadProfilePage(true)"
|
|
||||||
>
|
|
||||||
刷新数据
|
|
||||||
</NButton>
|
|
||||||
<NButton
|
|
||||||
v-if="selectedUploadDate"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
@click="clearSelectedUploadDate"
|
|
||||||
>
|
|
||||||
清除筛选
|
|
||||||
</NButton>
|
|
||||||
<RouterLink
|
|
||||||
v-if="isOwnProfile"
|
|
||||||
to="/upload"
|
|
||||||
class="oc-panel-button oc-panel-button--sky"
|
|
||||||
>
|
|
||||||
继续上传
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="selectedUploadDate"
|
|
||||||
class="mb-5 border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-800"
|
|
||||||
>
|
|
||||||
当前正在查看
|
|
||||||
<span class="font-semibold">{{ formatDayKey(selectedUploadDate) }}</span>
|
|
||||||
上传的图片, 共
|
|
||||||
<span class="font-semibold">{{ selectedUploadCount }}</span> 张。
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="loading" class="space-y-8">
|
|
||||||
<div v-for="n in 2" :key="n">
|
|
||||||
<NSkeleton class="h-6 w-40" />
|
|
||||||
<div class="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
<NCard
|
|
||||||
v-for="m in 3"
|
|
||||||
:key="m"
|
|
||||||
class="oc-panel-card border border-slate-200"
|
|
||||||
:bordered="false"
|
|
||||||
>
|
|
||||||
<NSkeleton class="h-52 w-full" />
|
|
||||||
<NSkeleton class="mt-4 h-5 w-2/3" />
|
|
||||||
<NSkeleton class="mt-3 h-4 w-1/2" />
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="timelineGroups.length" class="space-y-10">
|
|
||||||
<section v-for="group in timelineGroups" :key="group.key">
|
|
||||||
<div class="flex items-center gap-4">
|
|
||||||
<h3 class="text-xl font-bold text-slate-900">{{ group.label }}</h3>
|
|
||||||
<div class="h-px flex-1 bg-slate-200"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
|
||||||
<button
|
|
||||||
v-for="item in group.items"
|
|
||||||
:key="item.id"
|
|
||||||
type="button"
|
|
||||||
class="oc-interactive-card group overflow-hidden border bg-white text-left transition-all hover:-translate-y-1"
|
|
||||||
:class="
|
|
||||||
selectedUploadDate &&
|
|
||||||
toDayKey(item.created_at) === selectedUploadDate
|
|
||||||
? 'oc-interactive-card--selected'
|
|
||||||
: 'border-slate-200'
|
|
||||||
"
|
|
||||||
@click="selectedCloud = item"
|
|
||||||
>
|
|
||||||
<div class="relative">
|
|
||||||
<img
|
|
||||||
:src="item.thumbnail_url || item.image_url"
|
|
||||||
:alt="item.cloudTypeName"
|
|
||||||
class="h-56 w-full object-cover transition duration-500 group-hover:scale-[1.03]"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
class="absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-slate-950/82 via-slate-950/32 to-transparent"
|
|
||||||
></div>
|
|
||||||
<div class="absolute left-4 top-4 flex flex-wrap gap-2">
|
|
||||||
<label
|
|
||||||
v-if="isOwnProfile && selectionMode"
|
|
||||||
class="inline-flex items-center border border-white/60 bg-white/90 px-2 py-1 text-xs font-medium text-slate-700"
|
|
||||||
@click.stop
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
class="mr-2 h-3.5 w-3.5 border-slate-300 text-sky-500 focus:ring-sky-500"
|
|
||||||
:checked="selectedCloudIds.has(item.id)"
|
|
||||||
@change="
|
|
||||||
toggleCloudSelection(
|
|
||||||
item.id,
|
|
||||||
($event.target as HTMLInputElement)
|
|
||||||
.checked,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
选择
|
|
||||||
</label>
|
|
||||||
<span
|
|
||||||
class="inline-flex border px-3 py-1 text-xs font-medium"
|
|
||||||
:class="rarityChip"
|
|
||||||
>
|
|
||||||
稀有度 {{ item.cloudTypeRarity }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="isOwnProfile"
|
|
||||||
class="inline-flex border px-3 py-1 text-xs font-medium"
|
|
||||||
:class="statusMeta[item.status].chip"
|
|
||||||
>
|
|
||||||
{{ statusMeta[item.status].label }}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
v-if="isOwnProfile"
|
|
||||||
class="inline-flex border px-3 py-1 text-xs font-medium"
|
|
||||||
:class="
|
|
||||||
item.is_hidden
|
|
||||||
? 'border-slate-200 bg-slate-100 text-slate-600'
|
|
||||||
: 'border-emerald-200 bg-emerald-100 text-emerald-700'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ item.is_hidden ? '私密' : '公开' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="absolute bottom-4 left-4 right-4 text-white">
|
|
||||||
<p class="truncate text-lg font-semibold">
|
|
||||||
{{ item.cloudTypeName }}
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 truncate text-xs text-white/80">
|
|
||||||
{{ formatCloudTime(item) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="p-5">
|
|
||||||
<p class="truncate text-sm font-medium text-slate-900">
|
|
||||||
纬度 {{ formatCoordinate(item.latitude) }} / 经度
|
|
||||||
{{ formatCoordinate(item.longitude) }}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
class="mt-3 line-clamp-3 text-sm leading-6 text-slate-500"
|
|
||||||
>
|
|
||||||
{{ item.description || '没有留下额外说明。' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="oc-empty-card border border-dashed border-slate-300 bg-white p-10"
|
|
||||||
>
|
|
||||||
<NEmpty
|
|
||||||
:description="
|
|
||||||
selectedUploadDate
|
|
||||||
? '这一天没有上传记录'
|
|
||||||
: isOwnProfile
|
|
||||||
? '还没有上传任何云图'
|
|
||||||
: '该用户暂无公开云图'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<template #extra>
|
|
||||||
<RouterLink
|
|
||||||
v-if="isOwnProfile && !selectedUploadDate"
|
|
||||||
to="/upload"
|
|
||||||
class="oc-panel-button oc-panel-button--sky"
|
|
||||||
>
|
|
||||||
去上传第一张
|
|
||||||
</RouterLink>
|
|
||||||
<NButton
|
|
||||||
v-else-if="selectedUploadDate"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
@click="clearSelectedUploadDate"
|
|
||||||
>返回全部记录</NButton
|
|
||||||
>
|
|
||||||
</template>
|
|
||||||
</NEmpty>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ImageDetailModal
|
|
||||||
v-if="selectedCloud"
|
|
||||||
:open="!!selectedCloud"
|
|
||||||
:image-url="selectedCloud.image_url"
|
|
||||||
:thumbnail-url="selectedCloud.thumbnail_url"
|
|
||||||
:image-alt="selectedCloud.cloudTypeName"
|
|
||||||
:title="selectedCloud.cloudTypeName"
|
|
||||||
:subtitle="formatCloudTime(selectedCloud)"
|
|
||||||
:badge-label="`稀有度 ${selectedCloud.cloudTypeRarity}`"
|
|
||||||
:badge-class="rarityChip"
|
|
||||||
@close="selectedCloud = null"
|
|
||||||
>
|
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">上传时间</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">
|
|
||||||
{{ formatDateTime(selectedCloud.created_at) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">拍摄时间</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">
|
|
||||||
{{ formatDateTime(selectedCloud.captured_at) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">位置</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">已记录经纬度</p>
|
|
||||||
</div>
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">模糊化经纬度</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">
|
|
||||||
纬度 {{ formatCoordinate(selectedCloud.latitude) }} / 经度
|
|
||||||
{{ formatCoordinate(selectedCloud.longitude) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div class="oc-inset-panel">
|
|
||||||
<p class="oc-section-label">{{ isOwnProfile ? '审核状态' : '可见性' }}</p>
|
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">
|
|
||||||
<template v-if="isOwnProfile">
|
|
||||||
{{ statusMeta[selectedCloud.status].label }} /
|
|
||||||
{{ selectedCloud.is_hidden ? '私密' : '公开' }}
|
|
||||||
</template>
|
|
||||||
<template v-else>公开可见</template>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mt-5 border border-slate-200 bg-white p-5">
|
|
||||||
<p class="oc-section-label">图片说明</p>
|
|
||||||
<p class="mt-3 text-sm leading-7 text-slate-700">
|
|
||||||
{{ selectedCloud.description || '上传者没有留下额外说明。' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<MiniLocationMap
|
|
||||||
:latitude="selectedCloud.latitude"
|
|
||||||
:longitude="selectedCloud.longitude"
|
|
||||||
/>
|
|
||||||
<CloudLikeButton
|
|
||||||
v-if="selectedCloud.status === 'approved' && !selectedCloud.is_hidden"
|
|
||||||
:cloud-id="selectedCloud.id"
|
|
||||||
:count="selectedCloud.receivedLikeCount"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<template v-if="isOwnProfile" #actions>
|
|
||||||
<div class="flex items-center justify-between gap-3">
|
|
||||||
<NDropdown
|
|
||||||
trigger="click"
|
|
||||||
placement="top-start"
|
|
||||||
:options="cloudActionOptions"
|
|
||||||
@select="handleCloudAction"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-icon-button"
|
|
||||||
title="图片管理"
|
|
||||||
aria-label="图片管理"
|
|
||||||
>
|
|
||||||
<NIcon size="20">
|
|
||||||
<Settings />
|
|
||||||
</NIcon>
|
|
||||||
</button>
|
|
||||||
</NDropdown>
|
|
||||||
<!-- <span class="text-xs text-slate-500">
|
|
||||||
{{ selectedCloud.is_hidden ? '当前为私密图片' : '当前会公开展示' }}
|
|
||||||
</span> -->
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</ImageDetailModal>
|
|
||||||
|
|
||||||
<CloudEditModal
|
|
||||||
:open="editModalOpen && !!selectedCloud"
|
|
||||||
:saving="editSaving"
|
|
||||||
:error="manageError"
|
|
||||||
:cloud-types="cloudsStore.cloudTypes"
|
|
||||||
:initial-value="editInitialValue"
|
|
||||||
@close="closeEditModal"
|
|
||||||
@save="submitEditModal"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.profile-avatar {
|
|
||||||
box-shadow: var(--oc-shadow-sm);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { NButton, NIcon } from 'naive-ui'
|
|
||||||
import { Lock } from '@vicons/tabler'
|
|
||||||
import { RouterLink, useRoute } from 'vue-router'
|
|
||||||
|
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const redirect = computed(() => {
|
|
||||||
const value = route.query.redirect
|
|
||||||
return typeof value === 'string' && value.startsWith('/') ? value : '/'
|
|
||||||
})
|
|
||||||
|
|
||||||
const title = computed(() => {
|
|
||||||
if (redirect.value === '/upload') {
|
|
||||||
return '上传前先登录'
|
|
||||||
}
|
|
||||||
|
|
||||||
return '需要先登录'
|
|
||||||
})
|
|
||||||
|
|
||||||
const description = computed(() => {
|
|
||||||
if (redirect.value === '/upload') {
|
|
||||||
return '上传云图、使用地图快捷上传都需要先登录账号。登录后再回来,这里的上传入口就会开放。'
|
|
||||||
}
|
|
||||||
|
|
||||||
return '这个页面需要先登录才能继续访问。登录成功后可以返回刚才的目标页面继续操作。'
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="oc-system-page">
|
|
||||||
<div class="oc-system-card">
|
|
||||||
<NIcon size="72" class="text-sky-600"><Lock /></NIcon>
|
|
||||||
<p class="mt-6 text-sm font-medium uppercase tracking-[0.24em] text-sky-600">
|
|
||||||
401 Login Required
|
|
||||||
</p>
|
|
||||||
<h1 class="oc-page-title">{{ title }}</h1>
|
|
||||||
<p class="oc-page-description mx-auto max-w-xl">
|
|
||||||
{{ description }}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
|
||||||
<RouterLink
|
|
||||||
:to="{ path: '/login', query: redirect !== '/' ? { redirect } : undefined }"
|
|
||||||
>
|
|
||||||
<NButton
|
|
||||||
type="default"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
class="oc-panel-button oc-panel-button--sky"
|
|
||||||
>前往登录</NButton
|
|
||||||
>
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink to="/">
|
|
||||||
<NButton
|
|
||||||
type="default"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
>返回地图</NButton
|
|
||||||
>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { NButton, NIcon } from 'naive-ui'
|
|
||||||
import { HandStop } from '@vicons/tabler'
|
|
||||||
import { RouterLink } from 'vue-router'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="oc-system-page">
|
|
||||||
<div class="oc-system-card">
|
|
||||||
<NIcon size="72" class="text-rose-600"><HandStop /></NIcon>
|
|
||||||
<p class="mt-6 text-sm font-medium uppercase tracking-[0.24em] text-rose-600">
|
|
||||||
403 Forbidden
|
|
||||||
</p>
|
|
||||||
<h1 class="oc-page-title">这里不让进</h1>
|
|
||||||
<p class="oc-page-description mx-auto max-w-xl">
|
|
||||||
你当前没有访问这个页面的权限。通常是因为这个区域只对管理员开放。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
|
||||||
<RouterLink to="/">
|
|
||||||
<NButton
|
|
||||||
type="default"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
class="oc-panel-button oc-panel-button--sky"
|
|
||||||
>返回地图</NButton
|
|
||||||
>
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink to="/profile">
|
|
||||||
<NButton
|
|
||||||
type="default"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
>前往个人主页</NButton
|
|
||||||
>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { NButton, NIcon } from 'naive-ui'
|
|
||||||
import { Help } from '@vicons/tabler'
|
|
||||||
import { RouterLink } from 'vue-router'
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="oc-system-page">
|
|
||||||
<div class="oc-system-card">
|
|
||||||
<NIcon size="72" class="text-amber-600"><Help /></NIcon>
|
|
||||||
<p class="mt-6 text-sm font-medium uppercase tracking-[0.24em] text-amber-600">
|
|
||||||
404 Not Found
|
|
||||||
</p>
|
|
||||||
<h1 class="oc-page-title">我也没想明白</h1>
|
|
||||||
<p class="oc-page-description mx-auto max-w-xl">
|
|
||||||
这个页面不存在,或者它已经被移动到了别的地方。换个入口再试一次。
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
|
||||||
<RouterLink to="/">
|
|
||||||
<NButton
|
|
||||||
type="default"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
class="oc-panel-button oc-panel-button--sky"
|
|
||||||
>返回地图</NButton
|
|
||||||
>
|
|
||||||
</RouterLink>
|
|
||||||
<RouterLink to="/gallery">
|
|
||||||
<NButton
|
|
||||||
type="default"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
>去画廊看看</NButton
|
|
||||||
>
|
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,495 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
||||||
import { NAlert, NButton, NIcon, NProgress } from 'naive-ui'
|
|
||||||
import { CloudUpload, Map as MapIcon, X } from '@vicons/tabler'
|
|
||||||
import MapPickerModal from '@/shared/map/components/MapPickerModal.vue'
|
|
||||||
import { useCloudsStore } from '@/features/clouds/stores/clouds'
|
|
||||||
import { useUpload } from '@/features/upload/composables/useUpload'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
open: boolean
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
close: []
|
|
||||||
uploaded: []
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const cloudsStore = useCloudsStore()
|
|
||||||
const {
|
|
||||||
items,
|
|
||||||
uploading,
|
|
||||||
overallProgress,
|
|
||||||
currentItemIndex,
|
|
||||||
totalItems,
|
|
||||||
addFiles,
|
|
||||||
removeItem,
|
|
||||||
clearAll,
|
|
||||||
validateAll,
|
|
||||||
uploadAll,
|
|
||||||
} = useUpload()
|
|
||||||
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
|
||||||
const activeId = ref<string | null>(null)
|
|
||||||
const dragOver = ref(false)
|
|
||||||
const errorMsg = ref('')
|
|
||||||
const successMsg = ref(false)
|
|
||||||
const mapPickerOpen = ref(false)
|
|
||||||
|
|
||||||
const activeItem = computed(() => {
|
|
||||||
if (!activeId.value) return null
|
|
||||||
return items.value.find(item => item.id === activeId.value) ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.open,
|
|
||||||
open => {
|
|
||||||
if (open) {
|
|
||||||
cloudsStore.fetchCloudTypes()
|
|
||||||
errorMsg.value = ''
|
|
||||||
successMsg.value = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => items.value.length,
|
|
||||||
() => {
|
|
||||||
if (items.value.length > 0 && !activeId.value) {
|
|
||||||
activeId.value = items.value[0].id
|
|
||||||
}
|
|
||||||
if (items.value.length === 0) {
|
|
||||||
activeId.value = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
async function addSelectedFiles(files: File[]) {
|
|
||||||
const previousLength = items.value.length
|
|
||||||
await addFiles(files)
|
|
||||||
if (items.value.length > previousLength) {
|
|
||||||
activeId.value = items.value[previousLength].id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDrop(event: DragEvent) {
|
|
||||||
dragOver.value = false
|
|
||||||
if (event.dataTransfer?.files) {
|
|
||||||
addSelectedFiles(Array.from(event.dataTransfer.files))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFileSelect(event: Event) {
|
|
||||||
const target = event.target as HTMLInputElement
|
|
||||||
if (target.files?.length) {
|
|
||||||
addSelectedFiles(Array.from(target.files))
|
|
||||||
}
|
|
||||||
target.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRemove(id: string) {
|
|
||||||
removeItem(id)
|
|
||||||
if (activeId.value === id) {
|
|
||||||
activeId.value = items.value[0]?.id ?? null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCategoryChange(event: Event) {
|
|
||||||
if (!activeItem.value) return
|
|
||||||
const value = (event.target as HTMLSelectElement).value
|
|
||||||
if (value === '') {
|
|
||||||
activeItem.value.cloudCategoryId = null
|
|
||||||
} else {
|
|
||||||
activeItem.value.cloudCategoryId = Number(value)
|
|
||||||
}
|
|
||||||
activeItem.value.errors = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDatetimeLocal(iso: string): string {
|
|
||||||
if (!iso) return ''
|
|
||||||
const date = new Date(iso)
|
|
||||||
const pad = (value: number) => String(value).padStart(2, '0')
|
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCapturedAtChange(event: Event) {
|
|
||||||
if (!activeItem.value) return
|
|
||||||
const value = (event.target as HTMLInputElement).value
|
|
||||||
activeItem.value.capturedAt = value ? new Date(value).toISOString() : ''
|
|
||||||
delete activeItem.value.errors.capturedAt
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLatInput(event: Event) {
|
|
||||||
if (!activeItem.value) return
|
|
||||||
const value = Number.parseFloat((event.target as HTMLInputElement).value)
|
|
||||||
activeItem.value.latitude = Number.isNaN(value) ? null : value
|
|
||||||
delete activeItem.value.errors.latitude
|
|
||||||
delete activeItem.value.errors.longitude
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLngInput(event: Event) {
|
|
||||||
if (!activeItem.value) return
|
|
||||||
const value = Number.parseFloat((event.target as HTMLInputElement).value)
|
|
||||||
activeItem.value.longitude = Number.isNaN(value) ? null : value
|
|
||||||
delete activeItem.value.errors.latitude
|
|
||||||
delete activeItem.value.errors.longitude
|
|
||||||
}
|
|
||||||
|
|
||||||
function updatePublicState(isPublic: boolean) {
|
|
||||||
if (!activeItem.value) return
|
|
||||||
activeItem.value.isHidden = !isPublic
|
|
||||||
}
|
|
||||||
|
|
||||||
function openMapPicker() {
|
|
||||||
if (!activeItem.value) return
|
|
||||||
mapPickerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeMapPicker() {
|
|
||||||
mapPickerOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmMapPicker(payload: { latitude: number; longitude: number }) {
|
|
||||||
if (!activeItem.value) return
|
|
||||||
activeItem.value.latitude = payload.latitude
|
|
||||||
activeItem.value.longitude = payload.longitude
|
|
||||||
delete activeItem.value.errors.latitude
|
|
||||||
delete activeItem.value.errors.longitude
|
|
||||||
closeMapPicker()
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
errorMsg.value = ''
|
|
||||||
successMsg.value = false
|
|
||||||
|
|
||||||
if (!validateAll()) {
|
|
||||||
const firstInvalid = items.value.find(item => Object.keys(item.errors).length > 0)
|
|
||||||
if (firstInvalid) {
|
|
||||||
activeId.value = firstInvalid.id
|
|
||||||
await nextTick()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await uploadAll()
|
|
||||||
if (!result.ok) {
|
|
||||||
errorMsg.value = result.errorMessage || '上传失败,请稍后重试'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
successMsg.value = true
|
|
||||||
emit('uploaded')
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
if (uploading.value) return
|
|
||||||
clearAll()
|
|
||||||
errorMsg.value = ''
|
|
||||||
successMsg.value = false
|
|
||||||
mapPickerOpen.value = false
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
clearAll()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<div
|
|
||||||
v-if="open"
|
|
||||||
class="fixed inset-0 z-[130] flex items-center justify-center bg-slate-950/55 px-4 py-6 backdrop-blur-sm"
|
|
||||||
@click="close"
|
|
||||||
>
|
|
||||||
<div class="oc-modal-shell w-full max-w-3xl overflow-hidden" @click.stop>
|
|
||||||
<div class="flex items-start justify-between border-b border-slate-200 px-5 py-4">
|
|
||||||
<div>
|
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-sky-700">
|
|
||||||
Quick Upload
|
|
||||||
</p>
|
|
||||||
<h2 class="mt-2 text-xl font-bold text-slate-950">快速上传云图</h2>
|
|
||||||
<p class="oc-field-help">从地图当前位置带入经纬度,提交后进入审核队列。</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-icon-button"
|
|
||||||
aria-label="关闭快捷上传"
|
|
||||||
title="关闭快捷上传"
|
|
||||||
@click="close"
|
|
||||||
>
|
|
||||||
<NIcon size="20"><X /></NIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="max-h-[72vh] overflow-y-auto px-5 py-5">
|
|
||||||
<input
|
|
||||||
ref="fileInput"
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg,image/png,image/webp"
|
|
||||||
class="hidden"
|
|
||||||
@change="handleFileSelect"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<NAlert
|
|
||||||
v-if="successMsg"
|
|
||||||
type="success"
|
|
||||||
:bordered="false"
|
|
||||||
title="已提交审核"
|
|
||||||
class="mb-4"
|
|
||||||
>
|
|
||||||
图片审核通过后会出现在画廊和地图中。
|
|
||||||
</NAlert>
|
|
||||||
|
|
||||||
<div v-if="uploading" class="mb-4 border border-sky-100 bg-sky-50 p-4">
|
|
||||||
<div class="mb-2 flex items-center justify-between text-sm">
|
|
||||||
<span class="font-medium text-slate-700"
|
|
||||||
>正在上传 {{ currentItemIndex }} / {{ totalItems }}</span
|
|
||||||
>
|
|
||||||
<span class="font-medium text-sky-700">{{ overallProgress }}%</span>
|
|
||||||
</div>
|
|
||||||
<NProgress
|
|
||||||
type="line"
|
|
||||||
:percentage="overallProgress"
|
|
||||||
:show-indicator="false"
|
|
||||||
color="var(--oc-color-info)"
|
|
||||||
rail-color="var(--oc-color-border)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="items.length === 0"
|
|
||||||
class="flex cursor-pointer flex-col items-center justify-center border-2 border-dashed px-6 py-12 text-center transition-colors"
|
|
||||||
:class="
|
|
||||||
dragOver
|
|
||||||
? 'border-sky-400 bg-sky-50'
|
|
||||||
: 'border-slate-300 bg-slate-50 hover:border-sky-300 hover:bg-sky-50'
|
|
||||||
"
|
|
||||||
@click="fileInput?.click()"
|
|
||||||
@dragover.prevent="dragOver = true"
|
|
||||||
@dragleave.prevent="dragOver = false"
|
|
||||||
@drop.prevent="handleDrop"
|
|
||||||
>
|
|
||||||
<NIcon size="42" class="text-sky-600"><CloudUpload /></NIcon>
|
|
||||||
<p class="mt-3 text-base font-semibold text-slate-800">选择一张云图</p>
|
|
||||||
<p class="oc-field-help">支持 JPEG、PNG、WebP,图片不超过 20MB。</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else-if="activeItem">
|
|
||||||
<div class="grid gap-5 md:grid-cols-[220px_minmax(0,1fr)]">
|
|
||||||
<div>
|
|
||||||
<div class="overflow-hidden border border-slate-900 bg-slate-950">
|
|
||||||
<img
|
|
||||||
:src="activeItem.preview"
|
|
||||||
alt="图片预览"
|
|
||||||
class="h-56 w-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="mt-3 flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral flex-1"
|
|
||||||
@click="fileInput?.click()"
|
|
||||||
>
|
|
||||||
重新选择
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-panel-button oc-panel-button--danger flex-1"
|
|
||||||
@click="handleRemove(activeItem.id)"
|
|
||||||
>
|
|
||||||
移除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label"
|
|
||||||
>类别 <span class="oc-field-required">*</span></label
|
|
||||||
>
|
|
||||||
<select
|
|
||||||
:value="activeItem.cloudCategoryId ?? ''"
|
|
||||||
class="oc-field-control"
|
|
||||||
@change="onCategoryChange"
|
|
||||||
>
|
|
||||||
<option value="" disabled>请选择</option>
|
|
||||||
<option
|
|
||||||
v-for="cloudType in cloudsStore.cloudTypes"
|
|
||||||
:key="cloudType.id"
|
|
||||||
:value="cloudType.id"
|
|
||||||
>
|
|
||||||
{{ cloudType.name
|
|
||||||
}}{{
|
|
||||||
cloudType.genus ? '(' + cloudType.genus + ')' : ''
|
|
||||||
}}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<p
|
|
||||||
v-if="activeItem.errors.cloudCategory"
|
|
||||||
class="oc-field-error"
|
|
||||||
>
|
|
||||||
{{ activeItem.errors.cloudCategory }}
|
|
||||||
</p>
|
|
||||||
<p v-if="activeItem.errors.file" class="oc-field-error">
|
|
||||||
{{ activeItem.errors.file }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label">拍摄时间</label>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
:value="formatDatetimeLocal(activeItem.capturedAt)"
|
|
||||||
class="oc-field-control"
|
|
||||||
@change="onCapturedAtChange"
|
|
||||||
/>
|
|
||||||
<p v-if="activeItem.errors.capturedAt" class="oc-field-error">
|
|
||||||
{{ activeItem.errors.capturedAt }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label"
|
|
||||||
>经纬度 <span class="oc-field-required">*</span></label
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_40px] gap-2"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
:value="activeItem.latitude ?? ''"
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
placeholder="纬度"
|
|
||||||
class="oc-field-control"
|
|
||||||
@input="onLatInput"
|
|
||||||
/>
|
|
||||||
<p
|
|
||||||
v-if="activeItem.errors.latitude"
|
|
||||||
class="oc-field-error"
|
|
||||||
>
|
|
||||||
{{ activeItem.errors.latitude }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
:value="activeItem.longitude ?? ''"
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
placeholder="经度"
|
|
||||||
class="oc-field-control"
|
|
||||||
@input="onLngInput"
|
|
||||||
/>
|
|
||||||
<p
|
|
||||||
v-if="activeItem.errors.longitude"
|
|
||||||
class="oc-field-error"
|
|
||||||
>
|
|
||||||
{{ activeItem.errors.longitude }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-icon-button oc-icon-button--sky"
|
|
||||||
title="地图选点"
|
|
||||||
aria-label="地图选点"
|
|
||||||
@click="openMapPicker"
|
|
||||||
>
|
|
||||||
<NIcon size="20">
|
|
||||||
<MapIcon />
|
|
||||||
</NIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-slate-400">
|
|
||||||
必填,可手动输入,也可以点击右侧地图按钮选点。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label">图片说明</label>
|
|
||||||
<textarea
|
|
||||||
v-model="activeItem.description"
|
|
||||||
rows="3"
|
|
||||||
maxlength="128"
|
|
||||||
placeholder="描述一下这张图片..."
|
|
||||||
class="oc-field-control resize-none"
|
|
||||||
></textarea>
|
|
||||||
<p v-if="activeItem.errors.description" class="oc-field-error">
|
|
||||||
{{ activeItem.errors.description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label
|
|
||||||
class="flex items-center justify-between gap-4 border border-slate-200 bg-slate-50 px-4 py-3"
|
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<span class="block text-sm font-medium text-slate-800"
|
|
||||||
>公开展示</span
|
|
||||||
>
|
|
||||||
<span class="mt-1 block text-xs text-slate-500"
|
|
||||||
>关闭后不会出现在公开地图和画廊中。</span
|
|
||||||
>
|
|
||||||
</span>
|
|
||||||
<input
|
|
||||||
:checked="!activeItem.isHidden"
|
|
||||||
type="checkbox"
|
|
||||||
class="h-5 w-5 border-slate-300 text-sky-600 focus:ring-sky-500"
|
|
||||||
@change="
|
|
||||||
updatePublicState(
|
|
||||||
($event.target as HTMLInputElement).checked,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NAlert
|
|
||||||
v-if="errorMsg"
|
|
||||||
class="mt-4"
|
|
||||||
type="error"
|
|
||||||
:show-icon="false"
|
|
||||||
title="上传失败"
|
|
||||||
>
|
|
||||||
{{ errorMsg }}
|
|
||||||
</NAlert>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="flex items-center justify-end gap-3 border-t border-slate-200 bg-slate-50 px-5 py-4"
|
|
||||||
>
|
|
||||||
<NButton
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
:disabled="uploading"
|
|
||||||
@click="close"
|
|
||||||
>关闭</NButton
|
|
||||||
>
|
|
||||||
<NButton
|
|
||||||
type="default"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
class="oc-panel-button oc-panel-button--sky"
|
|
||||||
:disabled="uploading || items.length === 0"
|
|
||||||
@click="handleSubmit"
|
|
||||||
>
|
|
||||||
{{ uploading ? '上传中...' : '提交审核' }}
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MapPickerModal
|
|
||||||
:open="mapPickerOpen"
|
|
||||||
:latitude="activeItem?.latitude ?? null"
|
|
||||||
:longitude="activeItem?.longitude ?? null"
|
|
||||||
description="点击地图即可回填当前图片的经纬度,也可以继续手动修改。"
|
|
||||||
footer-text="建议点选大致拍摄位置,上传时会继续做模糊化处理。"
|
|
||||||
@close="closeMapPicker"
|
|
||||||
@confirm="confirmMapPicker"
|
|
||||||
/>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
import { ref } from 'vue'
|
|
||||||
import { apiRequest } from '@/lib/api'
|
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth'
|
|
||||||
import { useProfileStore } from '@/features/profile/stores/profile'
|
|
||||||
import type { CloudCreateResponse } from '@/types/api'
|
|
||||||
|
|
||||||
export interface UploadItem {
|
|
||||||
id: string
|
|
||||||
file: File
|
|
||||||
preview: string
|
|
||||||
cloudCategoryId: number | null
|
|
||||||
description: string
|
|
||||||
isHidden: boolean
|
|
||||||
latitude: number | null
|
|
||||||
longitude: number | null
|
|
||||||
capturedAt: string
|
|
||||||
errors: Record<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UploadResult {
|
|
||||||
ok: boolean
|
|
||||||
errorMessage?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
let nextId = 0
|
|
||||||
|
|
||||||
function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader()
|
|
||||||
reader.onload = () => resolve(reader.result as ArrayBuffer)
|
|
||||||
reader.onerror = reject
|
|
||||||
reader.readAsArrayBuffer(file)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractExifDate(buffer: ArrayBuffer): string | null {
|
|
||||||
const view = new DataView(buffer)
|
|
||||||
if (view.byteLength < 4 || view.getUint16(0, false) !== 0xffd8) return null
|
|
||||||
|
|
||||||
let offset = 2
|
|
||||||
while (offset < view.byteLength - 2) {
|
|
||||||
const marker = view.getUint16(offset, false)
|
|
||||||
offset += 2
|
|
||||||
if ((marker & 0xff00) !== 0xff00) break
|
|
||||||
const length = view.getUint16(offset, false)
|
|
||||||
offset += 2
|
|
||||||
|
|
||||||
if (marker === 0xffe1) {
|
|
||||||
const exifStart = offset
|
|
||||||
const header = String.fromCharCode(
|
|
||||||
view.getUint8(exifStart),
|
|
||||||
view.getUint8(exifStart + 1),
|
|
||||||
view.getUint8(exifStart + 2),
|
|
||||||
view.getUint8(exifStart + 3),
|
|
||||||
)
|
|
||||||
if (header !== 'Exif') break
|
|
||||||
|
|
||||||
const tiffStart = exifStart + 6
|
|
||||||
const isLE = view.getUint16(tiffStart, false) === 0x4949
|
|
||||||
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
|
|
||||||
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
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
function appendOptional(form: FormData, key: string, value: string) {
|
|
||||||
if (value.trim()) form.append(key, value.trim())
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useUpload() {
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const profileStore = useProfileStore()
|
|
||||||
const items = ref<UploadItem[]>([])
|
|
||||||
const uploading = ref(false)
|
|
||||||
const overallProgress = ref(0)
|
|
||||||
const currentItemIndex = ref(0)
|
|
||||||
const totalItems = ref(0)
|
|
||||||
|
|
||||||
async function addFiles(files: File[]) {
|
|
||||||
const imageFiles = files.filter(file =>
|
|
||||||
['image/jpeg', 'image/png', 'image/webp'].includes(file.type),
|
|
||||||
)
|
|
||||||
for (const file of imageFiles) {
|
|
||||||
let capturedAt: string
|
|
||||||
try {
|
|
||||||
capturedAt = extractExifDate(await readFileAsArrayBuffer(file)) || ''
|
|
||||||
} catch {
|
|
||||||
capturedAt = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
items.value.push({
|
|
||||||
id: String(++nextId),
|
|
||||||
file,
|
|
||||||
preview: URL.createObjectURL(file),
|
|
||||||
cloudCategoryId: null,
|
|
||||||
description: '',
|
|
||||||
isHidden: false,
|
|
||||||
latitude: null,
|
|
||||||
longitude: null,
|
|
||||||
capturedAt,
|
|
||||||
errors: {},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeItem(id: string) {
|
|
||||||
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() {
|
|
||||||
items.value.forEach(item => URL.revokeObjectURL(item.preview))
|
|
||||||
items.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateItem(item: UploadItem) {
|
|
||||||
const errors: Record<string, string> = {}
|
|
||||||
if (!item.cloudCategoryId) errors.cloudCategory = '请选择类别'
|
|
||||||
if (item.file.size === 0) errors.file = '图片文件为空'
|
|
||||||
if (item.file.size > 20 * 1024 * 1024) errors.file = '图片不能超过 20MB'
|
|
||||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(item.file.type)) {
|
|
||||||
errors.file = '仅支持 JPEG、PNG、WebP 格式'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.latitude === null || Number.isNaN(item.latitude)) {
|
|
||||||
errors.latitude = '请填写纬度'
|
|
||||||
} else if (item.latitude < -90 || item.latitude > 90) {
|
|
||||||
errors.latitude = '纬度范围为 -90 到 90'
|
|
||||||
}
|
|
||||||
if (item.longitude === null || Number.isNaN(item.longitude)) {
|
|
||||||
errors.longitude = '请填写经度'
|
|
||||||
} else if (item.longitude < -180 || item.longitude > 180) {
|
|
||||||
errors.longitude = '经度范围为 -180 到 180'
|
|
||||||
}
|
|
||||||
if (item.description.trim().length > 128) errors.description = '图片描述不能超过 128 个字符'
|
|
||||||
|
|
||||||
item.errors = errors
|
|
||||||
return Object.keys(errors).length === 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateAll() {
|
|
||||||
return items.value.map(validateItem).every(Boolean)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function uploadAll(): Promise<UploadResult> {
|
|
||||||
if (!validateAll() || !authStore.user) return { ok: false }
|
|
||||||
|
|
||||||
uploading.value = true
|
|
||||||
totalItems.value = items.value.length
|
|
||||||
currentItemIndex.value = 0
|
|
||||||
overallProgress.value = 0
|
|
||||||
|
|
||||||
try {
|
|
||||||
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 form = new FormData()
|
|
||||||
form.append('image', item.file)
|
|
||||||
form.append('type_id', String(item.cloudCategoryId))
|
|
||||||
form.append('latitude', String(item.latitude))
|
|
||||||
form.append('longitude', String(item.longitude))
|
|
||||||
appendOptional(form, 'description', item.description)
|
|
||||||
appendOptional(form, 'captured_at', item.capturedAt)
|
|
||||||
form.append('is_hidden', String(item.isHidden))
|
|
||||||
|
|
||||||
await apiRequest<CloudCreateResponse>('/cloud', {
|
|
||||||
method: 'POST',
|
|
||||||
body: form,
|
|
||||||
})
|
|
||||||
overallProgress.value = Math.round(((index + 1) / items.value.length) * 100)
|
|
||||||
}
|
|
||||||
|
|
||||||
clearAll()
|
|
||||||
profileStore.invalidateUser(authStore.user.id)
|
|
||||||
return { ok: true }
|
|
||||||
} catch (error) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
errorMessage: error instanceof Error ? error.message : '上传失败,请稍后重试',
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
uploading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
items,
|
|
||||||
uploading,
|
|
||||||
overallProgress,
|
|
||||||
currentItemIndex,
|
|
||||||
totalItems,
|
|
||||||
addFiles,
|
|
||||||
removeItem,
|
|
||||||
clearAll,
|
|
||||||
validateItem,
|
|
||||||
validateAll,
|
|
||||||
uploadAll,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,560 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
|
||||||
import { NAlert, NButton, NCard, NIcon, NProgress, NResult } from 'naive-ui'
|
|
||||||
import { CloudUpload, Map as MapIcon, X } from '@vicons/tabler'
|
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import { useCloudsStore } from '@/features/clouds/stores/clouds'
|
|
||||||
import { useUpload } from '@/features/upload/composables/useUpload'
|
|
||||||
import MapPickerModal from '@/shared/map/components/MapPickerModal.vue'
|
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const cloudsStore = useCloudsStore()
|
|
||||||
const {
|
|
||||||
items,
|
|
||||||
uploading,
|
|
||||||
overallProgress,
|
|
||||||
currentItemIndex,
|
|
||||||
totalItems,
|
|
||||||
addFiles,
|
|
||||||
removeItem,
|
|
||||||
clearAll,
|
|
||||||
validateAll,
|
|
||||||
uploadAll,
|
|
||||||
} = useUpload()
|
|
||||||
|
|
||||||
const activeId = ref<string | null>(null)
|
|
||||||
const dragOver = ref(false)
|
|
||||||
const successMsg = ref(false)
|
|
||||||
const errorMsg = ref('')
|
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
|
||||||
const mapPickerOpen = ref(false)
|
|
||||||
|
|
||||||
const activeItem = computed(() => {
|
|
||||||
if (!activeId.value) return null
|
|
||||||
return items.value.find(i => i.id === activeId.value) ?? null
|
|
||||||
})
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => items.value.length,
|
|
||||||
() => {
|
|
||||||
if (items.value.length > 0 && !activeId.value) {
|
|
||||||
activeId.value = items.value[0].id
|
|
||||||
}
|
|
||||||
if (items.value.length === 0) {
|
|
||||||
activeId.value = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
function selectItem(id: string) {
|
|
||||||
activeId.value = id
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDrop(e: DragEvent) {
|
|
||||||
dragOver.value = false
|
|
||||||
if (e.dataTransfer?.files) {
|
|
||||||
addFiles(Array.from(e.dataTransfer.files))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFileSelect(e: Event) {
|
|
||||||
const target = e.target as HTMLInputElement
|
|
||||||
if (target.files && target.files.length > 0) {
|
|
||||||
addFiles(Array.from(target.files))
|
|
||||||
}
|
|
||||||
target.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRemove(id: string) {
|
|
||||||
removeItem(id)
|
|
||||||
if (activeId.value === id) {
|
|
||||||
activeId.value = items.value[0]?.id ?? null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateActiveCoordinates(latitude: number | null, longitude: number | null) {
|
|
||||||
if (!activeItem.value) return
|
|
||||||
|
|
||||||
activeItem.value.latitude = latitude
|
|
||||||
activeItem.value.longitude = longitude
|
|
||||||
|
|
||||||
if (activeItem.value.errors.latitude) delete activeItem.value.errors.latitude
|
|
||||||
if (activeItem.value.errors.longitude) delete activeItem.value.errors.longitude
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openMapPicker() {
|
|
||||||
if (!activeItem.value) return
|
|
||||||
|
|
||||||
mapPickerOpen.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeMapPicker() {
|
|
||||||
mapPickerOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirmMapPicker(payload: { latitude: number; longitude: number }) {
|
|
||||||
updateActiveCoordinates(payload.latitude, payload.longitude)
|
|
||||||
closeMapPicker()
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateActivePublicState(isPublic: boolean) {
|
|
||||||
if (!activeItem.value) return
|
|
||||||
activeItem.value.isHidden = !isPublic
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleSubmit() {
|
|
||||||
errorMsg.value = ''
|
|
||||||
const allValid = validateAll()
|
|
||||||
if (!allValid) {
|
|
||||||
const firstInvalid = items.value.find(i => Object.keys(i.errors).length > 0)
|
|
||||||
if (firstInvalid) {
|
|
||||||
activeId.value = firstInvalid.id
|
|
||||||
await nextTick()
|
|
||||||
const el = document.getElementById(`field-${firstInvalid.id}-cloudCategory`)
|
|
||||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await uploadAll()
|
|
||||||
if (result.ok) {
|
|
||||||
successMsg.value = true
|
|
||||||
} else {
|
|
||||||
errorMsg.value = result.errorMessage || '上传失败,请稍后重试'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetAfterSuccess() {
|
|
||||||
successMsg.value = false
|
|
||||||
errorMsg.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLatInput(e: Event) {
|
|
||||||
const val = parseFloat((e.target as HTMLInputElement).value)
|
|
||||||
if (activeItem.value) {
|
|
||||||
activeItem.value.latitude = isNaN(val) ? null : val
|
|
||||||
if (activeItem.value.errors.latitude) delete activeItem.value.errors.latitude
|
|
||||||
if (activeItem.value.errors.longitude) delete activeItem.value.errors.longitude
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onLngInput(e: Event) {
|
|
||||||
const val = parseFloat((e.target as HTMLInputElement).value)
|
|
||||||
if (activeItem.value) {
|
|
||||||
activeItem.value.longitude = isNaN(val) ? null : val
|
|
||||||
if (activeItem.value.errors.latitude) delete activeItem.value.errors.latitude
|
|
||||||
if (activeItem.value.errors.longitude) delete activeItem.value.errors.longitude
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDatetimeLocal(iso: string): string {
|
|
||||||
if (!iso) return ''
|
|
||||||
const d = new Date(iso)
|
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
|
||||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCapturedAtChange(e: Event) {
|
|
||||||
const val = (e.target as HTMLInputElement).value
|
|
||||||
if (activeItem.value) {
|
|
||||||
activeItem.value.capturedAt = val ? new Date(val).toISOString() : ''
|
|
||||||
if (activeItem.value.errors.capturedAt) {
|
|
||||||
delete activeItem.value.errors.capturedAt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onCategoryChange(e: Event) {
|
|
||||||
const val = (e.target as HTMLSelectElement).value
|
|
||||||
if (!activeItem.value) return
|
|
||||||
if (val === '') {
|
|
||||||
activeItem.value.cloudCategoryId = null
|
|
||||||
} else {
|
|
||||||
activeItem.value.cloudCategoryId = Number(val)
|
|
||||||
}
|
|
||||||
activeItem.value.errors = {}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
cloudsStore.fetchCloudTypes()
|
|
||||||
})
|
|
||||||
onUnmounted(() => {
|
|
||||||
for (const item of items.value) URL.revokeObjectURL(item.preview)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<section class="oc-page-hero">
|
|
||||||
<div class="oc-container oc-container--content oc-page-hero__inner">
|
|
||||||
<p class="oc-page-eyebrow">Cloud Upload</p>
|
|
||||||
<h1 class="oc-page-title">上传云图</h1>
|
|
||||||
<p class="oc-page-description">
|
|
||||||
批量整理你的云图记录。类别和经纬度必填;拍摄时间可留空,也可以从图片 EXIF
|
|
||||||
自动读取。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="oc-container oc-container--content oc-page-content">
|
|
||||||
<input
|
|
||||||
ref="fileInput"
|
|
||||||
type="file"
|
|
||||||
accept="image/jpeg,image/png,image/webp"
|
|
||||||
multiple
|
|
||||||
class="hidden"
|
|
||||||
@change="handleFileSelect"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div v-if="successMsg" class="flex items-center justify-center min-h-[60vh]">
|
|
||||||
<div class="w-full max-w-5xl">
|
|
||||||
<NResult status="success" title="已提交审核" />
|
|
||||||
|
|
||||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
|
||||||
<NButton
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
@click="resetAfterSuccess"
|
|
||||||
>继续上传</NButton
|
|
||||||
>
|
|
||||||
<NButton
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--sky"
|
|
||||||
@click="router.push('/encyclopedia')"
|
|
||||||
>前往百科</NButton
|
|
||||||
>
|
|
||||||
<NButton
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
@click="router.push('/profile')"
|
|
||||||
>返回个人主页</NButton
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div
|
|
||||||
v-if="uploading"
|
|
||||||
class="oc-panel-card mb-6 border border-slate-200 bg-white p-5"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between mb-2">
|
|
||||||
<span class="text-sm font-medium text-slate-700"
|
|
||||||
>正在上传 {{ currentItemIndex }} / {{ totalItems }}...</span
|
|
||||||
>
|
|
||||||
<span class="text-sm font-medium text-sky-600">{{ overallProgress }}%</span>
|
|
||||||
</div>
|
|
||||||
<NProgress
|
|
||||||
type="line"
|
|
||||||
:show-indicator="false"
|
|
||||||
:percentage="overallProgress"
|
|
||||||
color="var(--oc-color-info)"
|
|
||||||
rail-color="var(--oc-color-border)"
|
|
||||||
:height="10"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="items.length === 0"
|
|
||||||
class="oc-panel-card flex cursor-pointer flex-col items-center justify-center border-2 border-dashed bg-white py-20 transition-colors"
|
|
||||||
:class="
|
|
||||||
dragOver
|
|
||||||
? 'border-sky-400 bg-sky-50'
|
|
||||||
: 'border-slate-300 hover:border-sky-400 hover:bg-slate-50'
|
|
||||||
"
|
|
||||||
@dragover.prevent="dragOver = true"
|
|
||||||
@dragleave.prevent="dragOver = false"
|
|
||||||
@drop.prevent="handleDrop"
|
|
||||||
@click="fileInput?.click()"
|
|
||||||
>
|
|
||||||
<NIcon size="48" class="mb-4 text-sky-600"><CloudUpload /></NIcon>
|
|
||||||
<p class="mb-1 text-lg font-medium text-slate-700">点击或拖拽图片到此处</p>
|
|
||||||
<p class="text-sm text-slate-400">
|
|
||||||
支持 JPEG、PNG、WebP,单张不超过 20MB,可一次选择多张
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<template v-else>
|
|
||||||
<div class="flex min-w-0 flex-col gap-6 lg:flex-row">
|
|
||||||
<div class="w-full min-w-0 lg:w-[480px] lg:flex-shrink-0">
|
|
||||||
<div
|
|
||||||
v-if="activeItem"
|
|
||||||
class="mb-3 overflow-hidden border border-slate-900 bg-slate-900"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
:src="activeItem.preview"
|
|
||||||
alt="预览"
|
|
||||||
class="h-64 w-full object-contain sm:h-[360px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-2 overflow-x-auto pb-2">
|
|
||||||
<div
|
|
||||||
v-for="item in items"
|
|
||||||
:key="item.id"
|
|
||||||
class="relative flex-shrink-0 w-16 h-16 overflow-hidden cursor-pointer border-2 transition-colors bg-white"
|
|
||||||
:class="
|
|
||||||
activeId === item.id
|
|
||||||
? 'border-sky-500'
|
|
||||||
: 'border-transparent hover:border-slate-300'
|
|
||||||
"
|
|
||||||
@click="selectItem(item.id)"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
:src="item.preview"
|
|
||||||
alt=""
|
|
||||||
class="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="Object.keys(item.errors).length > 0"
|
|
||||||
class="absolute top-0 right-0 w-2.5 h-2.5 bg-red-500"
|
|
||||||
></div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-icon-button oc-icon-button--danger oc-icon-button--tiny absolute -right-1 -top-1"
|
|
||||||
title="移除图片"
|
|
||||||
aria-label="移除图片"
|
|
||||||
@click.stop="handleRemove(item.id)"
|
|
||||||
>
|
|
||||||
<NIcon size="12"><X /></NIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex h-16 w-16 flex-shrink-0 cursor-pointer items-center justify-center border-2 border-dashed border-slate-300 bg-white transition-colors hover:border-sky-400 hover:bg-slate-50"
|
|
||||||
@click="fileInput?.click()"
|
|
||||||
>
|
|
||||||
<span class="text-xl text-slate-400">+</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="activeItem" class="flex-1 min-w-0">
|
|
||||||
<NCard class="oc-panel-card border border-slate-200">
|
|
||||||
<div class="space-y-5">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h2 class="text-lg font-semibold text-slate-900">
|
|
||||||
图片信息
|
|
||||||
</h2>
|
|
||||||
<span class="text-sm text-slate-400"
|
|
||||||
>{{ items.findIndex(i => i.id === activeId) + 1 }} /
|
|
||||||
{{ items.length }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 类别 * -->
|
|
||||||
<div :id="`field-${activeItem.id}-cloudCategory`">
|
|
||||||
<label class="oc-field-label">
|
|
||||||
类别 <span class="oc-field-required">*</span>
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
:value="activeItem.cloudCategoryId ?? ''"
|
|
||||||
class="oc-field-control"
|
|
||||||
@change="onCategoryChange"
|
|
||||||
>
|
|
||||||
<option value="" disabled>请选择</option>
|
|
||||||
<option
|
|
||||||
v-for="ct in cloudsStore.cloudTypes"
|
|
||||||
:key="ct.id"
|
|
||||||
:value="ct.id"
|
|
||||||
>
|
|
||||||
{{ ct.name
|
|
||||||
}}{{ ct.genus ? '(' + ct.genus + ')' : '' }}
|
|
||||||
</option>
|
|
||||||
</select>
|
|
||||||
<p
|
|
||||||
v-if="activeItem.errors.cloudCategory"
|
|
||||||
class="oc-field-error"
|
|
||||||
>
|
|
||||||
{{ activeItem.errors.cloudCategory }}
|
|
||||||
</p>
|
|
||||||
<p v-if="activeItem.errors.file" class="oc-field-error">
|
|
||||||
{{ activeItem.errors.file }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 拍摄时间 -->
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label">拍摄时间</label>
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
:value="formatDatetimeLocal(activeItem.capturedAt)"
|
|
||||||
class="oc-field-control"
|
|
||||||
@change="onCapturedAtChange"
|
|
||||||
/>
|
|
||||||
<p
|
|
||||||
v-if="activeItem.errors.capturedAt"
|
|
||||||
class="oc-field-error"
|
|
||||||
>
|
|
||||||
{{ activeItem.errors.capturedAt }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 经纬度 * -->
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label"
|
|
||||||
>经纬度 <span class="oc-field-required">*</span></label
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
:value="
|
|
||||||
activeItem.latitude !== null
|
|
||||||
? activeItem.latitude
|
|
||||||
: ''
|
|
||||||
"
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
placeholder="纬度(如 39.90)"
|
|
||||||
class="oc-field-control"
|
|
||||||
@input="onLatInput"
|
|
||||||
/>
|
|
||||||
<p
|
|
||||||
v-if="activeItem.errors.latitude"
|
|
||||||
class="oc-field-error"
|
|
||||||
>
|
|
||||||
{{ activeItem.errors.latitude }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
:value="
|
|
||||||
activeItem.longitude !== null
|
|
||||||
? activeItem.longitude
|
|
||||||
: ''
|
|
||||||
"
|
|
||||||
type="number"
|
|
||||||
step="any"
|
|
||||||
placeholder="经度(如 116.40)"
|
|
||||||
class="oc-field-control"
|
|
||||||
@input="onLngInput"
|
|
||||||
/>
|
|
||||||
<p
|
|
||||||
v-if="activeItem.errors.longitude"
|
|
||||||
class="oc-field-error"
|
|
||||||
>
|
|
||||||
{{ activeItem.errors.longitude }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
title="地图选点"
|
|
||||||
aria-label="地图选点"
|
|
||||||
class="oc-icon-button oc-icon-button--sky"
|
|
||||||
@click="openMapPicker"
|
|
||||||
>
|
|
||||||
<NIcon size="20"><MapIcon /></NIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="mt-1 text-xs text-slate-400">
|
|
||||||
必填,可手动输入,或点击右侧地图图标选择
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 图片描述 -->
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label">图片描述</label>
|
|
||||||
<textarea
|
|
||||||
v-model="activeItem.description"
|
|
||||||
rows="3"
|
|
||||||
maxlength="128"
|
|
||||||
placeholder="描述一下这张图片..."
|
|
||||||
class="oc-field-control resize-none"
|
|
||||||
></textarea>
|
|
||||||
<p
|
|
||||||
v-if="activeItem.errors.description"
|
|
||||||
class="oc-field-error"
|
|
||||||
>
|
|
||||||
{{ activeItem.errors.description }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 公开状态 -->
|
|
||||||
<div class="border border-slate-200 bg-slate-50 px-4 py-3">
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
|
||||||
<div>
|
|
||||||
<p class="text-sm font-medium text-slate-800">
|
|
||||||
公开展示
|
|
||||||
</p>
|
|
||||||
<p class="mt-1 text-xs text-slate-500">
|
|
||||||
关闭后不会出现在画廊、地图和公开主页中。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
:id="'isPublic-' + activeItem.id"
|
|
||||||
:checked="!activeItem.isHidden"
|
|
||||||
type="checkbox"
|
|
||||||
class="h-5 w-5 border-slate-300 text-sky-500 focus:ring-sky-500"
|
|
||||||
@change="
|
|
||||||
updateActivePublicState(
|
|
||||||
($event.target as HTMLInputElement).checked,
|
|
||||||
)
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</NCard>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"
|
|
||||||
>
|
|
||||||
<p class="text-sm text-slate-500">共 {{ items.length }} 张图片</p>
|
|
||||||
<div class="flex flex-wrap gap-3">
|
|
||||||
<NButton
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
:disabled="uploading"
|
|
||||||
@click="clearAll()"
|
|
||||||
>清空</NButton
|
|
||||||
>
|
|
||||||
<NButton
|
|
||||||
type="default"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
class="oc-panel-button oc-panel-button--sky"
|
|
||||||
:disabled="uploading"
|
|
||||||
@click="handleSubmit"
|
|
||||||
>
|
|
||||||
{{ uploading ? '上传中...' : '提交上传' }}
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NAlert
|
|
||||||
v-if="errorMsg"
|
|
||||||
class="mt-3"
|
|
||||||
type="error"
|
|
||||||
:show-icon="false"
|
|
||||||
:bordered="false"
|
|
||||||
title="上传失败"
|
|
||||||
>
|
|
||||||
{{ errorMsg }}
|
|
||||||
</NAlert>
|
|
||||||
</template>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<MapPickerModal
|
|
||||||
:open="mapPickerOpen"
|
|
||||||
:latitude="activeItem?.latitude ?? null"
|
|
||||||
:longitude="activeItem?.longitude ?? null"
|
|
||||||
description="点击地图即可回填当前图片的经纬度,也可以继续手动修改。"
|
|
||||||
footer-text="建议点选大致拍摄位置,上传时会继续做模糊化处理。"
|
|
||||||
@close="closeMapPicker"
|
|
||||||
@confirm="confirmMapPicker"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import AMapLoader from '@amap/amap-jsapi-loader'
|
||||||
|
|
||||||
|
export function loadAMap() {
|
||||||
|
return AMapLoader.load({
|
||||||
|
key: import.meta.env.VITE_AMAP_KEY,
|
||||||
|
version: '2.0',
|
||||||
|
plugins: [
|
||||||
|
'AMap.Scale',
|
||||||
|
'AMap.ControlBar',
|
||||||
|
'AMap.Geolocation',
|
||||||
|
'AMap.Marker',
|
||||||
|
'AMap.InfoWindow',
|
||||||
|
],
|
||||||
|
})
|
||||||
|
}
|
||||||
+101
-170
@@ -1,203 +1,134 @@
|
|||||||
import type {
|
import type { AuthResponse } from '@/types/api'
|
||||||
ApiCloud,
|
|
||||||
AuthUser,
|
|
||||||
BackendCloudInfo,
|
|
||||||
BackendCloudType,
|
|
||||||
BackendUserProfile,
|
|
||||||
} from '@/types/api'
|
|
||||||
import type { CloudType } from '@/types/database'
|
|
||||||
|
|
||||||
const DEFAULT_API_URL = 'http://localhost:3000'
|
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 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'
|
export const AUTH_EXPIRED_EVENT = 'opencloud:auth-expired'
|
||||||
|
|
||||||
export class ApiError extends Error {
|
let accessToken: string | null = null
|
||||||
status: number
|
let refreshPromise: Promise<AuthResponse> | null = null
|
||||||
detail: unknown
|
|
||||||
|
|
||||||
constructor(message: string, status: number, detail?: unknown) {
|
export class ApiError extends Error {
|
||||||
super(message)
|
status: number
|
||||||
this.name = 'ApiError'
|
detail: unknown
|
||||||
this.status = status
|
|
||||||
this.detail = detail
|
constructor(message: string, status: number, detail?: unknown) {
|
||||||
}
|
super(message)
|
||||||
|
this.name = 'ApiError'
|
||||||
|
this.status = status
|
||||||
|
this.detail = detail
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
|
interface ApiRequestOptions extends Omit<RequestInit, 'body'> {
|
||||||
body?: BodyInit | Record<string, unknown> | null
|
body?: BodyInit | Record<string, unknown> | null
|
||||||
auth?: boolean
|
auth?: boolean
|
||||||
|
retry?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function isJsonBody(body: ApiRequestOptions['body']): body is Record<string, unknown> {
|
function isJsonBody(body: ApiRequestOptions['body']): body is Record<string, unknown> {
|
||||||
return (
|
return !!body
|
||||||
!!body &&
|
&& typeof body === 'object'
|
||||||
typeof body === 'object' &&
|
&& !(body instanceof FormData)
|
||||||
!(body instanceof FormData) &&
|
&& !(body instanceof Blob)
|
||||||
!(body instanceof Blob) &&
|
&& !(body instanceof URLSearchParams)
|
||||||
!(body instanceof URLSearchParams) &&
|
&& !(body instanceof ArrayBuffer)
|
||||||
!(body instanceof ArrayBuffer)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function validationMessage(value: unknown) {
|
function detailToMessage(detail: unknown, fallback: string) {
|
||||||
if (typeof value !== 'string') return ''
|
if (typeof detail === 'string') return detail
|
||||||
|
if (Array.isArray(detail)) {
|
||||||
try {
|
const messages = detail
|
||||||
const issues = JSON.parse(value) as unknown
|
.map(item => item && typeof item === 'object' && 'msg' in item ? String(item.msg) : '')
|
||||||
if (!Array.isArray(issues)) return value
|
.filter(Boolean)
|
||||||
const messages = issues
|
if (messages.length) return messages.join(';')
|
||||||
.map(issue =>
|
}
|
||||||
issue && typeof issue === 'object' && 'message' in issue
|
return fallback
|
||||||
? String(issue.message)
|
|
||||||
: '',
|
|
||||||
)
|
|
||||||
.filter(Boolean)
|
|
||||||
return messages.join(';') || value
|
|
||||||
} catch {
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function errorMessage(payload: unknown, fallback: string) {
|
|
||||||
if (!payload || typeof payload !== 'object') return fallback
|
|
||||||
|
|
||||||
if ('detail' in payload) {
|
|
||||||
const detail = payload.detail
|
|
||||||
if (typeof detail === 'string') return detail
|
|
||||||
if (Array.isArray(detail)) {
|
|
||||||
const messages = detail
|
|
||||||
.map(item =>
|
|
||||||
item && typeof item === 'object' && 'msg' in item ? String(item.msg) : '',
|
|
||||||
)
|
|
||||||
.filter(Boolean)
|
|
||||||
if (messages.length) return messages.join(';')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ('error' in payload) {
|
|
||||||
const error = payload.error
|
|
||||||
if (typeof error === 'string') return error
|
|
||||||
if (error && typeof error === 'object' && 'message' in error) {
|
|
||||||
return validationMessage(error.message) || fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
async function readPayload(response: Response) {
|
|
||||||
const text = await response.text()
|
|
||||||
if (!text) return undefined
|
|
||||||
|
|
||||||
const contentType = response.headers.get('content-type') || ''
|
|
||||||
if (!contentType.includes('json')) return text
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(text) as unknown
|
|
||||||
} catch {
|
|
||||||
return text
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toApiError(response: Response) {
|
async function toApiError(response: Response) {
|
||||||
const payload = await readPayload(response)
|
let detail: unknown
|
||||||
const fallback =
|
try {
|
||||||
typeof payload === 'string' && payload.trim()
|
const payload = await response.json() as { detail?: unknown }
|
||||||
? payload.trim()
|
detail = payload.detail
|
||||||
: `请求失败(${response.status})`
|
} catch {
|
||||||
|
detail = undefined
|
||||||
|
}
|
||||||
|
|
||||||
return new ApiError(errorMessage(payload, fallback), response.status, payload)
|
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 = {}) {
|
async function request<T>(path: string, options: ApiRequestOptions = {}) {
|
||||||
const { body, auth = true, ...requestOptions } = options
|
const { body, auth = true, retry = true, ...requestOptions } = options
|
||||||
const headers = new Headers(requestOptions.headers)
|
const headers = new Headers(requestOptions.headers)
|
||||||
let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined
|
let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined
|
||||||
|
|
||||||
if (isJsonBody(body)) {
|
if (isJsonBody(body)) {
|
||||||
headers.set('Content-Type', 'application/json')
|
headers.set('Content-Type', 'application/json')
|
||||||
requestBody = JSON.stringify(body)
|
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))
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}${path}`, {
|
if (!response.ok) throw await toApiError(response)
|
||||||
...requestOptions,
|
if (response.status === 204) return undefined as T
|
||||||
body: requestBody,
|
|
||||||
headers,
|
|
||||||
credentials: 'include',
|
|
||||||
})
|
|
||||||
|
|
||||||
if (response.status === 401 && auth && typeof window !== 'undefined') {
|
return response.json() as Promise<T>
|
||||||
window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT))
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.ok) throw await toApiError(response)
|
|
||||||
return (await readPayload(response)) as T
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function apiRequest<T>(path: string, options: ApiRequestOptions = {}) {
|
export function apiRequest<T>(path: string, options: ApiRequestOptions = {}) {
|
||||||
return request<T>(path, options)
|
return request<T>(path, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function imageUrl(id: string, variant: 'preview' | 'original' = 'preview') {
|
export function refreshSession() {
|
||||||
return `${API_URL}/image/${encodeURIComponent(id)}/${variant}`
|
if (!refreshPromise) {
|
||||||
}
|
refreshPromise = (async () => {
|
||||||
|
const response = await fetch(`${API_URL}/auth/refresh`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (!response.ok) throw await toApiError(response)
|
||||||
|
|
||||||
export function toAuthUser(profile: BackendUserProfile): AuthUser {
|
const authResponse = await response.json() as AuthResponse
|
||||||
return {
|
setAccessToken(authResponse.access_token)
|
||||||
id: profile.id,
|
return authResponse
|
||||||
email: profile.email,
|
})().finally(() => {
|
||||||
username: profile.name,
|
refreshPromise = null
|
||||||
image: profile.image,
|
})
|
||||||
role: profile.role,
|
}
|
||||||
is_disabled: profile.is_disabled,
|
|
||||||
cloud_count: profile.cloud_count,
|
|
||||||
received_like_count: profile.received_like_count,
|
|
||||||
created_at: profile.created_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toCloudType(row: BackendCloudType): CloudType {
|
return refreshPromise
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
name: row.name,
|
|
||||||
genus: row.genus || '',
|
|
||||||
icon_id: row.icon_id,
|
|
||||||
// Compatibility value for the excluded legacy admin view. Migrated pages
|
|
||||||
// display rarity_value directly because API.md defines no semantic mapping.
|
|
||||||
rarity: 'common',
|
|
||||||
rarity_value: row.rarity,
|
|
||||||
description: row.description,
|
|
||||||
created_at: row.created_at,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function toApiCloud(row: BackendCloudInfo): ApiCloud {
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
user_id: row.owner.id,
|
|
||||||
cloud_type_id: row.type.id,
|
|
||||||
image_url: imageUrl(row.id, 'original'),
|
|
||||||
thumbnail_url: imageUrl(row.id, 'preview'),
|
|
||||||
latitude: row.latitude,
|
|
||||||
longitude: row.longitude,
|
|
||||||
description: row.description,
|
|
||||||
captured_at: row.captured_at,
|
|
||||||
status: row.status,
|
|
||||||
is_hidden: row.is_hidden,
|
|
||||||
created_at: row.uploaded_at,
|
|
||||||
updated_at: row.updated_at,
|
|
||||||
cloud_type: {
|
|
||||||
id: row.type.id,
|
|
||||||
name: row.type.name,
|
|
||||||
rarity: 'common',
|
|
||||||
rarity_value: row.type.rarity,
|
|
||||||
},
|
|
||||||
owner: { id: row.owner.id, username: row.owner.name },
|
|
||||||
cloud_type_name: row.type.name,
|
|
||||||
cloud_type_rarity: 'common',
|
|
||||||
cloud_type_rarity_value: row.type.rarity,
|
|
||||||
username: row.owner.name,
|
|
||||||
received_like_count: row.received_like_count,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
const PATCH_FLAG = '__openCloudWillReadFrequentlyPatched__'
|
||||||
|
|
||||||
|
type CanvasLikePrototype = {
|
||||||
|
getContext?: (contextId: string, options?: unknown) => unknown
|
||||||
|
[PATCH_FLAG]?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function patch2DContextOptions(ctor: typeof HTMLCanvasElement | typeof OffscreenCanvas | undefined) {
|
||||||
|
const prototype = ctor?.prototype as CanvasLikePrototype | undefined
|
||||||
|
if (!prototype?.getContext || prototype[PATCH_FLAG]) return
|
||||||
|
|
||||||
|
const originalGetContext = prototype.getContext
|
||||||
|
|
||||||
|
prototype.getContext = function getContextWithReadHint(contextId: string, options?: unknown) {
|
||||||
|
if (contextId === '2d') {
|
||||||
|
const nextOptions =
|
||||||
|
options && typeof options === 'object'
|
||||||
|
? { ...options as Record<string, unknown>, willReadFrequently: true }
|
||||||
|
: { willReadFrequently: true }
|
||||||
|
|
||||||
|
return originalGetContext.call(this, contextId, nextOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalGetContext.call(this, contextId, options)
|
||||||
|
}
|
||||||
|
|
||||||
|
prototype[PATCH_FLAG] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function enableCanvasReadbackHint() {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
patch2DContextOptions(window.HTMLCanvasElement)
|
||||||
|
if (typeof window.OffscreenCanvas !== 'undefined') {
|
||||||
|
patch2DContextOptions(window.OffscreenCanvas)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
export type BadgeRarity = 'common' | 'uncommon' | 'rare'
|
||||||
|
|
||||||
|
export interface CloudBadgeCardInput {
|
||||||
|
cloudName: string
|
||||||
|
cloudNameEn: string
|
||||||
|
unlockedAt: string
|
||||||
|
username: string
|
||||||
|
rarity: BadgeRarity
|
||||||
|
}
|
||||||
|
|
||||||
|
const rarityTheme = {
|
||||||
|
common: {
|
||||||
|
from: '#dbeafe',
|
||||||
|
to: '#f8fafc',
|
||||||
|
accent: '#0284c7',
|
||||||
|
shadow: 'rgba(2, 132, 199, 0.18)',
|
||||||
|
},
|
||||||
|
uncommon: {
|
||||||
|
from: '#fef3c7',
|
||||||
|
to: '#fff7ed',
|
||||||
|
accent: '#d97706',
|
||||||
|
shadow: 'rgba(217, 119, 6, 0.22)',
|
||||||
|
},
|
||||||
|
rare: {
|
||||||
|
from: '#fee2e2',
|
||||||
|
to: '#fff7ed',
|
||||||
|
accent: '#dc2626',
|
||||||
|
shadow: 'rgba(220, 38, 38, 0.22)',
|
||||||
|
},
|
||||||
|
} satisfies Record<BadgeRarity, { from: string; to: string; accent: string; shadow: string }>
|
||||||
|
|
||||||
|
function formatDate(iso: string) {
|
||||||
|
return new Date(iso).toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDownloadName(name: string) {
|
||||||
|
return `opencloud-badge-${name}.png`
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function downloadCloudBadgeCard(input: CloudBadgeCardInput) {
|
||||||
|
const canvas = document.createElement('canvas')
|
||||||
|
canvas.width = 1200
|
||||||
|
canvas.height = 630
|
||||||
|
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
const theme = rarityTheme[input.rarity]
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
|
||||||
|
gradient.addColorStop(0, theme.from)
|
||||||
|
gradient.addColorStop(1, theme.to)
|
||||||
|
|
||||||
|
ctx.fillStyle = gradient
|
||||||
|
ctx.fillRect(0, 0, canvas.width, canvas.height)
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.72)'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(1010, 120, 140, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.45)'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(160, 520, 180, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
ctx.fillStyle = '#0f172a'
|
||||||
|
ctx.font = '700 44px sans-serif'
|
||||||
|
ctx.fillText('OpenCloud 云朵图鉴', 72, 88)
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(15, 23, 42, 0.62)'
|
||||||
|
ctx.font = '400 24px sans-serif'
|
||||||
|
ctx.fillText('新徽章已解锁', 72, 126)
|
||||||
|
|
||||||
|
ctx.shadowColor = theme.shadow
|
||||||
|
ctx.shadowBlur = 28
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.roundRect(72, 170, 1056, 380, 32)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.shadowBlur = 0
|
||||||
|
|
||||||
|
ctx.fillStyle = theme.accent
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(210, 360, 92, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.font = '700 82px sans-serif'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.textBaseline = 'middle'
|
||||||
|
ctx.fillText(input.cloudName.slice(0, 1), 210, 360)
|
||||||
|
|
||||||
|
ctx.textAlign = 'left'
|
||||||
|
ctx.textBaseline = 'alphabetic'
|
||||||
|
ctx.fillStyle = '#0f172a'
|
||||||
|
ctx.font = '700 64px sans-serif'
|
||||||
|
ctx.fillText(input.cloudName, 340, 300)
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(15, 23, 42, 0.62)'
|
||||||
|
ctx.font = '400 30px sans-serif'
|
||||||
|
ctx.fillText(input.cloudNameEn, 340, 346)
|
||||||
|
|
||||||
|
ctx.fillStyle = theme.accent
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.roundRect(340, 382, 168, 48, 24)
|
||||||
|
ctx.fill()
|
||||||
|
|
||||||
|
ctx.fillStyle = '#ffffff'
|
||||||
|
ctx.font = '600 24px sans-serif'
|
||||||
|
ctx.fillText(input.rarity === 'common' ? '常见' : input.rarity === 'uncommon' ? '少见' : '罕见', 384, 414)
|
||||||
|
|
||||||
|
ctx.fillStyle = '#334155'
|
||||||
|
ctx.font = '400 28px sans-serif'
|
||||||
|
ctx.fillText(`解锁者:${input.username}`, 340, 476)
|
||||||
|
ctx.fillText(`解锁日期:${formatDate(input.unlockedAt)}`, 340, 518)
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob | null>(resolve => canvas.toBlob(resolve, 'image/png'))
|
||||||
|
if (!blob) return
|
||||||
|
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = makeDownloadName(input.cloudNameEn.toLowerCase())
|
||||||
|
link.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export const CLOUD_TYPES = [
|
||||||
|
{ id: 1, name: '积云', nameEn: 'Cumulus' },
|
||||||
|
{ id: 2, name: '层云', nameEn: 'Stratus' },
|
||||||
|
{ id: 3, name: '卷云', nameEn: 'Cirrus' },
|
||||||
|
{ id: 4, name: '积雨云', nameEn: 'Cumulonimbus' },
|
||||||
|
{ id: 5, name: '层积云', nameEn: 'Stratocumulus' },
|
||||||
|
{ id: 6, name: '高积云', nameEn: 'Altocumulus' },
|
||||||
|
{ id: 7, name: '高层云', nameEn: 'Altostratus' },
|
||||||
|
{ id: 8, name: '雨层云', nameEn: 'Nimbostratus' },
|
||||||
|
{ id: 9, name: '卷层云', nameEn: 'Cirrostratus' },
|
||||||
|
{ id: 10, name: '卷积云', nameEn: 'Cirrocumulus' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export function getCloudTypeName(id: string | string[]) {
|
||||||
|
const cloudTypeId = Number(Array.isArray(id) ? id[0] : id)
|
||||||
|
return CLOUD_TYPES.find(item => item.id === cloudTypeId)?.name ?? '云型详情'
|
||||||
|
}
|
||||||
+41
-45
@@ -4,64 +4,60 @@ const defaultTitle = 'OpenCloud'
|
|||||||
const defaultDescription = 'OpenCloud - 活的天空地图,拍云、识别、收藏'
|
const defaultDescription = 'OpenCloud - 活的天空地图,拍云、识别、收藏'
|
||||||
|
|
||||||
function resolveSiteUrl() {
|
function resolveSiteUrl() {
|
||||||
return (import.meta.env.VITE_SITE_URL || window.location.origin).replace(/\/$/, '')
|
return (import.meta.env.VITE_SITE_URL || window.location.origin).replace(/\/$/, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
function upsertMeta(name: string, content: string) {
|
function upsertMeta(name: string, content: string) {
|
||||||
let element = document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`)
|
let element = document.querySelector<HTMLMetaElement>(`meta[name="${name}"]`)
|
||||||
if (!element) {
|
if (!element) {
|
||||||
element = document.createElement('meta')
|
element = document.createElement('meta')
|
||||||
element.name = name
|
element.name = name
|
||||||
document.head.appendChild(element)
|
document.head.appendChild(element)
|
||||||
}
|
}
|
||||||
element.content = content
|
element.content = content
|
||||||
}
|
}
|
||||||
|
|
||||||
function upsertPropertyMeta(property: string, content: string) {
|
function upsertPropertyMeta(property: string, content: string) {
|
||||||
let element = document.querySelector<HTMLMetaElement>(`meta[property="${property}"]`)
|
let element = document.querySelector<HTMLMetaElement>(`meta[property="${property}"]`)
|
||||||
if (!element) {
|
if (!element) {
|
||||||
element = document.createElement('meta')
|
element = document.createElement('meta')
|
||||||
element.setAttribute('property', property)
|
element.setAttribute('property', property)
|
||||||
document.head.appendChild(element)
|
document.head.appendChild(element)
|
||||||
}
|
}
|
||||||
element.content = content
|
element.content = content
|
||||||
}
|
}
|
||||||
|
|
||||||
function upsertCanonical(href: string) {
|
function upsertCanonical(href: string) {
|
||||||
let element = document.querySelector<HTMLLinkElement>('link[rel="canonical"]')
|
let element = document.querySelector<HTMLLinkElement>('link[rel="canonical"]')
|
||||||
if (!element) {
|
if (!element) {
|
||||||
element = document.createElement('link')
|
element = document.createElement('link')
|
||||||
element.rel = 'canonical'
|
element.rel = 'canonical'
|
||||||
document.head.appendChild(element)
|
document.head.appendChild(element)
|
||||||
}
|
}
|
||||||
element.href = href
|
element.href = href
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyRouteSeo(route: RouteLocationNormalizedLoaded) {
|
export function applyRouteSeo(route: RouteLocationNormalizedLoaded) {
|
||||||
const title =
|
const title = typeof route.meta.title === 'function' ? route.meta.title(route) : route.meta.title
|
||||||
typeof route.meta.title === 'function' ? route.meta.title(route) : route.meta.title
|
const description = typeof route.meta.description === 'function' ? route.meta.description(route) : route.meta.description
|
||||||
const description =
|
const robots = route.meta.noindex ? 'noindex, nofollow' : 'index, follow'
|
||||||
typeof route.meta.description === 'function'
|
const canonicalPath = route.meta.canonicalPath || route.path
|
||||||
? route.meta.description(route)
|
const canonical = `${resolveSiteUrl()}${canonicalPath}`
|
||||||
: route.meta.description
|
const pageTitle = title ? `${title} | OpenCloud` : defaultTitle
|
||||||
const robots = route.meta.noindex ? 'noindex, nofollow' : 'index, follow'
|
const pageDescription = description || defaultDescription
|
||||||
const canonicalPath = route.meta.canonicalPath || route.path
|
|
||||||
const canonical = `${resolveSiteUrl()}${canonicalPath}`
|
|
||||||
const pageTitle = title ? `${title} | OpenCloud` : defaultTitle
|
|
||||||
const pageDescription = description || defaultDescription
|
|
||||||
|
|
||||||
document.title = pageTitle
|
document.title = pageTitle
|
||||||
upsertMeta('description', pageDescription)
|
upsertMeta('description', pageDescription)
|
||||||
upsertMeta('robots', robots)
|
upsertMeta('robots', robots)
|
||||||
upsertCanonical(canonical)
|
upsertCanonical(canonical)
|
||||||
|
|
||||||
upsertPropertyMeta('og:site_name', 'OpenCloud')
|
upsertPropertyMeta('og:site_name', 'OpenCloud')
|
||||||
upsertPropertyMeta('og:type', 'website')
|
upsertPropertyMeta('og:type', 'website')
|
||||||
upsertPropertyMeta('og:title', pageTitle)
|
upsertPropertyMeta('og:title', pageTitle)
|
||||||
upsertPropertyMeta('og:description', pageDescription)
|
upsertPropertyMeta('og:description', pageDescription)
|
||||||
upsertPropertyMeta('og:url', canonical)
|
upsertPropertyMeta('og:url', canonical)
|
||||||
|
|
||||||
upsertMeta('twitter:card', 'summary')
|
upsertMeta('twitter:card', 'summary')
|
||||||
upsertMeta('twitter:title', pageTitle)
|
upsertMeta('twitter:title', pageTitle)
|
||||||
upsertMeta('twitter:description', pageDescription)
|
upsertMeta('twitter:description', pageDescription)
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-52
@@ -2,57 +2,57 @@ import type { GlobalThemeOverrides } from 'naive-ui'
|
|||||||
|
|
||||||
/** Keep these values aligned with the semantic tokens in styles/tokens.css. */
|
/** Keep these values aligned with the semantic tokens in styles/tokens.css. */
|
||||||
export const openCloudThemeOverrides: GlobalThemeOverrides = {
|
export const openCloudThemeOverrides: GlobalThemeOverrides = {
|
||||||
common: {
|
common: {
|
||||||
primaryColor: '#34745d',
|
primaryColor: '#34745d',
|
||||||
primaryColorHover: '#2d5f4e',
|
primaryColorHover: '#2d5f4e',
|
||||||
primaryColorPressed: '#274c40',
|
primaryColorPressed: '#274c40',
|
||||||
primaryColorSuppl: '#34745d',
|
primaryColorSuppl: '#34745d',
|
||||||
infoColor: '#0369a1',
|
infoColor: '#0369a1',
|
||||||
successColor: '#4d7f3b',
|
successColor: '#4d7f3b',
|
||||||
successColorHover: '#3f6633',
|
successColorHover: '#3f6633',
|
||||||
successColorPressed: '#35522c',
|
successColorPressed: '#35522c',
|
||||||
successColorSuppl: '#4d7f3b',
|
successColorSuppl: '#4d7f3b',
|
||||||
warningColor: '#d97706',
|
warningColor: '#d97706',
|
||||||
errorColor: '#dc2626',
|
errorColor: '#dc2626',
|
||||||
borderRadius: '0px',
|
borderRadius: '0px',
|
||||||
borderRadiusSmall: '0px',
|
borderRadiusSmall: '0px',
|
||||||
fontFamily: '"IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif',
|
fontFamily: '"IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif',
|
||||||
fontFamilyMono: '"IBM Plex Mono", "SFMono-Regular", monospace',
|
fontFamilyMono: '"IBM Plex Mono", "SFMono-Regular", monospace',
|
||||||
bodyColor: '#eef4f8',
|
bodyColor: '#f3f7fb',
|
||||||
cardColor: '#ffffff',
|
cardColor: '#ffffff',
|
||||||
modalColor: '#ffffff',
|
modalColor: '#ffffff',
|
||||||
popoverColor: '#ffffff',
|
popoverColor: '#ffffff',
|
||||||
tableColor: '#ffffff',
|
tableColor: '#ffffff',
|
||||||
textColorBase: '#0f172a',
|
textColorBase: '#0f172a',
|
||||||
textColor1: '#0f172a',
|
textColor1: '#0f172a',
|
||||||
textColor2: '#334155',
|
textColor2: '#334155',
|
||||||
textColor3: '#64748b',
|
textColor3: '#64748b',
|
||||||
textColorDisabled: 'var(--oc-color-text-disabled)',
|
textColorDisabled: '#94a3b8',
|
||||||
dividerColor: '#d9e3ee',
|
dividerColor: '#d9e3ee',
|
||||||
borderColor: '#d9e3ee',
|
borderColor: '#d9e3ee',
|
||||||
inputColor: '#ffffff',
|
inputColor: '#ffffff',
|
||||||
|
},
|
||||||
|
Button: {
|
||||||
|
borderRadiusTiny: '0px',
|
||||||
|
borderRadiusSmall: '0px',
|
||||||
|
borderRadiusMedium: '0px',
|
||||||
|
borderRadiusLarge: '0px',
|
||||||
|
textColorPrimary: '#0f172a',
|
||||||
|
textColorHoverPrimary: '#0f172a',
|
||||||
|
},
|
||||||
|
Card: {
|
||||||
|
borderRadius: '0px',
|
||||||
|
borderRadiusEmbedded: '0px',
|
||||||
|
paddingMedium: '24px',
|
||||||
|
},
|
||||||
|
Input: { borderRadius: '0px' },
|
||||||
|
Select: {
|
||||||
|
peers: {
|
||||||
|
InternalSelection: { borderRadius: '0px' },
|
||||||
},
|
},
|
||||||
Button: {
|
},
|
||||||
borderRadiusTiny: '0px',
|
Tabs: { tabBorderRadius: '0px' },
|
||||||
borderRadiusSmall: '0px',
|
Tag: { borderRadius: '0px' },
|
||||||
borderRadiusMedium: '0px',
|
Alert: { borderRadius: '0px' },
|
||||||
borderRadiusLarge: '0px',
|
Modal: { borderRadius: '0px' },
|
||||||
textColorPrimary: '#0f172a',
|
|
||||||
textColorHoverPrimary: '#0f172a',
|
|
||||||
},
|
|
||||||
Card: {
|
|
||||||
borderRadius: '0px',
|
|
||||||
borderRadiusEmbedded: '0px',
|
|
||||||
paddingMedium: '24px',
|
|
||||||
},
|
|
||||||
Input: { borderRadius: '0px' },
|
|
||||||
Select: {
|
|
||||||
peers: {
|
|
||||||
InternalSelection: { borderRadius: '0px' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Tabs: { tabBorderRadius: '0px' },
|
|
||||||
Tag: { borderRadius: '0px' },
|
|
||||||
Alert: { borderRadius: '0px' },
|
|
||||||
Modal: { borderRadius: '0px' },
|
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-3
@@ -2,9 +2,12 @@ import { createApp } from 'vue'
|
|||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth'
|
import { useAuthStore } from './stores/auth'
|
||||||
|
import { enableCanvasReadbackHint } from './lib/canvas'
|
||||||
import './style.css'
|
import './style.css'
|
||||||
|
|
||||||
|
enableCanvasReadbackHint()
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
const pinia = createPinia()
|
const pinia = createPinia()
|
||||||
app.use(pinia)
|
app.use(pinia)
|
||||||
@@ -12,6 +15,6 @@ app.use(pinia)
|
|||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
authStore.initialize().then(() => {
|
authStore.initialize().then(() => {
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
})
|
})
|
||||||
|
|||||||
+153
-162
@@ -1,177 +1,168 @@
|
|||||||
import { createRouter, createWebHistory } from 'vue-router'
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
import { getCloudTypeName } from '@/features/clouds/cloudTypes'
|
import { getCloudTypeName } from '@/lib/cloudTypes'
|
||||||
import { applyRouteSeo } from '@/lib/seo'
|
import { applyRouteSeo } from '@/lib/seo'
|
||||||
import { useAuthStore } from '@/features/auth/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
routes: [
|
routes: [
|
||||||
{
|
{
|
||||||
path: '/',
|
path: '/',
|
||||||
name: 'map',
|
name: 'map',
|
||||||
component: () => import('@/features/map/views/MapView.vue'),
|
component: () => import('@/views/map/MapView.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: '实时云图地图',
|
title: '实时云图地图',
|
||||||
description: '在 OpenCloud 地图上浏览社区拍摄的云图,按位置查看天空观测记录。',
|
description: '在 OpenCloud 地图上浏览社区拍摄的云图,按位置查看天空观测记录。',
|
||||||
},
|
},
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/login',
|
|
||||||
name: 'login',
|
|
||||||
component: () => import('@/features/auth/views/LoginView.vue'),
|
|
||||||
meta: { title: '登录', noindex: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/register',
|
|
||||||
name: 'register',
|
|
||||||
component: () => import('@/features/auth/views/RegisterView.vue'),
|
|
||||||
meta: { title: '注册', noindex: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/resend-confirmation',
|
|
||||||
name: 'resend-confirmation',
|
|
||||||
component: () => import('@/features/auth/views/ResendConfirmationView.vue'),
|
|
||||||
meta: { title: '重发验证邮件', noindex: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/forgot-password',
|
|
||||||
name: 'forgot-password',
|
|
||||||
component: () => import('@/features/auth/views/ForgotPasswordView.vue'),
|
|
||||||
meta: { title: '忘记密码', noindex: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/verify-email',
|
|
||||||
alias: '/auth/confirm',
|
|
||||||
name: 'auth-confirm',
|
|
||||||
component: () => import('@/features/auth/views/AuthConfirmView.vue'),
|
|
||||||
meta: { title: '确认邮箱', noindex: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/reset-password',
|
|
||||||
alias: '/auth/reset-password',
|
|
||||||
name: 'auth-reset-password',
|
|
||||||
component: () => import('@/features/auth/views/ResetPasswordView.vue'),
|
|
||||||
meta: { title: '重置密码', noindex: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/upload',
|
|
||||||
name: 'upload',
|
|
||||||
component: () => import('@/features/upload/views/UploadView.vue'),
|
|
||||||
meta: { requiresAuth: true, title: '上传云图', noindex: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/encyclopedia',
|
|
||||||
name: 'encyclopedia',
|
|
||||||
component: () => import('@/features/encyclopedia/views/EncyclopediaView.vue'),
|
|
||||||
meta: {
|
|
||||||
requiresAuth: true,
|
|
||||||
title: '云朵百科',
|
|
||||||
description: '浏览基础云属,了解云型特征和社区公开观测记录。',
|
|
||||||
noindex: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/encyclopedia/:id',
|
|
||||||
name: 'cloud-type',
|
|
||||||
component: () => import('@/features/encyclopedia/views/CloudTypeView.vue'),
|
|
||||||
meta: {
|
|
||||||
requiresAuth: true,
|
|
||||||
title: route => getCloudTypeName(route.params.id),
|
|
||||||
description: route =>
|
|
||||||
`查看 ${getCloudTypeName(route.params.id)} 的识别要点、公开云图和社区观测记录。`,
|
|
||||||
noindex: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/gallery',
|
|
||||||
name: 'gallery',
|
|
||||||
component: () => import('@/features/gallery/views/GalleryView.vue'),
|
|
||||||
meta: {
|
|
||||||
title: '云图画廊',
|
|
||||||
description: '按时间浏览 OpenCloud 社区公开分享的云图照片和观测记录。',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/community',
|
|
||||||
name: 'community',
|
|
||||||
component: () => import('@/features/community/views/CommunityView.vue'),
|
|
||||||
meta: {
|
|
||||||
title: '社区',
|
|
||||||
description: '查看 OpenCloud 社区动态,发现更多天空观测和云图收藏。',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/profile',
|
|
||||||
name: 'profile',
|
|
||||||
component: () => import('@/features/profile/views/ProfileView.vue'),
|
|
||||||
meta: { requiresAuth: true, title: '个人主页', noindex: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/profile/settings',
|
|
||||||
name: 'profile-settings',
|
|
||||||
component: () => import('@/features/profile/views/ProfileSettingsView.vue'),
|
|
||||||
meta: { requiresAuth: true, title: '账号设置', noindex: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/profile/:id',
|
|
||||||
name: 'user-profile',
|
|
||||||
component: () => import('@/features/profile/views/ProfileView.vue'),
|
|
||||||
meta: {
|
|
||||||
title: '用户云图档案',
|
|
||||||
description: '按用户名查看用户公开分享的云图记录和拍摄时间线。',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/admin',
|
|
||||||
name: 'admin',
|
|
||||||
component: () => import('@/features/admin/views/AdminView.vue'),
|
|
||||||
meta: { requiresAuth: true, requiresAdmin: true, title: '管理后台', noindex: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/403',
|
|
||||||
name: 'forbidden',
|
|
||||||
component: () => import('@/features/system/views/ForbiddenView.vue'),
|
|
||||||
meta: { title: '无权访问', noindex: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/401',
|
|
||||||
name: 'auth-required',
|
|
||||||
component: () => import('@/features/system/views/AuthRequiredView.vue'),
|
|
||||||
meta: { title: '需要登录', noindex: true },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/:pathMatch(.*)*',
|
|
||||||
name: 'not-found',
|
|
||||||
component: () => import('@/features/system/views/NotFoundView.vue'),
|
|
||||||
meta: { title: '页面不存在', noindex: true },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
scrollBehavior() {
|
|
||||||
return { top: 0 }
|
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/login',
|
||||||
|
name: 'login',
|
||||||
|
component: () => import('@/views/auth/LoginView.vue'),
|
||||||
|
meta: { title: '登录', noindex: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/register',
|
||||||
|
name: 'register',
|
||||||
|
component: () => import('@/views/auth/RegisterView.vue'),
|
||||||
|
meta: { title: '注册', noindex: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/forgot-password',
|
||||||
|
name: 'forgot-password',
|
||||||
|
component: () => import('@/views/auth/ForgotPasswordView.vue'),
|
||||||
|
meta: { title: '忘记密码', noindex: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/auth/confirm',
|
||||||
|
name: 'auth-confirm',
|
||||||
|
component: () => import('@/views/auth/AuthConfirmView.vue'),
|
||||||
|
meta: { title: '确认邮箱', noindex: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/auth/reset-password',
|
||||||
|
name: 'auth-reset-password',
|
||||||
|
component: () => import('@/views/auth/ResetPasswordView.vue'),
|
||||||
|
meta: { title: '重置密码', noindex: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/upload',
|
||||||
|
name: 'upload',
|
||||||
|
component: () => import('@/views/upload/UploadView.vue'),
|
||||||
|
meta: { requiresAuth: true, title: '上传云图', noindex: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/encyclopedia',
|
||||||
|
name: 'encyclopedia',
|
||||||
|
component: () => import('@/views/encyclopedia/EncyclopediaView.vue'),
|
||||||
|
meta: {
|
||||||
|
requiresAuth: true,
|
||||||
|
title: '云朵图鉴',
|
||||||
|
description: '浏览 10 种基础云属,了解云型特征,并通过拍摄云图点亮个人图鉴。',
|
||||||
|
noindex: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/encyclopedia/:id',
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/gallery',
|
||||||
|
name: 'gallery',
|
||||||
|
component: () => import('@/views/gallery/GalleryView.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '云图画廊',
|
||||||
|
description: '按时间浏览 OpenCloud 社区公开分享的云图照片和观测记录。',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/community',
|
||||||
|
name: 'community',
|
||||||
|
component: () => import('@/views/community/CommunityView.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '社区',
|
||||||
|
description: '查看 OpenCloud 社区动态,发现更多天空观测和云图收藏。',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/profile',
|
||||||
|
name: 'profile',
|
||||||
|
component: () => import('@/views/profile/ProfileView.vue'),
|
||||||
|
meta: { requiresAuth: true, title: '个人主页', noindex: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/profile/settings',
|
||||||
|
name: 'profile-settings',
|
||||||
|
component: () => import('@/views/profile/ProfileSettingsView.vue'),
|
||||||
|
meta: { requiresAuth: true, title: '账号设置', noindex: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/profile/:id',
|
||||||
|
name: 'user-profile',
|
||||||
|
component: () => import('@/views/profile/ProfileView.vue'),
|
||||||
|
meta: {
|
||||||
|
title: '用户云图档案',
|
||||||
|
description: '查看用户公开分享的云图记录、图鉴进度和拍摄时间线。',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/admin',
|
||||||
|
name: 'admin',
|
||||||
|
component: () => import('@/views/admin/AdminView.vue'),
|
||||||
|
meta: { requiresAuth: true, requiresAdmin: true, title: '管理后台', noindex: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/403',
|
||||||
|
name: 'forbidden',
|
||||||
|
component: () => import('@/views/system/ForbiddenView.vue'),
|
||||||
|
meta: { title: '无权访问', noindex: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/401',
|
||||||
|
name: 'auth-required',
|
||||||
|
component: () => import('@/views/system/AuthRequiredView.vue'),
|
||||||
|
meta: { title: '需要登录', noindex: true },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/:pathMatch(.*)*',
|
||||||
|
name: 'not-found',
|
||||||
|
component: () => import('@/views/system/NotFoundView.vue'),
|
||||||
|
meta: { title: '页面不存在', noindex: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
scrollBehavior() {
|
||||||
|
return { top: 0 }
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
router.beforeEach((to, _from, next) => {
|
router.beforeEach((to, _from, next) => {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
|
if (to.meta.requiresAuth && !authStore.isLoggedIn) {
|
||||||
next({
|
next({
|
||||||
name: 'auth-required',
|
name: 'auth-required',
|
||||||
query: {
|
query: {
|
||||||
redirect: to.fullPath,
|
redirect: to.fullPath,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
} else if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
} else if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
||||||
next({ name: 'forbidden' })
|
next({ name: 'forbidden' })
|
||||||
} else if ((to.name === 'login' || to.name === 'register') && authStore.isLoggedIn) {
|
} else if ((to.name === 'login' || to.name === 'register') && authStore.isLoggedIn) {
|
||||||
next({ name: 'map' })
|
next({ name: 'map' })
|
||||||
} else {
|
} else {
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
router.afterEach(to => {
|
router.afterEach(to => {
|
||||||
applyRouteSeo(to)
|
applyRouteSeo(to)
|
||||||
})
|
})
|
||||||
|
|
||||||
export default router
|
export default router
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import AMapLoader from '@amap/amap-jsapi-loader'
|
|
||||||
|
|
||||||
export function loadAMap() {
|
|
||||||
return AMapLoader.load({
|
|
||||||
key: import.meta.env.VITE_AMAP_KEY,
|
|
||||||
version: '2.0',
|
|
||||||
plugins: [
|
|
||||||
'AMap.Scale',
|
|
||||||
'AMap.ToolBar',
|
|
||||||
'AMap.ControlBar',
|
|
||||||
'AMap.Geolocation',
|
|
||||||
'AMap.Marker',
|
|
||||||
'AMap.InfoWindow',
|
|
||||||
],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,238 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
||||||
import { NButton, NIcon } from 'naive-ui'
|
|
||||||
import { X } from '@vicons/tabler'
|
|
||||||
import { loadAMap } from '@/shared/map/amap'
|
|
||||||
|
|
||||||
const props = withDefaults(
|
|
||||||
defineProps<{
|
|
||||||
open: boolean
|
|
||||||
latitude: number | null
|
|
||||||
longitude: number | null
|
|
||||||
title?: string
|
|
||||||
description?: string
|
|
||||||
footerText?: string
|
|
||||||
}>(),
|
|
||||||
{
|
|
||||||
title: '地图选点',
|
|
||||||
description: '点击地图即可回填经纬度,也可以继续手动修改。',
|
|
||||||
footerText: '建议点选大致拍摄位置,保存时会继续做模糊化处理。',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
close: []
|
|
||||||
confirm: [payload: { latitude: number; longitude: number }]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const loading = ref(false)
|
|
||||||
const errorMsg = ref('')
|
|
||||||
const pickedLat = ref<number | null>(null)
|
|
||||||
const pickedLng = ref<number | null>(null)
|
|
||||||
const mapEl = ref<HTMLDivElement | null>(null)
|
|
||||||
|
|
||||||
let mapPickerAMap: typeof AMap | null = null
|
|
||||||
let mapPickerInstance: AMap.Map | null = null
|
|
||||||
let mapPickerMarker: AMap.Marker | null = null
|
|
||||||
|
|
||||||
function formatCoordinate(value: number | null) {
|
|
||||||
return value === null ? '未选择' : value.toFixed(6)
|
|
||||||
}
|
|
||||||
|
|
||||||
function destroyMapPicker() {
|
|
||||||
mapPickerMarker?.setMap(null)
|
|
||||||
mapPickerMarker = null
|
|
||||||
mapPickerInstance?.destroy()
|
|
||||||
mapPickerInstance = null
|
|
||||||
}
|
|
||||||
|
|
||||||
function placeMarker(longitude: number, latitude: number) {
|
|
||||||
if (!mapPickerAMap || !mapPickerInstance) return
|
|
||||||
|
|
||||||
if (!mapPickerMarker) {
|
|
||||||
mapPickerMarker = new mapPickerAMap.Marker({
|
|
||||||
position: [longitude, latitude],
|
|
||||||
title: '拍摄位置',
|
|
||||||
content:
|
|
||||||
'<div aria-hidden="true" class="h-4 w-4 rounded-full border-2 border-white bg-teal-500 shadow-[0_0_0_4px_rgba(52,116,93,0.24)]"></div>',
|
|
||||||
offset: new mapPickerAMap.Pixel(-8, -8),
|
|
||||||
} as AMap.MarkerOptions)
|
|
||||||
mapPickerMarker.setMap(mapPickerInstance)
|
|
||||||
} else {
|
|
||||||
mapPickerMarker.setPosition([longitude, latitude])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractLngLat(event: unknown) {
|
|
||||||
const payload = event as { lnglat?: { lng?: number; lat?: number } } | undefined
|
|
||||||
const lng = payload?.lnglat?.lng
|
|
||||||
const lat = payload?.lnglat?.lat
|
|
||||||
|
|
||||||
if (typeof lng !== 'number' || typeof lat !== 'number') return null
|
|
||||||
return { lng, lat }
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleMapClick(event: unknown) {
|
|
||||||
const picked = extractLngLat(event)
|
|
||||||
if (!picked) return
|
|
||||||
|
|
||||||
pickedLng.value = picked.lng
|
|
||||||
pickedLat.value = picked.lat
|
|
||||||
placeMarker(picked.lng, picked.lat)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initializeMap() {
|
|
||||||
loading.value = true
|
|
||||||
errorMsg.value = ''
|
|
||||||
pickedLat.value = props.latitude
|
|
||||||
pickedLng.value = props.longitude
|
|
||||||
|
|
||||||
await nextTick()
|
|
||||||
destroyMapPicker()
|
|
||||||
|
|
||||||
try {
|
|
||||||
mapPickerAMap = await loadAMap()
|
|
||||||
mapPickerAMap.getConfig().appname = 'amap-jsapi-skill'
|
|
||||||
|
|
||||||
const hasCoordinates =
|
|
||||||
typeof props.latitude === 'number' && typeof props.longitude === 'number'
|
|
||||||
|
|
||||||
const center: [number, number] = hasCoordinates
|
|
||||||
? [props.longitude as number, props.latitude as number]
|
|
||||||
: [104.07, 30.67]
|
|
||||||
|
|
||||||
mapPickerInstance = new mapPickerAMap.Map(mapEl.value!, {
|
|
||||||
viewMode: '2D',
|
|
||||||
pitch: 0,
|
|
||||||
rotation: 0,
|
|
||||||
zoom: hasCoordinates ? 11 : 4,
|
|
||||||
center,
|
|
||||||
mapStyle: 'amap://styles/normal',
|
|
||||||
resizeEnable: true,
|
|
||||||
} as AMap.MapOptions)
|
|
||||||
|
|
||||||
mapPickerInstance.on('click', handleMapClick)
|
|
||||||
|
|
||||||
if (hasCoordinates) {
|
|
||||||
placeMarker(props.longitude as number, props.latitude as number)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
errorMsg.value = error instanceof Error ? error.message : '地图加载失败'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function close() {
|
|
||||||
emit('close')
|
|
||||||
}
|
|
||||||
|
|
||||||
function confirm() {
|
|
||||||
if (pickedLat.value === null || pickedLng.value === null) return
|
|
||||||
emit('confirm', {
|
|
||||||
latitude: pickedLat.value,
|
|
||||||
longitude: pickedLng.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
() => props.open,
|
|
||||||
open => {
|
|
||||||
if (open) {
|
|
||||||
initializeMap()
|
|
||||||
} else {
|
|
||||||
loading.value = false
|
|
||||||
errorMsg.value = ''
|
|
||||||
destroyMapPicker()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
destroyMapPicker()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<Teleport to="body">
|
|
||||||
<div
|
|
||||||
v-if="open"
|
|
||||||
class="fixed inset-0 z-[140] flex items-center justify-center bg-slate-950/55 px-4 py-6"
|
|
||||||
@click="close"
|
|
||||||
>
|
|
||||||
<div class="oc-modal-shell w-full max-w-3xl overflow-hidden" @click.stop>
|
|
||||||
<div class="flex items-start justify-between border-b border-slate-200 px-6 py-5">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-xl font-semibold text-slate-900">{{ title }}</h3>
|
|
||||||
<p class="oc-field-help">{{ description }}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-icon-button"
|
|
||||||
aria-label="关闭地图选点窗口"
|
|
||||||
title="关闭地图选点窗口"
|
|
||||||
@click="close"
|
|
||||||
>
|
|
||||||
<NIcon size="20"><X /></NIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="px-6 py-5">
|
|
||||||
<div
|
|
||||||
class="mb-4 flex flex-wrap items-center gap-3 border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600"
|
|
||||||
>
|
|
||||||
<span>当前选择:</span>
|
|
||||||
<span class="font-medium text-slate-900"
|
|
||||||
>纬度 {{ formatCoordinate(pickedLat) }}</span
|
|
||||||
>
|
|
||||||
<span class="font-medium text-slate-900"
|
|
||||||
>经度 {{ formatCoordinate(pickedLng) }}</span
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative overflow-hidden border border-slate-200 bg-slate-100">
|
|
||||||
<div ref="mapEl" class="h-[420px] w-full"></div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="loading"
|
|
||||||
class="absolute inset-0 flex items-center justify-center bg-white/80 text-sm font-medium text-slate-600"
|
|
||||||
>
|
|
||||||
正在加载地图...
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="errorMsg"
|
|
||||||
class="absolute inset-0 flex items-center justify-center bg-white/90 px-6 text-center text-sm text-red-600"
|
|
||||||
>
|
|
||||||
{{ errorMsg }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between border-t border-slate-200 px-6 py-4">
|
|
||||||
<p class="text-sm text-slate-500">{{ footerText }}</p>
|
|
||||||
<div class="flex gap-3">
|
|
||||||
<NButton
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
@click="close"
|
|
||||||
>取消</NButton
|
|
||||||
>
|
|
||||||
<NButton
|
|
||||||
type="default"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
class="oc-panel-button oc-panel-button--sky"
|
|
||||||
:disabled="pickedLat === null || pickedLng === null"
|
|
||||||
@click="confirm"
|
|
||||||
>
|
|
||||||
使用这个位置
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
</template>
|
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
|
||||||
import { loadAMap } from '@/shared/map/amap'
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
latitude: number | null
|
|
||||||
longitude: number | null
|
|
||||||
locationName?: string | null
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const mapEl = ref<HTMLDivElement | null>(null)
|
|
||||||
const loading = ref(false)
|
|
||||||
const errorMsg = ref('')
|
|
||||||
|
|
||||||
let amapLib: typeof AMap | null = null
|
|
||||||
let mapInstance: AMap.Map | null = null
|
|
||||||
let marker: AMap.Marker | null = null
|
|
||||||
|
|
||||||
const hasCoordinates = computed(() => {
|
|
||||||
return Number.isFinite(props.latitude) && Number.isFinite(props.longitude)
|
|
||||||
})
|
|
||||||
|
|
||||||
const center = computed<[number, number] | null>(() => {
|
|
||||||
if (!hasCoordinates.value) return null
|
|
||||||
return [props.longitude as number, props.latitude as number]
|
|
||||||
})
|
|
||||||
|
|
||||||
function destroyMap() {
|
|
||||||
marker?.setMap(null)
|
|
||||||
marker = null
|
|
||||||
mapInstance?.destroy()
|
|
||||||
mapInstance = null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function initializeMap() {
|
|
||||||
if (!center.value) {
|
|
||||||
destroyMap()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
|
||||||
errorMsg.value = ''
|
|
||||||
|
|
||||||
await nextTick()
|
|
||||||
destroyMap()
|
|
||||||
|
|
||||||
if (!mapEl.value) {
|
|
||||||
loading.value = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
amapLib = await loadAMap()
|
|
||||||
if ('getConfig' in amapLib) {
|
|
||||||
amapLib.getConfig().appname = 'amap-jsapi-skill'
|
|
||||||
}
|
|
||||||
|
|
||||||
mapInstance = new amapLib.Map(mapEl.value, {
|
|
||||||
viewMode: '2D',
|
|
||||||
zoom: 11,
|
|
||||||
center: center.value,
|
|
||||||
mapStyle: 'amap://styles/normal',
|
|
||||||
features: ['bg', 'road', 'building', 'point'],
|
|
||||||
resizeEnable: true,
|
|
||||||
dragEnable: true,
|
|
||||||
zoomEnable: true,
|
|
||||||
keyboardEnable: false,
|
|
||||||
doubleClickZoom: true,
|
|
||||||
scrollWheel: true,
|
|
||||||
} as AMap.MapOptions)
|
|
||||||
|
|
||||||
mapInstance.addControl(
|
|
||||||
new amapLib.ToolBar({
|
|
||||||
position: 'RT',
|
|
||||||
offset: [8, 8],
|
|
||||||
} as Record<string, unknown>),
|
|
||||||
)
|
|
||||||
|
|
||||||
marker = new amapLib.Marker({
|
|
||||||
position: center.value,
|
|
||||||
title: props.locationName || '拍摄位置',
|
|
||||||
content:
|
|
||||||
'<div class="h-4 w-4 rounded-full border-2 border-white bg-teal-500 shadow-[0_0_0_4px_rgba(52,116,93,0.24)]"></div>',
|
|
||||||
offset: new amapLib.Pixel(-8, -8),
|
|
||||||
} as AMap.MarkerOptions)
|
|
||||||
marker.setMap(mapInstance)
|
|
||||||
} catch (error) {
|
|
||||||
errorMsg.value = error instanceof Error ? error.message : '地图加载失败'
|
|
||||||
} finally {
|
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(
|
|
||||||
center,
|
|
||||||
() => {
|
|
||||||
initializeMap()
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
)
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
destroyMap()
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div v-if="hasCoordinates" class="mt-5 overflow-hidden border border-slate-200 bg-white">
|
|
||||||
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
|
||||||
<div>
|
|
||||||
<p class="oc-section-label">位置地图</p>
|
|
||||||
<p class="mt-1 text-sm font-medium text-slate-900">
|
|
||||||
{{ locationName || '拍摄位置' }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p class="text-right text-[11px] leading-5 text-slate-500">
|
|
||||||
{{ latitude?.toFixed(4) }}<br />
|
|
||||||
{{ longitude?.toFixed(4) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="relative h-40 bg-sky-50">
|
|
||||||
<div ref="mapEl" class="h-full w-full"></div>
|
|
||||||
<div
|
|
||||||
v-if="loading"
|
|
||||||
class="absolute inset-0 flex items-center justify-center bg-white/70 text-sm text-slate-500"
|
|
||||||
>
|
|
||||||
地图加载中...
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="errorMsg"
|
|
||||||
class="absolute inset-0 flex items-center justify-center bg-white/85 px-4 text-center text-sm text-slate-500"
|
|
||||||
>
|
|
||||||
{{ errorMsg }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
Vendored
-126
@@ -1,126 +0,0 @@
|
|||||||
declare namespace AMap {
|
|
||||||
class Map {
|
|
||||||
constructor(container: string | HTMLElement, opts?: MapOptions)
|
|
||||||
addControl(control: unknown): void
|
|
||||||
add(layer: unknown): void
|
|
||||||
remove(layer: unknown): void
|
|
||||||
destroy(): void
|
|
||||||
setCenter(center: [number, number]): void
|
|
||||||
setZoom(zoom: number, immediately?: boolean, duration?: number): void
|
|
||||||
setPitch(pitch: number): void
|
|
||||||
getPitch(): number
|
|
||||||
setRotation(rotation: number): void
|
|
||||||
getRotation(): number
|
|
||||||
on(event: string, callback: (...args: unknown[]) => void): void
|
|
||||||
off(event: string, callback: (...args: unknown[]) => void): void
|
|
||||||
getCenter(): { lng: number; lat: number }
|
|
||||||
getZoom(digits?: number): number
|
|
||||||
getAllOverlays(type?: string): unknown[]
|
|
||||||
addOverlay(overlay: unknown): void
|
|
||||||
removeOverlay(overlay: unknown): void
|
|
||||||
clearMap(): void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MapOptions {
|
|
||||||
viewMode?: '2D' | '3D'
|
|
||||||
pitch?: number
|
|
||||||
rotation?: number
|
|
||||||
zoom?: number
|
|
||||||
zooms?: [number, number]
|
|
||||||
center?: [number, number]
|
|
||||||
mapStyle?: string
|
|
||||||
features?: string[]
|
|
||||||
layers?: unknown[]
|
|
||||||
skyColor?: string
|
|
||||||
maxPitch?: number
|
|
||||||
resizeEnable?: boolean
|
|
||||||
dragEnable?: boolean
|
|
||||||
zoomEnable?: boolean
|
|
||||||
keyboardEnable?: boolean
|
|
||||||
doubleClickZoom?: boolean
|
|
||||||
scrollWheel?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
class Marker {
|
|
||||||
constructor(opts?: MarkerOptions)
|
|
||||||
on(event: string, callback: (...args: unknown[]) => void): void
|
|
||||||
setPosition(position: [number, number]): void
|
|
||||||
getPosition(): { lng: number; lat: number }
|
|
||||||
setContent(content: string): void
|
|
||||||
setOpacity(opacity: number): void
|
|
||||||
setMap(map: Map | null): void
|
|
||||||
remove(): void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MarkerOptions {
|
|
||||||
position?: [number, number]
|
|
||||||
content?: string | HTMLElement
|
|
||||||
offset?: Pixel
|
|
||||||
zIndex?: number
|
|
||||||
opacity?: number
|
|
||||||
title?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
class InfoWindow {
|
|
||||||
constructor(opts?: InfoWindowOptions)
|
|
||||||
open(map: Map, position?: [number, number]): void
|
|
||||||
close(): void
|
|
||||||
}
|
|
||||||
|
|
||||||
interface InfoWindowOptions {
|
|
||||||
content?: string | HTMLElement
|
|
||||||
offset?: Pixel
|
|
||||||
size?: Size
|
|
||||||
isCustom?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
class Scale {}
|
|
||||||
class ToolBar {
|
|
||||||
constructor(opts?: Record<string, unknown>)
|
|
||||||
}
|
|
||||||
class ControlBar {
|
|
||||||
constructor(opts?: Record<string, unknown>)
|
|
||||||
}
|
|
||||||
class Geolocation {
|
|
||||||
constructor(opts?: Record<string, unknown>)
|
|
||||||
getCurrentPosition(
|
|
||||||
onSuccess: (data: { position: { lng: number; lat: number } }) => void,
|
|
||||||
onError?: (error: unknown) => void,
|
|
||||||
): void
|
|
||||||
}
|
|
||||||
|
|
||||||
class TileLayer {
|
|
||||||
static Satellite: new () => TileLayer
|
|
||||||
static RoadNet: new () => TileLayer
|
|
||||||
setOpacity(opacity: number): void
|
|
||||||
}
|
|
||||||
|
|
||||||
class Pixel {
|
|
||||||
constructor(x: number, y: number)
|
|
||||||
}
|
|
||||||
|
|
||||||
class Size {
|
|
||||||
constructor(width: number, height: number)
|
|
||||||
}
|
|
||||||
|
|
||||||
class MarkerCluster {
|
|
||||||
constructor(map: Map, markers: Marker[], opts?: Record<string, unknown>)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
declare function AMapLoader_load(opts: {
|
|
||||||
key: string
|
|
||||||
version: string
|
|
||||||
plugins?: string[]
|
|
||||||
}): Promise<typeof AMap>
|
|
||||||
|
|
||||||
declare namespace AMap {
|
|
||||||
function getConfig(): { appname?: string }
|
|
||||||
}
|
|
||||||
|
|
||||||
declare module '@amap/amap-jsapi-loader' {
|
|
||||||
const AMapLoader: {
|
|
||||||
load: typeof AMapLoader_load
|
|
||||||
}
|
|
||||||
export default AMapLoader
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
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<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')
|
||||||
|
|
||||||
|
function applyAuth(auth: AuthResponse) {
|
||||||
|
setAccessToken(auth.access_token)
|
||||||
|
user.value = auth.user
|
||||||
|
profile.value = auth.profile
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAuth() {
|
||||||
|
clearAccessToken()
|
||||||
|
user.value = null
|
||||||
|
profile.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(email: string, password: string) {
|
||||||
|
const auth = await apiRequest<AuthResponse>('/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: false,
|
||||||
|
body: { email, password },
|
||||||
|
})
|
||||||
|
applyAuth(auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function register(email: string, password: string, username: string) {
|
||||||
|
await apiRequest('/auth/register', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: false,
|
||||||
|
body: { email, password, username },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
try {
|
||||||
|
await apiRequest('/auth/logout', { method: 'POST', retry: false })
|
||||||
|
} finally {
|
||||||
|
clearAuth()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 auth = await apiRequest<AuthResponse>('/auth/password', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { password },
|
||||||
|
})
|
||||||
|
applyAuth(auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendPasswordReset(email: string) {
|
||||||
|
await apiRequest('/auth/forgot-password', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: false,
|
||||||
|
body: { email },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initialize() {
|
||||||
|
if (!listenersAttached) {
|
||||||
|
window.addEventListener(AUTH_UPDATED_EVENT, event => {
|
||||||
|
applyAuth((event as CustomEvent<AuthResponse>).detail)
|
||||||
|
})
|
||||||
|
window.addEventListener(AUTH_EXPIRED_EVENT, clearAuth)
|
||||||
|
listenersAttached = true
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
applyAuth(await refreshSession())
|
||||||
|
} catch {
|
||||||
|
clearAuth()
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
profile,
|
||||||
|
loading,
|
||||||
|
isLoggedIn,
|
||||||
|
isAdmin,
|
||||||
|
login,
|
||||||
|
register,
|
||||||
|
logout,
|
||||||
|
updateUsername,
|
||||||
|
updatePassword,
|
||||||
|
sendPasswordReset,
|
||||||
|
initialize,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { apiRequest } from '@/lib/api'
|
||||||
|
import type { CloudType } from '@/types/database'
|
||||||
|
|
||||||
|
export const useCloudsStore = defineStore('clouds', () => {
|
||||||
|
const cloudTypes = ref<CloudType[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
async function fetchCloudTypes() {
|
||||||
|
if (cloudTypes.value.length > 0) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
cloudTypes.value = await apiRequest<CloudType[]>('/cloud-types', { auth: false })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cloudTypes, loading, fetchCloudTypes }
|
||||||
|
})
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
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 {
|
||||||
|
id: string
|
||||||
|
image_url: string
|
||||||
|
thumbnail_url: string | null
|
||||||
|
captured_at: string | null
|
||||||
|
created_at: string
|
||||||
|
location_name: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectionEntry extends UserCollection {
|
||||||
|
firstCloud: CollectionPreviewCloud | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown) {
|
||||||
|
return error instanceof Error ? error.message : '图鉴收藏加载失败'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useEncyclopediaStore = defineStore('encyclopedia', () => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const cloudTypes = ref<CloudType[]>([])
|
||||||
|
const myCollection = ref<CollectionEntry[]>([])
|
||||||
|
const loadingCloudTypes = ref(false)
|
||||||
|
const loadingCollection = ref(false)
|
||||||
|
const cloudTypesLoaded = ref(false)
|
||||||
|
const collectionLoadedForUserId = ref<string | null>(null)
|
||||||
|
const collectionError = ref('')
|
||||||
|
|
||||||
|
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(() => 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 {
|
||||||
|
cloudTypes.value = await apiRequest<CloudType[]>('/cloud-types', { auth: false })
|
||||||
|
cloudTypesLoaded.value = true
|
||||||
|
} finally {
|
||||||
|
loadingCloudTypes.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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) {
|
||||||
|
myCollection.value = []
|
||||||
|
collectionLoadedForUserId.value = userId
|
||||||
|
collectionError.value = getErrorMessage(error)
|
||||||
|
} finally {
|
||||||
|
loadingCollection.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUnlocked(cloudTypeId: number) {
|
||||||
|
return collectionMap.value.has(cloudTypeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCollectionEntry(cloudTypeId: number) {
|
||||||
|
return collectionMap.value.get(cloudTypeId) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cloudTypes,
|
||||||
|
myCollection,
|
||||||
|
loadingCloudTypes,
|
||||||
|
loadingCollection,
|
||||||
|
collectionError,
|
||||||
|
unlockedCount,
|
||||||
|
unlockProgress,
|
||||||
|
unlockPercent,
|
||||||
|
fetchCloudTypes,
|
||||||
|
fetchMyCollection,
|
||||||
|
isUnlocked,
|
||||||
|
getCollectionEntry,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
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 {
|
||||||
|
id: string
|
||||||
|
cloud_type_id: number | null
|
||||||
|
custom_cloud_type: string | null
|
||||||
|
image_url: string
|
||||||
|
thumbnail_url: string | null
|
||||||
|
latitude: number | null
|
||||||
|
longitude: number | null
|
||||||
|
location_name: string | null
|
||||||
|
description: string | null
|
||||||
|
captured_at: string | null
|
||||||
|
created_at: string
|
||||||
|
status: 'pending' | 'approved' | 'rejected'
|
||||||
|
is_hidden: boolean
|
||||||
|
cloudTypeName: string
|
||||||
|
cloudTypeRarity: CloudType['rarity']
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProfileCloud(row: ApiCloud): ProfileCloudItem {
|
||||||
|
return {
|
||||||
|
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 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>>({})
|
||||||
|
const errorByKey = ref<Record<string, string>>({})
|
||||||
|
|
||||||
|
const makeKey = (userId: string, isOwnProfile: boolean) => `${userId}:${isOwnProfile ? 'own' : 'public'}`
|
||||||
|
|
||||||
|
function getProfile(userId: string | null) {
|
||||||
|
return userId ? profilesById.value[userId] ?? null : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClouds(userId: string | null, isOwnProfile: boolean) {
|
||||||
|
return userId ? cloudsByKey.value[makeKey(userId, isOwnProfile)] ?? [] : []
|
||||||
|
}
|
||||||
|
|
||||||
|
function getError(userId: string | null, isOwnProfile: boolean) {
|
||||||
|
return userId ? errorByKey.value[makeKey(userId, isOwnProfile)] ?? '' : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoaded(userId: string | null, isOwnProfile: boolean) {
|
||||||
|
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) {
|
||||||
|
const key = makeKey(userId, isOwnProfile)
|
||||||
|
if (!force && key in cloudsByKey.value && profilesById.value[userId]) return
|
||||||
|
|
||||||
|
loadingKeys.value[key] = true
|
||||||
|
errorByKey.value[key] = ''
|
||||||
|
try {
|
||||||
|
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] = []
|
||||||
|
} finally {
|
||||||
|
loadingKeys.value[key] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoading(userId: string | null, isOwnProfile: boolean) {
|
||||||
|
return !!userId && !!loadingKeys.value[makeKey(userId, isOwnProfile)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchCachedCloud(userId: string, cloudId: string, patch: Partial<ProfileCloudItem>) {
|
||||||
|
for (const isOwnProfile of [true, false]) {
|
||||||
|
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)
|
||||||
|
.filter(item => isOwnProfile || (item.status === 'approved' && !item.is_hidden))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCachedClouds(userId: string, cloudIds: string[]) {
|
||||||
|
const idSet = new Set(cloudIds)
|
||||||
|
for (const isOwnProfile of [true, false]) {
|
||||||
|
const key = makeKey(userId, isOwnProfile)
|
||||||
|
const current = cloudsByKey.value[key]
|
||||||
|
if (current) cloudsByKey.value[key] = current.filter(item => !idSet.has(item.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidateUser(userId: string) {
|
||||||
|
delete cloudsByKey.value[makeKey(userId, true)]
|
||||||
|
delete cloudsByKey.value[makeKey(userId, false)]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateCloud(userId: string, cloudId: string, patch: {
|
||||||
|
cloud_type_id: number | null
|
||||||
|
custom_cloud_type: string | null
|
||||||
|
latitude: number | null
|
||||||
|
longitude: number | null
|
||||||
|
location_name: string | null
|
||||||
|
description: string | null
|
||||||
|
captured_at: string | null
|
||||||
|
is_hidden: boolean
|
||||||
|
}) {
|
||||||
|
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) {
|
||||||
|
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
|
||||||
|
|
||||||
|
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, cloudIds)
|
||||||
|
return cloudIds.length
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getProfile,
|
||||||
|
getClouds,
|
||||||
|
getError,
|
||||||
|
isLoaded,
|
||||||
|
isLoading,
|
||||||
|
fetchProfilePage,
|
||||||
|
invalidateUser,
|
||||||
|
updateCloud,
|
||||||
|
updateCloudVisibility,
|
||||||
|
deleteClouds,
|
||||||
|
}
|
||||||
|
})
|
||||||
+20
-19
@@ -1,26 +1,27 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#app {
|
#app {
|
||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
color: var(--oc-color-text);
|
color: var(--oc-color-text);
|
||||||
background:
|
background:
|
||||||
linear-gradient(180deg, rgb(15 23 42 / 2%), rgb(15 23 42 / 0%)), var(--oc-color-canvas);
|
linear-gradient(180deg, rgb(15 23 42 / 2%), rgb(15 23 42 / 0%)),
|
||||||
font-family: var(--oc-font-sans);
|
var(--oc-color-canvas);
|
||||||
}
|
font-family: var(--oc-font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
::selection {
|
::selection {
|
||||||
background: var(--oc-color-selection);
|
background: var(--oc-color-selection);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer utilities {
|
@layer utilities {
|
||||||
/* The atlas language is square by default. Documented exceptions may opt out. */
|
/* The atlas language is square by default. Documented exceptions may opt out. */
|
||||||
[class~='rounded'],
|
[class~='rounded'],
|
||||||
[class*='rounded-'] {
|
[class*='rounded-'] {
|
||||||
border-radius: 0 !important;
|
border-radius: 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+543
-602
File diff suppressed because it is too large
Load Diff
+58
-72
@@ -5,87 +5,73 @@
|
|||||||
* values live here so the Vue and Naive UI layers share one visual contract.
|
* values live here so the Vue and Naive UI layers share one visual contract.
|
||||||
*/
|
*/
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Tailwind compatibility palettes.
|
* Tailwind compatibility palettes.
|
||||||
* Existing teal/emerald utilities intentionally resolve to OpenCloud's
|
* Existing teal/emerald utilities intentionally resolve to OpenCloud's
|
||||||
* fresh eucalyptus and leaf colors instead of Tailwind's vivid defaults.
|
* fresh eucalyptus and leaf colors instead of Tailwind's vivid defaults.
|
||||||
*/
|
*/
|
||||||
--color-teal-50: #f2faf6;
|
--color-teal-50: #f2faf6;
|
||||||
--color-teal-100: #ddf2e6;
|
--color-teal-100: #ddf2e6;
|
||||||
--color-teal-200: #bce4cf;
|
--color-teal-200: #bce4cf;
|
||||||
--color-teal-300: #8fceb0;
|
--color-teal-300: #8fceb0;
|
||||||
--color-teal-400: #60b18d;
|
--color-teal-400: #60b18d;
|
||||||
--color-teal-500: #438f71;
|
--color-teal-500: #438f71;
|
||||||
--color-teal-600: #34745d;
|
--color-teal-600: #34745d;
|
||||||
--color-teal-700: #2d5f4e;
|
--color-teal-700: #2d5f4e;
|
||||||
--color-teal-800: #274c40;
|
--color-teal-800: #274c40;
|
||||||
--color-teal-900: #213e35;
|
--color-teal-900: #213e35;
|
||||||
|
|
||||||
--color-emerald-50: #f4faef;
|
--color-emerald-50: #f4faef;
|
||||||
--color-emerald-100: #e5f3d9;
|
--color-emerald-100: #e5f3d9;
|
||||||
--color-emerald-200: #cce7b7;
|
--color-emerald-200: #cce7b7;
|
||||||
--color-emerald-300: #a8d58d;
|
--color-emerald-300: #a8d58d;
|
||||||
--color-emerald-400: #7fbc63;
|
--color-emerald-400: #7fbc63;
|
||||||
--color-emerald-500: #609d48;
|
--color-emerald-500: #609d48;
|
||||||
--color-emerald-600: #4d7f3b;
|
--color-emerald-600: #4d7f3b;
|
||||||
--color-emerald-700: #3f6633;
|
--color-emerald-700: #3f6633;
|
||||||
--color-emerald-800: #35522c;
|
--color-emerald-800: #35522c;
|
||||||
--color-emerald-900: #2d4427;
|
--color-emerald-900: #2d4427;
|
||||||
|
|
||||||
--oc-font-sans: 'IBM Plex Sans', 'Noto Sans SC', 'PingFang SC', sans-serif;
|
--oc-font-sans: 'IBM Plex Sans', 'Noto Sans SC', 'PingFang SC', sans-serif;
|
||||||
--oc-font-mono: 'IBM Plex Mono', 'SFMono-Regular', monospace;
|
--oc-font-mono: 'IBM Plex Mono', 'SFMono-Regular', monospace;
|
||||||
|
|
||||||
--oc-color-canvas: #eef4f8;
|
--oc-color-canvas: #eef4f8;
|
||||||
--oc-color-surface: #ffffff;
|
--oc-color-surface: #ffffff;
|
||||||
--oc-color-surface-muted: #f8fafc;
|
--oc-color-surface-muted: #f8fafc;
|
||||||
--oc-color-text: #0f172a;
|
--oc-color-text: #0f172a;
|
||||||
--oc-color-text-secondary: #334155;
|
--oc-color-text-secondary: #334155;
|
||||||
--oc-color-text-muted: #64748b;
|
--oc-color-text-muted: #64748b;
|
||||||
--oc-color-text-disabled: #94a3b8;
|
--oc-color-border: #d9e3ee;
|
||||||
--oc-color-border: #d9e3ee;
|
--oc-color-primary: #34745d;
|
||||||
--oc-color-primary: #34745d;
|
--oc-color-primary-hover: #2d5f4e;
|
||||||
--oc-color-primary-hover: #2d5f4e;
|
--oc-color-primary-pressed: #274c40;
|
||||||
--oc-color-primary-pressed: #274c40;
|
--oc-color-info: #0369a1;
|
||||||
--oc-color-info: #0369a1;
|
--oc-color-success: #4d7f3b;
|
||||||
--oc-color-success: #4d7f3b;
|
--oc-color-warning: #d97706;
|
||||||
--oc-color-warning: #d97706;
|
--oc-color-danger: #dc2626;
|
||||||
--oc-color-danger: #dc2626;
|
--oc-color-selection: rgb(52 116 93 / 20%);
|
||||||
--oc-color-selection: rgb(52 116 93 / 20%);
|
|
||||||
|
|
||||||
/* Contribution heatmap: no activity + GitHub-style contribution quartiles. */
|
--oc-gradient-canvas:
|
||||||
--oc-heatmap-empty: #f1f5f9;
|
radial-gradient(circle at top left, rgb(52 116 93 / 11%), transparent 28%),
|
||||||
--oc-heatmap-empty-border: #d9e3ee;
|
linear-gradient(180deg, #f8fbfd 0%, #eef4f8 100%);
|
||||||
--oc-heatmap-level-1: #ddf2e6;
|
--oc-gradient-page: linear-gradient(180deg, #e0f2fe 0%, #f8fafc 100%);
|
||||||
--oc-heatmap-level-1-border: #bce4cf;
|
--oc-gradient-page-soft: linear-gradient(180deg, #f2faf6 0%, #f8fafc 100%);
|
||||||
--oc-heatmap-level-2: #8fceb0;
|
--oc-gradient-grid:
|
||||||
--oc-heatmap-level-2-border: #60b18d;
|
linear-gradient(rgb(15 23 42 / 3%) 1px, transparent 1px),
|
||||||
--oc-heatmap-level-3: #438f71;
|
linear-gradient(90deg, rgb(15 23 42 / 3%) 1px, transparent 1px);
|
||||||
--oc-heatmap-level-3-border: #34745d;
|
|
||||||
--oc-heatmap-level-4: #274c40;
|
|
||||||
--oc-heatmap-level-4-border: #213e35;
|
|
||||||
|
|
||||||
--oc-gradient-canvas:
|
--oc-shadow-xs: 3px 3px 0 0 rgb(15 23 42 / 6%);
|
||||||
radial-gradient(circle at top left, rgb(52 116 93 / 11%), transparent 28%),
|
--oc-shadow-sm: 4px 4px 0 0 rgb(15 23 42 / 6%);
|
||||||
linear-gradient(180deg, #f8fbfd 0%, #eef4f8 100%);
|
--oc-shadow-md: 6px 6px 0 0 rgb(15 23 42 / 5%);
|
||||||
--oc-gradient-page: linear-gradient(180deg, #e0f2fe 0%, #f8fafc 100%);
|
--oc-shadow-lg: 8px 8px 0 0 rgb(15 23 42 / 8%);
|
||||||
--oc-gradient-page-soft: linear-gradient(180deg, #f2faf6 0%, #f8fafc 100%);
|
--oc-shadow-xl: 12px 12px 0 0 rgb(15 23 42 / 8%);
|
||||||
--oc-gradient-grid:
|
--oc-shadow-sky: 4px 4px 0 0 rgb(14 165 233 / 14%);
|
||||||
linear-gradient(rgb(15 23 42 / 3%) 1px, transparent 1px),
|
--oc-shadow-primary: 4px 4px 0 0 rgb(52 116 93 / 14%);
|
||||||
linear-gradient(90deg, rgb(15 23 42 / 3%) 1px, transparent 1px);
|
--oc-shadow-primary-hover: 5px 5px 0 0 rgb(52 116 93 / 20%);
|
||||||
|
--oc-shadow-teal: var(--oc-shadow-primary);
|
||||||
|
|
||||||
--oc-shadow-xs: 3px 3px 0 0 rgb(15 23 42 / 6%);
|
--oc-duration-fast: 160ms;
|
||||||
--oc-shadow-sm: 4px 4px 0 0 rgb(15 23 42 / 6%);
|
--oc-ease-standard: ease;
|
||||||
--oc-shadow-md: 6px 6px 0 0 rgb(15 23 42 / 5%);
|
|
||||||
--oc-shadow-lg: 8px 8px 0 0 rgb(15 23 42 / 8%);
|
|
||||||
--oc-shadow-xl: 12px 12px 0 0 rgb(15 23 42 / 8%);
|
|
||||||
--oc-shadow-sky: 4px 4px 0 0 rgb(14 165 233 / 14%);
|
|
||||||
--oc-shadow-primary: 4px 4px 0 0 rgb(52 116 93 / 14%);
|
|
||||||
--oc-shadow-primary-hover: 5px 5px 0 0 rgb(52 116 93 / 20%);
|
|
||||||
--oc-shadow-amber: 5px 5px 0 0 rgb(245 158 11 / 12%);
|
|
||||||
--oc-shadow-teal: var(--oc-shadow-primary);
|
|
||||||
|
|
||||||
--oc-duration-fast: 160ms;
|
|
||||||
--oc-ease-standard: ease;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+126
@@ -0,0 +1,126 @@
|
|||||||
|
declare namespace AMap {
|
||||||
|
class Map {
|
||||||
|
constructor(container: string | HTMLElement, opts?: MapOptions)
|
||||||
|
addControl(control: unknown): void
|
||||||
|
add(layer: unknown): void
|
||||||
|
remove(layer: unknown): void
|
||||||
|
destroy(): void
|
||||||
|
setCenter(center: [number, number]): void
|
||||||
|
setZoom(zoom: number, immediately?: boolean, duration?: number): void
|
||||||
|
setPitch(pitch: number): void
|
||||||
|
getPitch(): number
|
||||||
|
setRotation(rotation: number): void
|
||||||
|
getRotation(): number
|
||||||
|
on(event: string, callback: (...args: unknown[]) => void): void
|
||||||
|
off(event: string, callback: (...args: unknown[]) => void): void
|
||||||
|
getCenter(): { lng: number; lat: number }
|
||||||
|
getZoom(digits?: number): number
|
||||||
|
getAllOverlays(type?: string): unknown[]
|
||||||
|
addOverlay(overlay: unknown): void
|
||||||
|
removeOverlay(overlay: unknown): void
|
||||||
|
clearMap(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MapOptions {
|
||||||
|
viewMode?: '2D' | '3D'
|
||||||
|
pitch?: number
|
||||||
|
rotation?: number
|
||||||
|
zoom?: number
|
||||||
|
zooms?: [number, number]
|
||||||
|
center?: [number, number]
|
||||||
|
mapStyle?: string
|
||||||
|
features?: string[]
|
||||||
|
layers?: unknown[]
|
||||||
|
skyColor?: string
|
||||||
|
maxPitch?: number
|
||||||
|
resizeEnable?: boolean
|
||||||
|
dragEnable?: boolean
|
||||||
|
zoomEnable?: boolean
|
||||||
|
keyboardEnable?: boolean
|
||||||
|
doubleClickZoom?: boolean
|
||||||
|
scrollWheel?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
class Marker {
|
||||||
|
constructor(opts?: MarkerOptions)
|
||||||
|
on(event: string, callback: (...args: unknown[]) => void): void
|
||||||
|
setPosition(position: [number, number]): void
|
||||||
|
getPosition(): { lng: number; lat: number }
|
||||||
|
setContent(content: string): void
|
||||||
|
setOpacity(opacity: number): void
|
||||||
|
setMap(map: Map | null): void
|
||||||
|
remove(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MarkerOptions {
|
||||||
|
position?: [number, number]
|
||||||
|
content?: string | HTMLElement
|
||||||
|
offset?: Pixel
|
||||||
|
zIndex?: number
|
||||||
|
opacity?: number
|
||||||
|
title?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
class InfoWindow {
|
||||||
|
constructor(opts?: InfoWindowOptions)
|
||||||
|
open(map: Map, position?: [number, number]): void
|
||||||
|
close(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InfoWindowOptions {
|
||||||
|
content?: string | HTMLElement
|
||||||
|
offset?: Pixel
|
||||||
|
size?: Size
|
||||||
|
isCustom?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
class Scale {}
|
||||||
|
class ToolBar {
|
||||||
|
constructor(opts?: Record<string, unknown>)
|
||||||
|
}
|
||||||
|
class ControlBar {
|
||||||
|
constructor(opts?: Record<string, unknown>)
|
||||||
|
}
|
||||||
|
class Geolocation {
|
||||||
|
constructor(opts?: Record<string, unknown>)
|
||||||
|
getCurrentPosition(
|
||||||
|
onSuccess: (data: { position: { lng: number; lat: number } }) => void,
|
||||||
|
onError?: (error: unknown) => void,
|
||||||
|
): void
|
||||||
|
}
|
||||||
|
|
||||||
|
class TileLayer {
|
||||||
|
static Satellite: new () => TileLayer
|
||||||
|
static RoadNet: new () => TileLayer
|
||||||
|
setOpacity(opacity: number): void
|
||||||
|
}
|
||||||
|
|
||||||
|
class Pixel {
|
||||||
|
constructor(x: number, y: number)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Size {
|
||||||
|
constructor(width: number, height: number)
|
||||||
|
}
|
||||||
|
|
||||||
|
class MarkerCluster {
|
||||||
|
constructor(map: Map, markers: Marker[], opts?: Record<string, unknown>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare function AMapLoader_load(opts: {
|
||||||
|
key: string
|
||||||
|
version: string
|
||||||
|
plugins?: string[]
|
||||||
|
}): Promise<typeof AMap>
|
||||||
|
|
||||||
|
declare namespace AMap {
|
||||||
|
function getConfig(): { appname?: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module '@amap/amap-jsapi-loader' {
|
||||||
|
const AMapLoader: {
|
||||||
|
load: typeof AMapLoader_load
|
||||||
|
}
|
||||||
|
export default AMapLoader
|
||||||
|
}
|
||||||
+81
-82
@@ -1,94 +1,93 @@
|
|||||||
import type { Cloud, CloudType, Profile } from '@/types/database'
|
import type { Cloud, CloudType, Profile, UserCollection } from '@/types/database'
|
||||||
|
|
||||||
export interface BackendUserProfile {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
email: string
|
|
||||||
image: string | null
|
|
||||||
cloud_count: number
|
|
||||||
received_like_count: number
|
|
||||||
last_online: string | null
|
|
||||||
role: Profile['role']
|
|
||||||
is_disabled: boolean
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BackendCloudType {
|
|
||||||
id: number
|
|
||||||
name: string
|
|
||||||
genus: string | null
|
|
||||||
icon_id: string | null
|
|
||||||
rarity: number
|
|
||||||
description: string | null
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BackendCloudOwner {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BackendCloudInfo {
|
|
||||||
id: string
|
|
||||||
owner: BackendCloudOwner
|
|
||||||
type: BackendCloudType
|
|
||||||
latitude: number
|
|
||||||
longitude: number
|
|
||||||
description: string | null
|
|
||||||
captured_at: string | null
|
|
||||||
uploaded_at: string
|
|
||||||
updated_at: string
|
|
||||||
received_like_count: number
|
|
||||||
status: Cloud['status']
|
|
||||||
is_hidden: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ActionResponse {
|
|
||||||
status_code: number
|
|
||||||
message: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CloudCreateResponse extends ActionResponse {
|
|
||||||
cloud_id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CloudTypeCreateResponse extends ActionResponse {
|
|
||||||
cloud_type_id: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// Internal view model. The backend profile is normalized here so existing
|
|
||||||
// layout/admin consumers do not need to understand the wire representation.
|
|
||||||
export interface AuthUser {
|
export interface AuthUser {
|
||||||
id: string
|
id: string
|
||||||
email: string
|
email: string
|
||||||
username: string
|
email_verified: boolean
|
||||||
image: string | null
|
created_at: string
|
||||||
role: Profile['role']
|
}
|
||||||
is_disabled: boolean
|
|
||||||
cloud_count: number
|
export interface AuthResponse {
|
||||||
received_like_count: number
|
access_token: string
|
||||||
created_at: 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 {
|
export interface ApiCloudTypeSummary {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
rarity: CloudType['rarity']
|
name_en: string
|
||||||
rarity_value: number
|
rarity: CloudType['rarity']
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiCloudOwner {
|
export interface ApiCloudOwner {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiCloud extends Cloud {
|
export interface ApiCloud extends Cloud {
|
||||||
updated_at: string
|
updated_at: string
|
||||||
cloud_type: ApiCloudTypeSummary | null
|
cloud_type: ApiCloudTypeSummary | null
|
||||||
owner: ApiCloudOwner | null
|
owner: ApiCloudOwner | null
|
||||||
cloud_type_name: string | null
|
cloud_type_name: string | null
|
||||||
cloud_type_rarity: CloudType['rarity'] | null
|
cloud_type_rarity: CloudType['rarity'] | null
|
||||||
cloud_type_rarity_value: number | null
|
username: string | null
|
||||||
username: string | null
|
}
|
||||||
received_like_count: number
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+44
-26
@@ -1,34 +1,52 @@
|
|||||||
export interface CloudType {
|
export interface CloudType {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
genus: string
|
name_en: string
|
||||||
icon_id: string | null
|
genus: string
|
||||||
rarity: 'common' | 'uncommon' | 'rare'
|
rarity: 'common' | 'uncommon' | 'rare'
|
||||||
rarity_value: number
|
description: string | null
|
||||||
description: string | null
|
icon_url: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Cloud {
|
export interface Cloud {
|
||||||
id: string
|
id: string
|
||||||
user_id: string
|
user_id: string
|
||||||
cloud_type_id: number | null
|
cloud_type_id: number | null
|
||||||
image_url: string
|
custom_cloud_type: string | null
|
||||||
thumbnail_url: string | null
|
image_url: string
|
||||||
latitude: number | null
|
thumbnail_url: string | null
|
||||||
longitude: number | null
|
latitude: number | null
|
||||||
description: string | null
|
longitude: number | null
|
||||||
captured_at: string | null
|
location_name: string | null
|
||||||
status: 'pending' | 'approved' | 'rejected'
|
description: string | null
|
||||||
is_hidden: boolean
|
captured_at: string | null
|
||||||
created_at: string
|
status: 'pending' | 'approved' | 'rejected'
|
||||||
|
is_hidden: boolean
|
||||||
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Profile {
|
export interface Profile {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
image: string | null
|
avatar_url: string | null
|
||||||
role: 'user' | 'admin'
|
role: 'user' | 'admin'
|
||||||
is_disabled: boolean
|
is_disabled: boolean
|
||||||
created_at: string
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserCollection {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
cloud_type_id: number
|
||||||
|
first_cloud_id: string | null
|
||||||
|
unlocked_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Reaction {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
cloud_id: string
|
||||||
|
emoji: string
|
||||||
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+8
-8
@@ -1,12 +1,12 @@
|
|||||||
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
import type { RouteLocationNormalizedLoaded } from 'vue-router'
|
||||||
|
|
||||||
declare module 'vue-router' {
|
declare module 'vue-router' {
|
||||||
interface RouteMeta {
|
interface RouteMeta {
|
||||||
title?: string | ((route: RouteLocationNormalizedLoaded) => string)
|
title?: string | ((route: RouteLocationNormalizedLoaded) => string)
|
||||||
description?: string | ((route: RouteLocationNormalizedLoaded) => string)
|
description?: string | ((route: RouteLocationNormalizedLoaded) => string)
|
||||||
canonicalPath?: string
|
canonicalPath?: string
|
||||||
noindex?: boolean
|
noindex?: boolean
|
||||||
requiresAuth?: boolean
|
requiresAuth?: boolean
|
||||||
requiresAdmin?: boolean
|
requiresAdmin?: boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { NButton, NCard, NResult, NSpin } from 'naive-ui'
|
||||||
|
import { apiRequest } from '@/lib/api'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const state = ref<'loading' | 'success' | 'failed'>('loading')
|
||||||
|
const countdown = ref(5)
|
||||||
|
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
const confirmationToken = typeof route.query.token === 'string' ? route.query.token : ''
|
||||||
|
|
||||||
|
if (route.query.token !== undefined) {
|
||||||
|
window.history.replaceState(window.history.state, '', window.location.pathname)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdown() {
|
||||||
|
countdownTimer = setInterval(() => {
|
||||||
|
countdown.value--
|
||||||
|
if (countdown.value <= 0) {
|
||||||
|
if (countdownTimer) clearInterval(countdownTimer)
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
try {
|
||||||
|
if (!confirmationToken) {
|
||||||
|
state.value = 'failed'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiRequest('/auth/confirm-email', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: false,
|
||||||
|
body: { token: confirmationToken },
|
||||||
|
})
|
||||||
|
state.value = 'success'
|
||||||
|
startCountdown()
|
||||||
|
} catch {
|
||||||
|
state.value = 'failed'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (countdownTimer) clearInterval(countdownTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||||
|
<div class="mx-auto max-w-3xl">
|
||||||
|
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||||
|
<template v-if="state === 'success'">
|
||||||
|
<NResult status="success" title="邮箱认证成功" description="你的邮箱已确认,现在可以登录了。">
|
||||||
|
<template #footer>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动跳转登录页面...</p>
|
||||||
|
<RouterLink to="/login" class="no-underline">
|
||||||
|
<NButton type="primary" class="oc-primary-button oc-primary-button--teal">立即登录</NButton>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NResult>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="state === 'failed'">
|
||||||
|
<NResult status="error" title="认证失败" description="邮箱确认链接无效或已过期,请重新注册。">
|
||||||
|
<template #footer>
|
||||||
|
<RouterLink to="/register" class="no-underline">
|
||||||
|
<NButton type="primary" class="oc-primary-button oc-primary-button--sky">重新注册</NButton>
|
||||||
|
</RouterLink>
|
||||||
|
</template>
|
||||||
|
</NResult>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<NSpin size="large" />
|
||||||
|
<h1 class="mt-6 text-2xl font-bold text-slate-900">正在验证...</h1>
|
||||||
|
<p class="mt-2 text-slate-500">请稍候</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onUnmounted, ref } from 'vue'
|
||||||
|
import { useRoute } from 'vue-router'
|
||||||
|
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitted = ref(false)
|
||||||
|
const cooldown = ref(0)
|
||||||
|
let cooldownTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
const resendButtonLabel = computed(() => {
|
||||||
|
if (loading.value) return '发送中...'
|
||||||
|
if (cooldown.value > 0) return `重新发送(${cooldown.value}s)`
|
||||||
|
return '重新发送'
|
||||||
|
})
|
||||||
|
|
||||||
|
function clearCooldownTimer() {
|
||||||
|
if (!cooldownTimer) return
|
||||||
|
clearInterval(cooldownTimer)
|
||||||
|
cooldownTimer = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCooldown() {
|
||||||
|
clearCooldownTimer()
|
||||||
|
cooldown.value = 60
|
||||||
|
cooldownTimer = setInterval(() => {
|
||||||
|
cooldown.value--
|
||||||
|
if (cooldown.value <= 0) {
|
||||||
|
cooldown.value = 0
|
||||||
|
clearCooldownTimer()
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSendResetEmail() {
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
if (!email.value.trim()) {
|
||||||
|
error.value = '请输入需要找回密码的邮箱。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await authStore.sendPasswordReset(email.value.trim())
|
||||||
|
submitted.value = true
|
||||||
|
startCooldown()
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : '重置邮件发送失败,请稍后重试。'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resendResetEmail() {
|
||||||
|
if (cooldown.value > 0 || loading.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleSendResetEmail()
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearCooldownTimer()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||||
|
<div class="mx-auto grid min-w-0 max-w-6xl gap-8 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
|
||||||
|
<section class="min-w-0 overflow-hidden border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_52%,#f2faf6_100%)] p-6 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)] sm:p-8">
|
||||||
|
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Password Recovery</p>
|
||||||
|
<h1 class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl">
|
||||||
|
重新拿回你的
|
||||||
|
<span class="block text-sky-700">OpenCloud 账号</span>
|
||||||
|
</h1>
|
||||||
|
<p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
|
||||||
|
输入注册邮箱后,我们会发送一封密码重置邮件。打开邮件中的链接,即可进入新的密码设置页面。
|
||||||
|
</p>
|
||||||
|
<div class="mt-8 grid gap-4 sm:grid-cols-2">
|
||||||
|
<div class="border border-slate-200 bg-white px-4 py-3">
|
||||||
|
<div class="oc-stat-label">Step 1</div>
|
||||||
|
<div class="mt-2 text-lg font-bold text-slate-900">发送邮件</div>
|
||||||
|
<div class="oc-field-help">使用注册邮箱请求重置链接</div>
|
||||||
|
</div>
|
||||||
|
<div class="border border-slate-200 bg-white px-4 py-3">
|
||||||
|
<div class="oc-stat-label">Step 2</div>
|
||||||
|
<div class="mt-2 text-lg font-bold text-slate-900">设置新密码</div>
|
||||||
|
<div class="oc-field-help">在专用页面完成新密码更新</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<NCard class="min-w-0 shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||||
|
<NResult
|
||||||
|
v-if="submitted"
|
||||||
|
status="success"
|
||||||
|
title="重置邮件已发送"
|
||||||
|
description="如果该邮箱已注册,请查收邮件中的重置链接。"
|
||||||
|
>
|
||||||
|
<template #footer>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-sm text-slate-500">
|
||||||
|
目标邮箱:
|
||||||
|
<span class="font-semibold text-slate-700">{{ email }}</span>
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap justify-center gap-3">
|
||||||
|
<NButton
|
||||||
|
type="primary"
|
||||||
|
class="oc-primary-button oc-primary-button--teal"
|
||||||
|
:disabled="cooldown > 0"
|
||||||
|
:loading="loading"
|
||||||
|
@click="resendResetEmail"
|
||||||
|
>
|
||||||
|
{{ resendButtonLabel }}
|
||||||
|
</NButton>
|
||||||
|
<RouterLink :to="{ path: '/login', query: { email } }" class="no-underline">
|
||||||
|
<NButton type="default" class="oc-panel-button oc-panel-button--neutral">返回登录</NButton>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NResult>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Reset Request</div>
|
||||||
|
<h2 class="mt-3 text-3xl font-bold text-slate-900">忘记密码</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">输入你的注册邮箱,我们会发送一封密码重置邮件。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NForm @submit.prevent="handleSendResetEmail">
|
||||||
|
<NFormItem label="邮箱">
|
||||||
|
<NInput
|
||||||
|
v-model:value="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NAlert v-if="error" type="error" class="mb-4">
|
||||||
|
{{ error }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<NButton
|
||||||
|
attr-type="submit"
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
class="oc-primary-button oc-primary-button--sky"
|
||||||
|
:loading="loading"
|
||||||
|
>
|
||||||
|
{{ loading ? '发送中...' : '发送重置邮件' }}
|
||||||
|
</NButton>
|
||||||
|
</NForm>
|
||||||
|
|
||||||
|
<div class="mt-6 flex flex-wrap items-center justify-between gap-3 text-sm text-slate-500">
|
||||||
|
<p>如果你已经记起密码,可以直接返回登录。</p>
|
||||||
|
<RouterLink :to="{ path: '/login', query: { email } }" class="font-semibold text-sky-700 hover:text-sky-800">
|
||||||
|
返回登录
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
|
import { NAlert, NButton, NCard, NForm, NFormItem, NInput } from 'naive-ui'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
|
||||||
|
const password = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
function getSafeRedirect(value: unknown) {
|
||||||
|
if (typeof value !== 'string' || !value.startsWith('/')) return '/'
|
||||||
|
|
||||||
|
try {
|
||||||
|
const target = new URL(value, window.location.origin)
|
||||||
|
return target.origin === window.location.origin
|
||||||
|
? `${target.pathname}${target.search}${target.hash}`
|
||||||
|
: '/'
|
||||||
|
} catch {
|
||||||
|
return '/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
error.value = ''
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await authStore.login(email.value, password.value)
|
||||||
|
void router.push(getSafeRedirect(route.query.redirect))
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : '登录失败,请稍后重试'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||||
|
<div class="mx-auto grid min-w-0 max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-stretch">
|
||||||
|
<section class="flex min-w-0 h-full flex-col justify-between overflow-hidden border border-slate-200 bg-[linear-gradient(135deg,#f2faf6_0%,#ffffff_48%,#eff6ff_100%)] p-6 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)] sm:p-8">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm uppercase tracking-[0.26em] text-teal-700">Sky Log In</p>
|
||||||
|
<h1 class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl">
|
||||||
|
登录后继续记录
|
||||||
|
<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 grid min-w-0 grid-cols-2 gap-4">
|
||||||
|
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
||||||
|
<div class="oc-stat-label">Collection</div>
|
||||||
|
<div class="oc-stat-value">10</div>
|
||||||
|
<div class="oc-field-help">基础云属待收集</div>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
||||||
|
<div class="oc-stat-label">Mode</div>
|
||||||
|
<div class="oc-stat-value">Atlas</div>
|
||||||
|
<div class="oc-field-help">地图、图鉴、画廊一体</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<NCard class="min-w-0 h-full shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Member Access</div>
|
||||||
|
<h2 class="mt-3 text-3xl font-bold text-slate-900">登录 OpenCloud</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">输入邮箱和密码,继续你的天空档案。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NForm @submit.prevent="handleLogin">
|
||||||
|
<NFormItem label="邮箱">
|
||||||
|
<NInput
|
||||||
|
v-model:value="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem label="密码">
|
||||||
|
<NInput
|
||||||
|
v-model:value="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
show-password-on="click"
|
||||||
|
autocomplete="current-password"
|
||||||
|
placeholder="输入密码"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NAlert v-if="error" type="error" class="mb-4">
|
||||||
|
{{ error }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<NButton
|
||||||
|
attr-type="submit"
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
class="oc-primary-button oc-primary-button--teal"
|
||||||
|
:loading="loading"
|
||||||
|
>
|
||||||
|
{{ loading ? '登录中...' : '登录' }}
|
||||||
|
</NButton>
|
||||||
|
</NForm>
|
||||||
|
|
||||||
|
<div class="mt-6 flex flex-wrap items-center justify-between gap-3 text-sm text-slate-500">
|
||||||
|
<p>
|
||||||
|
没有账号?
|
||||||
|
<RouterLink to="/register" class="font-semibold text-teal-700 hover:text-teal-800">去注册</RouterLink>
|
||||||
|
</p>
|
||||||
|
<RouterLink
|
||||||
|
:to="email ? { path: '/forgot-password', query: { email } } : { path: '/forgot-password' }"
|
||||||
|
class="font-semibold text-teal-700 transition-colors hover:text-teal-800"
|
||||||
|
>
|
||||||
|
忘记密码?
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const username = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const emailSent = ref(false)
|
||||||
|
|
||||||
|
async function handleRegister() {
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
if (!username.value.trim()) {
|
||||||
|
error.value = '请输入用户名'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (username.value.length > 20) {
|
||||||
|
error.value = '用户名不能超过20个字符'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.value.length < 8) {
|
||||||
|
error.value = '密码至少8位'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.value !== confirmPassword.value) {
|
||||||
|
error.value = '两次密码不一致'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await authStore.register(email.value, password.value, username.value.trim())
|
||||||
|
emailSent.value = true
|
||||||
|
} catch (e: unknown) {
|
||||||
|
error.value = e instanceof Error ? e.message : '注册失败,请稍后重试'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||||
|
<div class="mx-auto grid min-w-0 max-w-6xl gap-8 lg:grid-cols-[1.1fr_0.9fr] lg:items-stretch">
|
||||||
|
<section class="flex min-w-0 h-full flex-col justify-between overflow-hidden border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_42%,#f2faf6_100%)] p-6 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)] sm:p-8">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Observer Join</p>
|
||||||
|
<h1 class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl">
|
||||||
|
加入天空探索者
|
||||||
|
<span class="block text-sky-700">建立你的云图档案</span>
|
||||||
|
</h1>
|
||||||
|
<!-- <p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
|
||||||
|
注册后即可上传云图、点亮图鉴、在社区画廊里按时间展示你的观测记录。所有页面都会围绕你的个人云层档案同步更新。
|
||||||
|
</p> -->
|
||||||
|
</div>
|
||||||
|
<div class="mt-8 grid min-w-0 grid-cols-2 gap-4">
|
||||||
|
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
||||||
|
<div class="oc-stat-label">Review</div>
|
||||||
|
<div class="oc-stat-value">Queue</div>
|
||||||
|
<div class="oc-field-help">上传后进入审核队列</div>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
||||||
|
<div class="oc-stat-label">Profile</div>
|
||||||
|
<div class="oc-stat-value">Journal</div>
|
||||||
|
<div class="oc-field-help">自动生成个人天空日志</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<NCard class="min-w-0 h-full shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||||
|
<NResult
|
||||||
|
v-if="emailSent"
|
||||||
|
status="success"
|
||||||
|
title="确认你的邮箱"
|
||||||
|
description="确认邮件已经发送,请查收并点击链接完成注册。"
|
||||||
|
>
|
||||||
|
<template #footer>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<p class="text-sm text-slate-500">
|
||||||
|
目标邮箱:
|
||||||
|
<span class="font-semibold text-slate-700">{{ email }}</span>
|
||||||
|
</p>
|
||||||
|
<RouterLink to="/login" class="no-underline">
|
||||||
|
<NButton type="primary" class="oc-primary-button oc-primary-button--teal">去登录</NButton>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NResult>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Create Account</div>
|
||||||
|
<h2 class="mt-3 text-3xl font-bold text-slate-900">注册 OpenCloud</h2>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">加入社区后即可开始记录、收集与展示云图。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NForm @submit.prevent="handleRegister">
|
||||||
|
<NFormItem label="用户名">
|
||||||
|
<NInput
|
||||||
|
v-model:value="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autocomplete="username"
|
||||||
|
placeholder="你的昵称"
|
||||||
|
maxlength="20"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem label="邮箱">
|
||||||
|
<NInput
|
||||||
|
v-model:value="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem label="密码">
|
||||||
|
<NInput
|
||||||
|
v-model:value="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
show-password-on="click"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="至少8位"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem label="确认密码">
|
||||||
|
<NInput
|
||||||
|
v-model:value="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
show-password-on="click"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="再次输入密码"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NAlert v-if="error" type="error" class="mb-4">
|
||||||
|
{{ error }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<NButton
|
||||||
|
attr-type="submit"
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
class="oc-primary-button oc-primary-button--sky"
|
||||||
|
:loading="loading"
|
||||||
|
>
|
||||||
|
{{ loading ? '注册中...' : '注册' }}
|
||||||
|
</NButton>
|
||||||
|
</NForm>
|
||||||
|
|
||||||
|
<p class="mt-6 text-sm text-slate-500">
|
||||||
|
已有账号?
|
||||||
|
<RouterLink to="/login" class="font-semibold text-sky-700 hover:text-sky-800">去登录</RouterLink>
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult, NSpin } from 'naive-ui'
|
||||||
|
import { apiRequest } from '@/lib/api'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const password = ref('')
|
||||||
|
const confirmPassword = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const state = ref<'checking' | 'ready' | 'invalid' | 'success'>('checking')
|
||||||
|
const loading = ref(false)
|
||||||
|
const countdown = ref(5)
|
||||||
|
const initialResetToken = typeof route.query.token === 'string' ? route.query.token : ''
|
||||||
|
const resetToken = ref(initialResetToken)
|
||||||
|
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
|
if (route.query.token !== undefined) {
|
||||||
|
window.history.replaceState(window.history.state, '', window.location.pathname)
|
||||||
|
}
|
||||||
|
|
||||||
|
const canSubmit = computed(() =>
|
||||||
|
state.value === 'ready' && password.value.length >= 8 && password.value === confirmPassword.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
function showInvalidRecoveryLink() {
|
||||||
|
error.value = '密码重置链接无效或已过期,请重新发送邮件。'
|
||||||
|
state.value = 'invalid'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResetErrorMessage(value: unknown) {
|
||||||
|
const message = value instanceof Error ? value.message : ''
|
||||||
|
if (message.includes('无效') || message.includes('过期') || message.includes('expired')) {
|
||||||
|
return '密码重置链接无效或已过期,请重新发送邮件。'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.includes('8')) {
|
||||||
|
return '新密码至少需要 8 位。'
|
||||||
|
}
|
||||||
|
|
||||||
|
return message || '密码重置失败,请稍后重试。'
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdown() {
|
||||||
|
countdownTimer = setInterval(() => {
|
||||||
|
countdown.value--
|
||||||
|
if (countdown.value <= 0) {
|
||||||
|
if (countdownTimer) clearInterval(countdownTimer)
|
||||||
|
router.push('/login')
|
||||||
|
}
|
||||||
|
}, 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initializeRecoverySession() {
|
||||||
|
if (!resetToken.value) {
|
||||||
|
showInvalidRecoveryLink()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.value = 'ready'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResetPassword() {
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
if (password.value.length < 8) {
|
||||||
|
error.value = '新密码至少需要 8 位。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (password.value !== confirmPassword.value) {
|
||||||
|
error.value = '两次输入的密码不一致。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await apiRequest('/auth/reset-password', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: false,
|
||||||
|
body: { token: resetToken.value, password: password.value },
|
||||||
|
})
|
||||||
|
countdown.value = 5
|
||||||
|
state.value = 'success'
|
||||||
|
startCountdown()
|
||||||
|
} catch (e) {
|
||||||
|
error.value = getResetErrorMessage(e)
|
||||||
|
if (error.value === '密码重置链接无效或已过期,请重新发送邮件。') {
|
||||||
|
state.value = 'invalid'
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void initializeRecoverySession()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (countdownTimer) clearInterval(countdownTimer)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||||
|
<div class="mx-auto max-w-2xl">
|
||||||
|
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||||
|
<NResult
|
||||||
|
v-if="state === 'success'"
|
||||||
|
status="success"
|
||||||
|
title="密码已重置"
|
||||||
|
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('/login')">立即登录</NButton>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</NResult>
|
||||||
|
|
||||||
|
<template v-else-if="state === 'checking'">
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<NSpin size="large" />
|
||||||
|
<h1 class="mt-6 text-2xl font-bold text-slate-900">正在验证重置链接...</h1>
|
||||||
|
<p class="mt-2 text-slate-500">请稍候</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Password Recovery</div>
|
||||||
|
<h1 class="mt-3 text-3xl font-bold text-slate-900">设置新密码</h1>
|
||||||
|
<p class="mt-2 text-sm text-slate-500">
|
||||||
|
{{
|
||||||
|
state === 'invalid'
|
||||||
|
? '当前重置链接不可用,请重新发送密码重置邮件。'
|
||||||
|
: '请输入新的登录密码,提交后当前重置链接会失效。'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NForm @submit.prevent="handleResetPassword">
|
||||||
|
<NFormItem label="新密码">
|
||||||
|
<NInput
|
||||||
|
v-model:value="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
show-password-on="click"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="至少 8 位"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NFormItem label="确认新密码">
|
||||||
|
<NInput
|
||||||
|
v-model:value="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
show-password-on="click"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="再次输入新密码"
|
||||||
|
/>
|
||||||
|
</NFormItem>
|
||||||
|
|
||||||
|
<NAlert v-if="error" type="error" class="mb-4">
|
||||||
|
{{ error }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<RouterLink
|
||||||
|
v-if="state === 'invalid'"
|
||||||
|
to="/forgot-password"
|
||||||
|
class="mb-4 block text-sm font-semibold text-teal-700 hover:text-teal-800"
|
||||||
|
>
|
||||||
|
返回重新发送重置邮件
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<NButton
|
||||||
|
attr-type="submit"
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
class="oc-primary-button oc-primary-button--teal"
|
||||||
|
:disabled="!canSubmit"
|
||||||
|
:loading="loading"
|
||||||
|
>
|
||||||
|
保存新密码
|
||||||
|
</NButton>
|
||||||
|
</NForm>
|
||||||
|
</template>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#f2faf6_0%,#f8fafc_100%)] px-4 py-16">
|
||||||
|
<section class="mx-auto max-w-4xl border border-slate-200 bg-white p-10 shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||||
|
<p class="text-sm font-semibold uppercase tracking-[0.24em] text-teal-700">Community</p>
|
||||||
|
<h1 class="mt-4 text-4xl font-bold text-slate-900">社区</h1>
|
||||||
|
<p class="mt-5 max-w-2xl text-sm leading-7 text-slate-600">
|
||||||
|
社区模块正在规划中。后续这里会承载话题讨论、观测活动和用户互动内容。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
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 { 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 {
|
||||||
|
id: string
|
||||||
|
image_url: string
|
||||||
|
thumbnail_url: string | null
|
||||||
|
latitude: number | null
|
||||||
|
longitude: number | null
|
||||||
|
location_name: string | null
|
||||||
|
description: string | null
|
||||||
|
captured_at: string | null
|
||||||
|
created_at: string
|
||||||
|
profiles: {
|
||||||
|
username: string
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const encyclopediaStore = useEncyclopediaStore()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const loadError = ref('')
|
||||||
|
const gallery = ref<CloudGalleryItem[]>([])
|
||||||
|
const selectedGalleryItem = ref<CloudGalleryItem | null>(null)
|
||||||
|
const publicCount = ref(0)
|
||||||
|
|
||||||
|
const rarityMeta = {
|
||||||
|
common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200', glow: 'from-sky-100 via-white to-cyan-50' },
|
||||||
|
uncommon: { label: '少见', chip: 'bg-amber-100 text-amber-700 border-amber-200', glow: 'from-amber-100 via-white to-orange-50' },
|
||||||
|
rare: { label: '罕见', chip: 'bg-rose-100 text-rose-700 border-rose-200', glow: 'from-rose-100 via-white to-orange-50' },
|
||||||
|
} satisfies Record<CloudType['rarity'], { label: string; chip: string; glow: string }>
|
||||||
|
|
||||||
|
const cloudTypeId = computed(() => Number(route.params.id))
|
||||||
|
const cloudType = computed(() => encyclopediaStore.cloudTypes.find(item => item.id === cloudTypeId.value) ?? null)
|
||||||
|
const collectionEntry = computed(() => {
|
||||||
|
if (!authStore.isLoggedIn || !cloudType.value) return null
|
||||||
|
return encyclopediaStore.getCollectionEntry(cloudType.value.id)
|
||||||
|
})
|
||||||
|
const isUnlocked = computed(() => !!collectionEntry.value)
|
||||||
|
const heroImage = computed(() => collectionEntry.value?.firstCloud?.image_url || gallery.value[0]?.image_url || null)
|
||||||
|
|
||||||
|
function formatDate(iso: string | null) {
|
||||||
|
if (!iso) return '未知时间'
|
||||||
|
return new Date(iso).toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatGalleryTime(item: CloudGalleryItem) {
|
||||||
|
return formatDate(item.captured_at || item.created_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(iso: string | null) {
|
||||||
|
if (!iso) return '未知时间'
|
||||||
|
return new Date(iso).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCoordinate(value: number | null) {
|
||||||
|
return value === null ? '未记录' : value.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGalleryDetail(item: CloudGalleryItem) {
|
||||||
|
selectedGalleryItem.value = item
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGallery(typeId: number) {
|
||||||
|
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() {
|
||||||
|
if (!Number.isFinite(cloudTypeId.value) || cloudTypeId.value <= 0) {
|
||||||
|
loadError.value = '云型不存在'
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await encyclopediaStore.fetchCloudTypes()
|
||||||
|
await encyclopediaStore.fetchMyCollection()
|
||||||
|
|
||||||
|
if (!cloudType.value) {
|
||||||
|
loadError.value = '云型不存在'
|
||||||
|
gallery.value = []
|
||||||
|
publicCount.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadGallery(cloudType.value.id)
|
||||||
|
} catch (error) {
|
||||||
|
loadError.value = error instanceof Error ? error.message : '详情加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadPage)
|
||||||
|
watch(() => route.params.id, loadPage)
|
||||||
|
|
||||||
|
watch(() => route.params.id, () => {
|
||||||
|
selectedGalleryItem.value = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||||
|
<div class="mb-6">
|
||||||
|
<RouterLink to="/encyclopedia">
|
||||||
|
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">← 返回图鉴总览</NButton>
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="space-y-6">
|
||||||
|
<NSkeleton class="h-80 w-full" />
|
||||||
|
<div class="grid gap-4 lg:grid-cols-3">
|
||||||
|
<NCard v-for="n in 3" :key="n">
|
||||||
|
<NSkeleton class="h-4 w-1/3" />
|
||||||
|
<NSkeleton class="mt-4 h-7 w-1/2" />
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NAlert
|
||||||
|
v-else-if="loadError"
|
||||||
|
type="error"
|
||||||
|
:show-icon="false"
|
||||||
|
:bordered="false"
|
||||||
|
title="详情加载失败"
|
||||||
|
>
|
||||||
|
{{ loadError }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<template v-else-if="cloudType">
|
||||||
|
<section class="overflow-hidden border border-slate-200 bg-white shadow-sm">
|
||||||
|
<div class="grid lg:grid-cols-[1.15fr_0.85fr]">
|
||||||
|
<div class="relative min-h-[360px] overflow-hidden">
|
||||||
|
<img
|
||||||
|
v-if="heroImage"
|
||||||
|
:src="heroImage"
|
||||||
|
:alt="cloudType.name"
|
||||||
|
class="absolute inset-0 h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="absolute inset-0 bg-gradient-to-br"
|
||||||
|
:class="rarityMeta[cloudType.rarity].glow"
|
||||||
|
></div>
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-slate-950/78 via-slate-950/18 to-transparent"></div>
|
||||||
|
<div class="absolute bottom-8 left-8 right-8 text-white">
|
||||||
|
<NTag :bordered="false" class="border border-white/20 bg-white/12 text-white backdrop-blur">
|
||||||
|
{{ rarityMeta[cloudType.rarity].label }}
|
||||||
|
</NTag>
|
||||||
|
<h1 class="mt-4 text-4xl font-bold">{{ cloudType.name }}</h1>
|
||||||
|
<p class="mt-2 text-lg text-white/82">{{ cloudType.name_en }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col justify-between bg-slate-50 p-8">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium uppercase tracking-[0.22em] text-slate-500">Meteorology Note</p>
|
||||||
|
<p class="mt-5 text-base leading-8 text-slate-700">
|
||||||
|
{{ cloudType.description || '这类云的图像、形成条件和观测要点会随着图鉴完善逐步补充。' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NCard class="mt-8 border border-slate-200">
|
||||||
|
<p class="text-sm text-slate-500">你的收集状态</p>
|
||||||
|
<template v-if="isUnlocked && collectionEntry">
|
||||||
|
<p class="oc-stat-value">已解锁</p>
|
||||||
|
<p class="mt-2 text-sm text-slate-600">首次解锁于 {{ formatDate(collectionEntry.unlocked_at) }}</p>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<p class="oc-stat-value">尚未解锁</p>
|
||||||
|
<p class="mt-2 text-sm text-slate-600">拍到并上传这种云朵后,这枚徽章就会被点亮。</p>
|
||||||
|
</template>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-6 grid gap-4 md:grid-cols-3">
|
||||||
|
<NCard class="border border-slate-200 shadow-sm">
|
||||||
|
<p class="text-sm text-slate-500">稀有度</p>
|
||||||
|
<p class="oc-stat-value">{{ rarityMeta[cloudType.rarity].label }}</p>
|
||||||
|
</NCard>
|
||||||
|
<NCard class="border border-slate-200 shadow-sm">
|
||||||
|
<p class="text-sm text-slate-500">公开云图</p>
|
||||||
|
<p class="oc-stat-value">{{ publicCount }}</p>
|
||||||
|
</NCard>
|
||||||
|
<NCard class="border border-slate-200 shadow-sm">
|
||||||
|
<p class="text-sm text-slate-500">首次解锁</p>
|
||||||
|
<p class="mt-2 text-xl font-bold text-slate-900">
|
||||||
|
{{ collectionEntry ? formatDate(collectionEntry.unlocked_at) : '等待记录' }}
|
||||||
|
</p>
|
||||||
|
</NCard>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-8">
|
||||||
|
<div class="mb-4 flex items-end justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-slate-900">该类型云图画廊</h2>
|
||||||
|
<p class="oc-field-help">来自社区公开可见的已通过云图。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="gallery.length" class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<button
|
||||||
|
v-for="item in gallery"
|
||||||
|
:key="item.id"
|
||||||
|
type="button"
|
||||||
|
class="oc-interactive-card overflow-hidden border border-slate-200 bg-white text-left shadow-sm transition-transform duration-300 hover:-translate-y-1 hover:shadow-lg"
|
||||||
|
@click="openGalleryDetail(item)"
|
||||||
|
>
|
||||||
|
<img :src="item.thumbnail_url || item.image_url" :alt="cloudType.name" class="h-56 w-full object-cover" />
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<p class="text-sm font-medium text-slate-900">{{ item.profiles?.username || '匿名' }}</p>
|
||||||
|
<p class="text-xs text-slate-400">{{ formatGalleryTime(item) }}</p>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-sm text-slate-600">{{ item.location_name || '未填写位置' }}</p>
|
||||||
|
<p v-if="item.description" class="mt-3 line-clamp-3 text-sm leading-6 text-slate-500">
|
||||||
|
{{ item.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="border border-dashed border-slate-300 bg-white p-8">
|
||||||
|
<NEmpty description="还没有公开作品">
|
||||||
|
<template #extra>
|
||||||
|
<p class="text-sm text-slate-500">等第一位观测者上传这类云图后,这里就会展示出来。</p>
|
||||||
|
</template>
|
||||||
|
</NEmpty>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<ImageDetailModal
|
||||||
|
v-if="selectedGalleryItem"
|
||||||
|
:open="!!selectedGalleryItem"
|
||||||
|
:image-url="selectedGalleryItem.image_url"
|
||||||
|
:thumbnail-url="selectedGalleryItem.thumbnail_url"
|
||||||
|
:image-alt="cloudType.name"
|
||||||
|
:title="cloudType.name"
|
||||||
|
:subtitle="`上传者:${selectedGalleryItem.profiles?.username || '匿名'}`"
|
||||||
|
:badge-label="rarityMeta[cloudType.rarity].label"
|
||||||
|
:badge-class="rarityMeta[cloudType.rarity].chip"
|
||||||
|
@close="selectedGalleryItem = null"
|
||||||
|
>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div class="oc-inset-panel">
|
||||||
|
<p class="oc-section-label">上传时间</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(selectedGalleryItem.created_at) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="oc-inset-panel">
|
||||||
|
<p class="oc-section-label">拍摄时间</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(selectedGalleryItem.captured_at) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="oc-inset-panel">
|
||||||
|
<p class="oc-section-label">位置</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">{{ selectedGalleryItem.location_name || '未填写位置名称' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="oc-inset-panel">
|
||||||
|
<p class="oc-section-label">模糊化经纬度</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">
|
||||||
|
纬度 {{ formatCoordinate(selectedGalleryItem.latitude) }} / 经度 {{ formatCoordinate(selectedGalleryItem.longitude) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 border border-slate-200 bg-white p-5">
|
||||||
|
<p class="oc-section-label">图片说明</p>
|
||||||
|
<p class="mt-3 text-sm leading-7 text-slate-700">
|
||||||
|
{{ selectedGalleryItem.description || '上传者没有留下额外说明。' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<MiniLocationMap
|
||||||
|
:latitude="selectedGalleryItem.latitude"
|
||||||
|
:longitude="selectedGalleryItem.longitude"
|
||||||
|
:location-name="selectedGalleryItem.location_name"
|
||||||
|
/>
|
||||||
|
</ImageDetailModal>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,191 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { NAlert, NCard, NEmpty, NIcon, NSkeleton } from 'naive-ui'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { useEncyclopediaStore } from '@/stores/encyclopedia'
|
||||||
|
import EncyclopediaProgressCard from '@/components/cloud/EncyclopediaProgressCard.vue'
|
||||||
|
import { Lock } from '@vicons/tabler'
|
||||||
|
import type { CloudType } from '@/types/database'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const encyclopediaStore = useEncyclopediaStore()
|
||||||
|
|
||||||
|
const lockHint = ref('')
|
||||||
|
let lockHintTimer: number | null = null
|
||||||
|
|
||||||
|
const rarityMeta = {
|
||||||
|
common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200', glow: 'from-sky-100 to-white' },
|
||||||
|
uncommon: { label: '少见', chip: 'bg-amber-100 text-amber-700 border-amber-200', glow: 'from-amber-100 to-white' },
|
||||||
|
rare: { label: '罕见', chip: 'bg-rose-100 text-rose-700 border-rose-200', glow: 'from-rose-100 to-white' },
|
||||||
|
} satisfies Record<CloudType['rarity'], { label: string; chip: string; glow: string }>
|
||||||
|
|
||||||
|
function isUnlocked(cloudTypeId: number) {
|
||||||
|
return authStore.isLoggedIn && encyclopediaStore.isUnlocked(cloudTypeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCollectionEntry(cloudTypeId: number) {
|
||||||
|
if (!authStore.isLoggedIn) return null
|
||||||
|
return encyclopediaStore.getCollectionEntry(cloudTypeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUnlockedAt(iso: string) {
|
||||||
|
return new Date(iso).toLocaleDateString('zh-CN', {
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLockHint(name: string) {
|
||||||
|
lockHint.value = `拍到 ${name} 后,这枚徽章就会亮起来。`
|
||||||
|
if (lockHintTimer) window.clearTimeout(lockHintTimer)
|
||||||
|
lockHintTimer = window.setTimeout(() => {
|
||||||
|
lockHint.value = ''
|
||||||
|
lockHintTimer = null
|
||||||
|
}, 2200)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCard(cloudType: CloudType) {
|
||||||
|
if (isUnlocked(cloudType.id)) {
|
||||||
|
router.push(`/encyclopedia/${cloudType.id}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
showLockHint(cloudType.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await encyclopediaStore.fetchCloudTypes()
|
||||||
|
await encyclopediaStore.fetchMyCollection()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<div class="oc-page-hero">
|
||||||
|
<div class="oc-container oc-container--content oc-page-hero__inner">
|
||||||
|
<div class="grid gap-6 lg:grid-cols-[1.4fr_0.9fr] lg:items-start">
|
||||||
|
<div>
|
||||||
|
<p class="oc-page-eyebrow">Cloud Encyclopedia</p>
|
||||||
|
<h1 class="oc-page-title">云朵图鉴</h1>
|
||||||
|
<p class="oc-page-description">
|
||||||
|
收集 10 种基础云属。每拍到一种新的云型,图鉴里就会点亮一枚徽章,并记录你第一次遇见它的时间。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EncyclopediaProgressCard
|
||||||
|
:unlocked-count="authStore.isLoggedIn ? encyclopediaStore.unlockedCount : 0"
|
||||||
|
:total-count="encyclopediaStore.cloudTypes.length || 10"
|
||||||
|
:percent="encyclopediaStore.unlockPercent"
|
||||||
|
:is-logged-in="authStore.isLoggedIn"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="oc-container oc-container--content oc-page-content">
|
||||||
|
<NAlert
|
||||||
|
v-if="encyclopediaStore.collectionError && authStore.isLoggedIn"
|
||||||
|
class="mb-6"
|
||||||
|
type="warning"
|
||||||
|
:show-icon="false"
|
||||||
|
:bordered="false"
|
||||||
|
title="图鉴收藏数据暂时不可用"
|
||||||
|
>
|
||||||
|
图鉴收藏数据暂时不可用:{{ encyclopediaStore.collectionError }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<div v-if="encyclopediaStore.loadingCloudTypes" class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<NCard v-for="n in 6" :key="n" class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||||||
|
<NSkeleton class="h-44 w-full" />
|
||||||
|
<NSkeleton class="mt-5 h-5 w-2/5" />
|
||||||
|
<NSkeleton class="mt-3 h-4 w-1/3" />
|
||||||
|
<NSkeleton class="mt-6 h-4 w-full" :repeat="2" />
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="encyclopediaStore.cloudTypes.length" class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<button
|
||||||
|
v-for="cloudType in encyclopediaStore.cloudTypes"
|
||||||
|
:key="cloudType.id"
|
||||||
|
type="button"
|
||||||
|
@click="openCard(cloudType)"
|
||||||
|
class="oc-interactive-card group overflow-hidden border bg-white text-left shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg"
|
||||||
|
:class="isUnlocked(cloudType.id) ? 'border-amber-300 shadow-amber-100/60' : 'border-slate-200 hover:border-slate-300'"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative h-44 overflow-hidden border-b"
|
||||||
|
:class="isUnlocked(cloudType.id) ? 'border-white/40' : 'border-slate-200'"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="getCollectionEntry(cloudType.id)?.firstCloud?.image_url"
|
||||||
|
:src="getCollectionEntry(cloudType.id)?.firstCloud?.thumbnail_url || getCollectionEntry(cloudType.id)?.firstCloud?.image_url || ''"
|
||||||
|
:alt="cloudType.name"
|
||||||
|
class="h-full w-full object-cover transition duration-500 group-hover:scale-105"
|
||||||
|
:class="isUnlocked(cloudType.id) ? '' : 'grayscale'"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="flex h-full w-full items-center justify-center bg-gradient-to-br"
|
||||||
|
:class="isUnlocked(cloudType.id) ? rarityMeta[cloudType.rarity].glow : 'from-slate-300 to-slate-100'"
|
||||||
|
>
|
||||||
|
<span class="text-6xl font-bold text-slate-800/85">{{ cloudType.name.slice(0, 1) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-slate-950/70 via-slate-950/10 to-transparent"></div>
|
||||||
|
|
||||||
|
<div v-if="!isUnlocked(cloudType.id)" class="absolute inset-0 flex items-center justify-center bg-slate-950/22 backdrop-blur-[2px]">
|
||||||
|
<div class="border border-white/45 bg-white/15 px-4 py-2 text-sm font-medium text-white">
|
||||||
|
<span style="display: inline-flex; align-items: center; gap: 4px;">
|
||||||
|
<NIcon size="16" style="display: inline-flex;">
|
||||||
|
<Lock />
|
||||||
|
</NIcon>
|
||||||
|
<span>尚未解锁</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute left-4 top-4">
|
||||||
|
<span class="inline-flex border px-3 py-1 text-xs font-medium" :class="rarityMeta[cloudType.rarity].chip">
|
||||||
|
{{ rarityMeta[cloudType.rarity].label }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute bottom-4 left-4 right-4 text-white">
|
||||||
|
<p class="text-xl font-semibold">{{ cloudType.name }}</p>
|
||||||
|
<p class="mt-1 text-sm text-white/80">{{ cloudType.name_en }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-5">
|
||||||
|
<p class="line-clamp-2 min-h-[3.25rem] text-sm leading-6 text-slate-600">
|
||||||
|
{{ cloudType.description || '云层特征和识别要点会显示在这里。' }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-5 flex items-center justify-between text-sm">
|
||||||
|
<template v-if="isUnlocked(cloudType.id) && getCollectionEntry(cloudType.id)">
|
||||||
|
<span class="font-medium text-amber-700">已收录</span>
|
||||||
|
<span class="text-slate-500">首次记录于 {{ formatUnlockedAt(getCollectionEntry(cloudType.id)!.unlocked_at) }}</span>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="font-medium text-slate-700">等待发现</span>
|
||||||
|
<span class="text-slate-400">拍到后点亮</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="oc-empty-card border border-dashed border-slate-300 bg-white px-6 py-12">
|
||||||
|
<NEmpty description="暂时还没有图鉴数据" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="lockHint"
|
||||||
|
class="fixed bottom-6 left-1/2 z-50 -translate-x-1/2 border border-slate-900 bg-slate-900 px-4 py-2 text-sm text-white shadow-lg"
|
||||||
|
>
|
||||||
|
{{ lockHint }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,606 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { NAlert, NButton, NDropdown, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
|
||||||
|
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 { 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 {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
cloud_type_id: number | null
|
||||||
|
custom_cloud_type: string | null
|
||||||
|
image_url: string
|
||||||
|
thumbnail_url: string | null
|
||||||
|
location_name: string | null
|
||||||
|
description: string | null
|
||||||
|
latitude: number | null
|
||||||
|
longitude: number | null
|
||||||
|
captured_at: string | null
|
||||||
|
created_at: string
|
||||||
|
status: 'pending' | 'approved' | 'rejected'
|
||||||
|
is_hidden: boolean
|
||||||
|
cloudTypeName: string
|
||||||
|
cloudTypeRarity: CloudType['rarity']
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const cloudsStore = useCloudsStore()
|
||||||
|
const profileStore = useProfileStore()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const loadError = ref('')
|
||||||
|
const galleryItems = ref<GalleryCloud[]>([])
|
||||||
|
const selectedTypeId = ref<number | 'all'>('all')
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const selectedCloud = ref<GalleryCloud | null>(null)
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const totalCount = ref(0)
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / PAGE_SIZE)))
|
||||||
|
const manageError = ref('')
|
||||||
|
const editModalOpen = ref(false)
|
||||||
|
const editSaving = ref(false)
|
||||||
|
const editInitialValue = ref<CloudEditFormValue | null>(null)
|
||||||
|
|
||||||
|
let searchTimer: number | null = null
|
||||||
|
|
||||||
|
const rarityMeta = {
|
||||||
|
common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200' },
|
||||||
|
uncommon: { label: '少见', chip: 'bg-amber-100 text-amber-700 border-amber-200' },
|
||||||
|
rare: { label: '罕见', chip: 'bg-rose-100 text-rose-700 border-rose-200' },
|
||||||
|
} satisfies Record<CloudType['rarity'], { label: string; chip: string }>
|
||||||
|
|
||||||
|
function formatDateTime(iso: string | null) {
|
||||||
|
if (!iso) return '未知时间'
|
||||||
|
return new Date(iso).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUploadTime(cloud: GalleryCloud) {
|
||||||
|
return formatDateTime(cloud.created_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCapturedTime(cloud: GalleryCloud) {
|
||||||
|
return formatDateTime(cloud.captured_at)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCoordinate(value: number | null) {
|
||||||
|
return value === null ? '未记录' : value.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedSearch = computed(() => searchQuery.value.trim())
|
||||||
|
const isUserSearch = computed(() => normalizedSearch.value.startsWith('@'))
|
||||||
|
|
||||||
|
function toGalleryCloud(row: ApiCloud) {
|
||||||
|
return {
|
||||||
|
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 loadPage(page: number) {
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 : '画廊加载失败'
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToPage(page: number) {
|
||||||
|
if (page < 1 || page > totalPages.value || page === currentPage.value) return
|
||||||
|
await loadPage(page)
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDetail(cloud: GalleryCloud) {
|
||||||
|
selectedCloud.value = cloud
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDetail() {
|
||||||
|
selectedCloud.value = null
|
||||||
|
editModalOpen.value = false
|
||||||
|
manageError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedCloudIsMine = computed(() => {
|
||||||
|
return !!selectedCloud.value && selectedCloud.value.user_id === authStore.user?.id
|
||||||
|
})
|
||||||
|
|
||||||
|
const cloudActionOptions = computed(() => [
|
||||||
|
{ label: '编辑图片信息', key: 'edit' },
|
||||||
|
{ label: '删除', key: 'delete' },
|
||||||
|
{ label: '更改为私密', key: 'toggle-privacy' },
|
||||||
|
])
|
||||||
|
|
||||||
|
function formatDatetimeLocal(iso: string | null) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const date = new Date(iso)
|
||||||
|
const pad = (value: number) => String(value).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function blurCoordinate(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditModal() {
|
||||||
|
if (!selectedCloud.value) return
|
||||||
|
|
||||||
|
manageError.value = ''
|
||||||
|
editInitialValue.value = {
|
||||||
|
cloudCategoryId: selectedCloud.value.cloud_type_id ?? (selectedCloud.value.custom_cloud_type ? 'other' : null),
|
||||||
|
customCloudType: selectedCloud.value.custom_cloud_type || '',
|
||||||
|
capturedAt: formatDatetimeLocal(selectedCloud.value.captured_at),
|
||||||
|
latitude: selectedCloud.value.latitude,
|
||||||
|
longitude: selectedCloud.value.longitude,
|
||||||
|
locationName: selectedCloud.value.location_name || '',
|
||||||
|
description: selectedCloud.value.description || '',
|
||||||
|
isPublic: !selectedCloud.value.is_hidden,
|
||||||
|
}
|
||||||
|
editModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeEditModal() {
|
||||||
|
editModalOpen.value = false
|
||||||
|
editSaving.value = false
|
||||||
|
manageError.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchGalleryCloud(updated: GalleryCloud) {
|
||||||
|
galleryItems.value = galleryItems.value.map(item => (item.id === updated.id ? updated : item))
|
||||||
|
if (selectedCloud.value?.id === updated.id) {
|
||||||
|
selectedCloud.value = updated
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitEditModal(value: CloudEditFormValue) {
|
||||||
|
if (!selectedCloud.value || !authStore.user?.id) return
|
||||||
|
|
||||||
|
if (!value.cloudCategoryId) {
|
||||||
|
manageError.value = '请选择云型类别。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (value.cloudCategoryId === 'other' && !value.customCloudType.trim()) {
|
||||||
|
manageError.value = '请输入自定义云型名称。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!value.capturedAt) {
|
||||||
|
manageError.value = '请选择拍摄时间。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const hasLat = typeof value.latitude === 'number' && !Number.isNaN(value.latitude)
|
||||||
|
const hasLng = typeof value.longitude === 'number' && !Number.isNaN(value.longitude)
|
||||||
|
if (hasLat !== hasLng) {
|
||||||
|
manageError.value = '经纬度必须同时填写。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = selectedCloud.value
|
||||||
|
editSaving.value = true
|
||||||
|
manageError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await profileStore.updateCloud(authStore.user.id, current.id, {
|
||||||
|
cloud_type_id: value.cloudCategoryId === 'other' ? null : value.cloudCategoryId,
|
||||||
|
custom_cloud_type: value.cloudCategoryId === 'other' ? value.customCloudType.trim() : null,
|
||||||
|
latitude: hasLat ? blurCoordinate(value.latitude as number) : null,
|
||||||
|
longitude: hasLng ? blurCoordinate(value.longitude as number) : null,
|
||||||
|
location_name: value.locationName.trim() || null,
|
||||||
|
description: value.description.trim() || null,
|
||||||
|
captured_at: new Date(value.capturedAt).toISOString(),
|
||||||
|
is_hidden: !value.isPublic,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (updated.is_hidden || updated.status !== 'approved') {
|
||||||
|
removeGalleryCloud(updated.id)
|
||||||
|
} else {
|
||||||
|
patchGalleryCloud({
|
||||||
|
...current,
|
||||||
|
...updated,
|
||||||
|
user_id: current.user_id,
|
||||||
|
username: current.username,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
closeEditModal()
|
||||||
|
message.success('图片信息已更新')
|
||||||
|
} catch (error) {
|
||||||
|
manageError.value = error instanceof Error ? error.message : '图片信息更新失败'
|
||||||
|
message.error(manageError.value)
|
||||||
|
} finally {
|
||||||
|
editSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeGalleryCloud(cloudId: string) {
|
||||||
|
galleryItems.value = galleryItems.value.filter(item => item.id !== cloudId)
|
||||||
|
if (selectedCloud.value?.id === cloudId) {
|
||||||
|
selectedCloud.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSelectedCloud() {
|
||||||
|
if (!selectedCloud.value || !authStore.user?.id) return
|
||||||
|
const cloudId = selectedCloud.value.id
|
||||||
|
const confirmed = window.confirm('确定删除这张图片吗?删除后无法在页面中恢复。')
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
manageError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deletedCount = await profileStore.deleteClouds(authStore.user.id, [cloudId])
|
||||||
|
removeGalleryCloud(cloudId)
|
||||||
|
message.success(`已成功删除 ${deletedCount} 张照片`)
|
||||||
|
} catch (error) {
|
||||||
|
manageError.value = error instanceof Error ? error.message : '图片删除失败'
|
||||||
|
message.error(manageError.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hideSelectedCloud() {
|
||||||
|
if (!selectedCloud.value || !authStore.user?.id) return
|
||||||
|
const cloudId = selectedCloud.value.id
|
||||||
|
|
||||||
|
manageError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await profileStore.updateCloudVisibility(authStore.user.id, cloudId, true)
|
||||||
|
removeGalleryCloud(cloudId)
|
||||||
|
message.success('已更改为私密图片')
|
||||||
|
} catch (error) {
|
||||||
|
manageError.value = error instanceof Error ? error.message : '私密状态更新失败'
|
||||||
|
message.error(manageError.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCloudAction(key: string | number) {
|
||||||
|
if (key === 'edit') openEditModal()
|
||||||
|
if (key === 'delete') deleteSelectedCloud()
|
||||||
|
if (key === 'toggle-privacy') hideSelectedCloud()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSearch() {
|
||||||
|
searchQuery.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterTabs = computed(() => [
|
||||||
|
{ id: 'all' as const, label: '全部' },
|
||||||
|
...cloudsStore.cloudTypes.map(type => ({ id: type.id, label: type.name })),
|
||||||
|
])
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await cloudsStore.fetchCloudTypes()
|
||||||
|
await loadPage(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedTypeId, async () => {
|
||||||
|
selectedCloud.value = null
|
||||||
|
await loadPage(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(searchQuery, () => {
|
||||||
|
if (searchTimer !== null) {
|
||||||
|
window.clearTimeout(searchTimer)
|
||||||
|
}
|
||||||
|
searchTimer = window.setTimeout(async () => {
|
||||||
|
selectedCloud.value = null
|
||||||
|
await loadPage(1)
|
||||||
|
searchTimer = null
|
||||||
|
}, 250)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (searchTimer !== null) {
|
||||||
|
window.clearTimeout(searchTimer)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<section class="oc-page-hero">
|
||||||
|
<div class="oc-container oc-container--wide oc-page-hero__inner">
|
||||||
|
<p class="oc-page-eyebrow">Community Gallery</p>
|
||||||
|
<h1 class="oc-page-title">云图画廊</h1>
|
||||||
|
<p class="oc-page-description">
|
||||||
|
按上传时间倒序浏览社区云图。瀑布流会尽量保留图片原始比例,悬停即可快速查看基本信息,点开可看大图和详细记录。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="oc-container oc-container--wide oc-page-content">
|
||||||
|
<section>
|
||||||
|
<div class="mb-4 border border-slate-200 bg-white p-3 shadow-[6px_6px_0_0_rgba(15,23,42,0.05)]">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<NIcon size="20" class="shrink-0 text-slate-400">
|
||||||
|
<Search />
|
||||||
|
</NIcon>
|
||||||
|
<input
|
||||||
|
v-model="searchQuery"
|
||||||
|
type="text"
|
||||||
|
class="min-w-0 flex-1 bg-transparent text-sm text-slate-800 outline-none placeholder:text-slate-400"
|
||||||
|
placeholder="搜索云型名称,或输入 @用户名 查看某个用户的图片"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
v-if="searchQuery"
|
||||||
|
type="button"
|
||||||
|
class="oc-icon-button oc-icon-button--small"
|
||||||
|
title="清空搜索"
|
||||||
|
aria-label="清空搜索"
|
||||||
|
@click="clearSearch"
|
||||||
|
>
|
||||||
|
<NIcon size="18">
|
||||||
|
<X />
|
||||||
|
</NIcon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="normalizedSearch" class="mt-2 text-xs text-slate-500">
|
||||||
|
<template v-if="isUserSearch">
|
||||||
|
正在按上传者搜索:{{ normalizedSearch.slice(1) || '请输入用户名' }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
正在匹配云型名称和自定义云型:{{ normalizedSearch }}
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 overflow-x-auto pb-2">
|
||||||
|
<NButton
|
||||||
|
v-for="tab in filterTabs"
|
||||||
|
:key="tab.id"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
@click="selectedTypeId = tab.id"
|
||||||
|
class="shrink-0"
|
||||||
|
:class="selectedTypeId === tab.id ? 'oc-panel-button oc-panel-button--sky' : 'oc-panel-button oc-panel-button--neutral'"
|
||||||
|
type="default"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<NAlert v-if="loadError" class="mt-6" type="error" :show-icon="false" :bordered="false" title="画廊加载失败">
|
||||||
|
{{ loadError }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<NAlert v-if="manageError" class="mt-6" type="error" :show-icon="false" :bordered="false" title="操作失败">
|
||||||
|
{{ manageError }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<section v-if="loading" class="mt-6 columns-2 gap-3 md:columns-3 xl:columns-4 [column-gap:0.75rem]">
|
||||||
|
<div
|
||||||
|
v-for="n in 8"
|
||||||
|
:key="n"
|
||||||
|
class="oc-panel-card mb-3 break-inside-avoid border border-slate-200 bg-white p-3"
|
||||||
|
>
|
||||||
|
<NSkeleton class="h-44 w-full" />
|
||||||
|
<NSkeleton class="mt-4 h-4 w-3/5" />
|
||||||
|
<NSkeleton class="mt-3 h-3 w-2/5" />
|
||||||
|
<NSkeleton class="mt-2 h-3 w-4/5" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else-if="galleryItems.length" class="mt-6 columns-2 gap-3 md:columns-3 xl:columns-4 [column-gap:0.75rem]">
|
||||||
|
<button
|
||||||
|
v-for="cloud in galleryItems"
|
||||||
|
:key="cloud.id"
|
||||||
|
type="button"
|
||||||
|
@click="openDetail(cloud)"
|
||||||
|
class="oc-interactive-card group relative mb-3 block w-full break-inside-avoid overflow-hidden border border-slate-200 bg-slate-200 text-left shadow-sm transition-transform duration-300 hover:-translate-y-1 hover:shadow-lg"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="cloud.thumbnail_url || cloud.image_url"
|
||||||
|
:alt="cloud.cloudTypeName"
|
||||||
|
class="block h-auto w-full object-cover transition duration-500 group-hover:scale-[1.04]"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="absolute inset-x-0 bottom-0 h-28 bg-gradient-to-t from-slate-950/88 via-slate-950/45 to-transparent opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100"></div>
|
||||||
|
<div class="absolute inset-x-0 bottom-0 p-4 text-white opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<p class="truncate text-sm font-semibold">{{ cloud.cloudTypeName }}</p>
|
||||||
|
<NTag size="small" :bordered="false" class="shrink-0 bg-white/12 text-white backdrop-blur">
|
||||||
|
{{ rarityMeta[cloud.cloudTypeRarity].label }}
|
||||||
|
</NTag>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 flex items-center gap-1.5 truncate text-xs text-white/82">
|
||||||
|
<NIcon size="14">
|
||||||
|
<User />
|
||||||
|
</NIcon>
|
||||||
|
<span class="truncate">{{ cloud.username }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 flex items-center gap-1.5 truncate text-xs text-white/82">
|
||||||
|
<NIcon size="14">
|
||||||
|
<Clock />
|
||||||
|
</NIcon>
|
||||||
|
<span class="truncate">{{ formatUploadTime(cloud) }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="mt-1 flex items-center gap-1.5 truncate text-xs text-white/82">
|
||||||
|
<NIcon size="14">
|
||||||
|
<Location />
|
||||||
|
</NIcon>
|
||||||
|
<span class="truncate">{{ cloud.location_name || '未填写位置' }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-else class="oc-empty-card mt-6 border border-dashed border-slate-300 bg-white px-6 py-12">
|
||||||
|
<NEmpty description="还没有符合条件的云图">
|
||||||
|
<template #extra>
|
||||||
|
<p class="text-sm text-slate-500">换个云型筛选试试,或者等社区上传更多作品。</p>
|
||||||
|
</template>
|
||||||
|
</NEmpty>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div v-if="totalPages > 1" class="mt-8 flex items-center justify-center gap-2">
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
:disabled="currentPage <= 1"
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
|
@click="goToPage(currentPage - 1)"
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</NButton>
|
||||||
|
|
||||||
|
<template v-for="page in totalPages" :key="page">
|
||||||
|
<NButton
|
||||||
|
v-if="page === 1 || page === totalPages || Math.abs(page - currentPage) <= 2"
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
:class="page === currentPage ? 'oc-panel-button oc-panel-button--sky' : 'oc-panel-button oc-panel-button--neutral'"
|
||||||
|
type="default"
|
||||||
|
@click="goToPage(page)"
|
||||||
|
>
|
||||||
|
{{ page }}
|
||||||
|
</NButton>
|
||||||
|
<span
|
||||||
|
v-else-if="page === 2 || page === totalPages - 1"
|
||||||
|
class="px-1 text-sm text-slate-400"
|
||||||
|
>...</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
:disabled="currentPage >= totalPages"
|
||||||
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
|
@click="goToPage(currentPage + 1)"
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="totalCount > 0" class="mt-2 text-center text-xs text-slate-400">
|
||||||
|
共 {{ totalCount }} 张照片,第 {{ currentPage }} / {{ totalPages }} 页
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageDetailModal
|
||||||
|
v-if="selectedCloud"
|
||||||
|
:open="!!selectedCloud"
|
||||||
|
:image-url="selectedCloud.image_url"
|
||||||
|
:thumbnail-url="selectedCloud.thumbnail_url"
|
||||||
|
:image-alt="selectedCloud.cloudTypeName"
|
||||||
|
:title="selectedCloud.cloudTypeName"
|
||||||
|
:subtitle="`上传者:${selectedCloud.username}`"
|
||||||
|
:badge-label="rarityMeta[selectedCloud.cloudTypeRarity].label"
|
||||||
|
:badge-class="rarityMeta[selectedCloud.cloudTypeRarity].chip"
|
||||||
|
@close="closeDetail"
|
||||||
|
>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div class="oc-inset-panel">
|
||||||
|
<p class="oc-section-label">上传时间</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatUploadTime(selectedCloud) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="oc-inset-panel">
|
||||||
|
<p class="oc-section-label">拍摄时间</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatCapturedTime(selectedCloud) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="oc-inset-panel">
|
||||||
|
<p class="oc-section-label">位置</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">{{ selectedCloud.location_name || '未填写位置名称' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="oc-inset-panel">
|
||||||
|
<p class="oc-section-label">模糊化经纬度</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">
|
||||||
|
纬度 {{ formatCoordinate(selectedCloud.latitude) }} / 经度 {{ formatCoordinate(selectedCloud.longitude) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 border border-slate-200 bg-white p-5">
|
||||||
|
<p class="oc-section-label">图片说明</p>
|
||||||
|
<p class="mt-3 text-sm leading-7 text-slate-700">
|
||||||
|
{{ selectedCloud.description || '上传者没有留下额外说明。' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<MiniLocationMap
|
||||||
|
:latitude="selectedCloud.latitude"
|
||||||
|
:longitude="selectedCloud.longitude"
|
||||||
|
:location-name="selectedCloud.location_name"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<template v-if="selectedCloudIsMine" #actions>
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<NDropdown
|
||||||
|
trigger="click"
|
||||||
|
placement="top-start"
|
||||||
|
:options="cloudActionOptions"
|
||||||
|
@select="handleCloudAction"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-icon-button"
|
||||||
|
title="图片管理"
|
||||||
|
aria-label="图片管理"
|
||||||
|
>
|
||||||
|
<NIcon size="20">
|
||||||
|
<Settings />
|
||||||
|
</NIcon>
|
||||||
|
</button>
|
||||||
|
</NDropdown>
|
||||||
|
<!-- <span class="text-xs text-slate-500">这是你上传的公开图片</span> -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</ImageDetailModal>
|
||||||
|
|
||||||
|
<CloudEditModal
|
||||||
|
:open="editModalOpen && !!selectedCloud"
|
||||||
|
:saving="editSaving"
|
||||||
|
:error="manageError"
|
||||||
|
:cloud-types="cloudsStore.cloudTypes"
|
||||||
|
:initial-value="editInitialValue"
|
||||||
|
@close="closeEditModal"
|
||||||
|
@save="submitEditModal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,971 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
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 { 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, Minus, Plus, Refresh, Map, Satellite, X } from '@vicons/tabler'
|
||||||
|
|
||||||
|
interface CloudMarkerData {
|
||||||
|
id: string
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
imageUrl: string
|
||||||
|
thumbnailUrl: string | null
|
||||||
|
locationName: string | null
|
||||||
|
description: string | null
|
||||||
|
cloudTypeName: string
|
||||||
|
rarity: 'common' | 'uncommon' | 'rare'
|
||||||
|
username: string
|
||||||
|
capturedAt: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapEl = ref<HTMLDivElement>()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const previewCloud = ref<CloudMarkerData | null>(null)
|
||||||
|
const satelliteOn = ref(false)
|
||||||
|
const statusText = ref('加载中...')
|
||||||
|
const quickUploadOpen = ref(false)
|
||||||
|
const mapMode = ref<'realtime' | 'archive'>('realtime')
|
||||||
|
const timelineControlsOpen = ref(false)
|
||||||
|
const archivePanelOpen = ref(false)
|
||||||
|
const archiveKind = ref<'day' | 'month'>('day')
|
||||||
|
const archiveDay = ref(formatDateInput(new Date()))
|
||||||
|
const archiveMonth = ref(formatMonthInput(new Date()))
|
||||||
|
const currentMinuteOfDay = ref(getMinuteOfDay(new Date()))
|
||||||
|
const selectedMinuteOfDay = ref(currentMinuteOfDay.value)
|
||||||
|
const sliderDragging = ref(false)
|
||||||
|
const currentZoom = ref(5)
|
||||||
|
|
||||||
|
const VISIBLE_WINDOW_MS = 2 * 60 * 60 * 1000
|
||||||
|
const MIN_MARKER_OPACITY = 0.3
|
||||||
|
const HIDE_HEADER_EVENT = 'opencloud:hide-header'
|
||||||
|
const MIN_MAP_ZOOM = 4
|
||||||
|
const MAX_MAP_ZOOM = 18
|
||||||
|
const ZOOM_HOLD_DELAY_MS = 180
|
||||||
|
const ZOOM_CONTINUOUS_SPEED = 2.2
|
||||||
|
|
||||||
|
let AMapLib: typeof AMap | null = null
|
||||||
|
let mapInst: AMap.Map | null = null
|
||||||
|
let satLayer: AMap.TileLayer | null = null
|
||||||
|
let roadLayer: AMap.TileLayer | null = null
|
||||||
|
let mks: AMap.Marker[] = []
|
||||||
|
let hoverIW: AMap.InfoWindow | null = null
|
||||||
|
let allClouds: CloudMarkerData[] = []
|
||||||
|
let redrawTimer: number | null = null
|
||||||
|
let zoomHoldDelayTimer: number | null = null
|
||||||
|
let zoomAnimationFrame: number | null = null
|
||||||
|
let activeZoomPointerId: number | null = null
|
||||||
|
let zoomContinuousDirection: -1 | 1 | null = null
|
||||||
|
let zoomAnimationValue = currentZoom.value
|
||||||
|
let zoomLastFrameTime = 0
|
||||||
|
let zoomHoldStarted = false
|
||||||
|
|
||||||
|
const rarityColors: Record<string, string> = {
|
||||||
|
common: '#cbd5e1',
|
||||||
|
uncommon: '#60a5fa',
|
||||||
|
rare: '#c084fc',
|
||||||
|
}
|
||||||
|
|
||||||
|
const rarityMeta = {
|
||||||
|
common: { label: '常见', chip: 'bg-sky-100 text-sky-700 border-sky-200' },
|
||||||
|
uncommon: { label: '少见', chip: 'bg-amber-100 text-amber-700 border-amber-200' },
|
||||||
|
rare: { label: '罕见', chip: 'bg-rose-100 text-rose-700 border-rose-200' },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const selectedTimeLabel = computed(() => formatMinuteOfDay(selectedMinuteOfDay.value))
|
||||||
|
const currentMinutePercent = computed(() => Math.min(100, Math.max(0, currentMinuteOfDay.value / 1439 * 100)))
|
||||||
|
const selectedMinutePercent = computed(() => Math.min(100, Math.max(0, selectedMinuteOfDay.value / 1439 * 100)))
|
||||||
|
const archiveTitle = computed(() => {
|
||||||
|
if (mapMode.value === 'realtime') return '实时'
|
||||||
|
return archiveKind.value === 'day' ? archiveDay.value : archiveMonth.value
|
||||||
|
})
|
||||||
|
function getMinuteOfDay(date: Date) {
|
||||||
|
return date.getHours() * 60 + date.getMinutes()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMinuteOfDay(minute: number) {
|
||||||
|
const hours = Math.floor(minute / 60)
|
||||||
|
const minutes = minute % 60
|
||||||
|
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateInput(date: Date) {
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(date.getDate()).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${month}-${day}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatMonthInput(date: Date) {
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${month}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalDateRange(dateValue: string) {
|
||||||
|
const [year, month, day] = dateValue.split('-').map(Number)
|
||||||
|
const start = new Date(year, month - 1, day, 0, 0, 0, 0)
|
||||||
|
const end = new Date(year, month - 1, day + 1, 0, 0, 0, 0)
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLocalMonthRange(monthValue: string) {
|
||||||
|
const [year, month] = monthValue.split('-').map(Number)
|
||||||
|
const start = new Date(year, month - 1, 1, 0, 0, 0, 0)
|
||||||
|
const end = new Date(year, month, 1, 0, 0, 0, 0)
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedRealtimeDate() {
|
||||||
|
const { start } = getLocalDateRange(formatDateInput(new Date()))
|
||||||
|
return new Date(start.getTime() + selectedMinuteOfDay.value * 60 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCurrentMinute() {
|
||||||
|
currentMinuteOfDay.value = getMinuteOfDay(new Date())
|
||||||
|
if (selectedMinuteOfDay.value > currentMinuteOfDay.value) {
|
||||||
|
selectedMinuteOfDay.value = currentMinuteOfDay.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(iso: string): string {
|
||||||
|
const diff = Date.now() - new Date(iso).getTime()
|
||||||
|
const m = Math.floor(diff / 60000)
|
||||||
|
if (m < 1) return '刚刚'
|
||||||
|
if (m < 60) return `${m} 分钟前`
|
||||||
|
const h = Math.floor(m / 60)
|
||||||
|
if (h < 24) return `${h} 小时前`
|
||||||
|
const d = Math.floor(h / 24)
|
||||||
|
if (d < 30) return `${d} 天前`
|
||||||
|
return new Date(iso).toLocaleDateString('zh-CN')
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(iso: string | null) {
|
||||||
|
if (!iso) return '未知时间'
|
||||||
|
return new Date(iso).toLocaleString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'numeric',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCoordinate(value: number | null) {
|
||||||
|
return value === null ? '未记录' : value.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCloudAgeMs(cloud: CloudMarkerData) {
|
||||||
|
const capturedTime = new Date(cloud.capturedAt).getTime()
|
||||||
|
const fallbackTime = new Date(cloud.createdAt).getTime()
|
||||||
|
const targetTime = Number.isNaN(capturedTime) ? fallbackTime : capturedTime
|
||||||
|
return getSelectedRealtimeDate().getTime() - targetTime
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMarkerOpacity(cloud: CloudMarkerData) {
|
||||||
|
if (mapMode.value === 'archive') return 1
|
||||||
|
|
||||||
|
const ageMs = getCloudAgeMs(cloud)
|
||||||
|
if (ageMs < 0) return null
|
||||||
|
if (ageMs >= VISIBLE_WINDOW_MS) return null
|
||||||
|
|
||||||
|
const progress = ageMs / VISIBLE_WINDOW_MS
|
||||||
|
return 1 - progress * (1 - MIN_MARKER_OPACITY)
|
||||||
|
}
|
||||||
|
|
||||||
|
function bubbleContent(cloud: CloudMarkerData): HTMLElement {
|
||||||
|
const border = rarityColors[cloud.rarity] || rarityColors.common
|
||||||
|
const bubbleImage = cloud.thumbnailUrl || cloud.imageUrl
|
||||||
|
const opacity = getMarkerOpacity(cloud) ?? MIN_MARKER_OPACITY
|
||||||
|
|
||||||
|
const container = document.createElement('div')
|
||||||
|
container.style.cssText = `opacity:${opacity};width:46px;height:46px;cursor:pointer;line-height:0`
|
||||||
|
|
||||||
|
const bubble = document.createElement('div')
|
||||||
|
bubble.style.cssText = `box-sizing:border-box;width:46px;height:46px;border-radius:50%;background:linear-gradient(135deg,#ffffff 0%,#e8ecf1 100%);border:3px solid ${border};box-shadow:0 4px 16px rgba(0,0,0,0.3),inset 0 2px 4px rgba(255,255,255,0.6),0 0 0 2px rgba(0,0,0,0.06);overflow:hidden`
|
||||||
|
|
||||||
|
const image = document.createElement('img')
|
||||||
|
image.src = bubbleImage
|
||||||
|
image.alt = ''
|
||||||
|
image.style.cssText = 'display:block;width:100%;height:100%;object-fit:cover'
|
||||||
|
|
||||||
|
bubble.append(image)
|
||||||
|
container.append(bubble)
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
||||||
|
function hoverCardContent(cloud: CloudMarkerData): HTMLElement {
|
||||||
|
const border = rarityColors[cloud.rarity] || rarityColors.common
|
||||||
|
const cardImage = cloud.thumbnailUrl || cloud.imageUrl
|
||||||
|
const label = cloud.rarity === 'common' ? '常见' : cloud.rarity === 'uncommon' ? '少见' : '罕见'
|
||||||
|
|
||||||
|
const card = document.createElement('div')
|
||||||
|
card.style.cssText = 'background:#fff;border-radius:14px;box-shadow:0 10px 30px rgba(0,0,0,0.18);overflow:hidden;width:200px;font-family:system-ui,-apple-system,sans-serif'
|
||||||
|
|
||||||
|
const image = document.createElement('img')
|
||||||
|
image.src = cardImage
|
||||||
|
image.alt = ''
|
||||||
|
image.style.cssText = 'display:block;width:100%;height:110px;object-fit:cover'
|
||||||
|
|
||||||
|
const body = document.createElement('div')
|
||||||
|
body.style.cssText = 'padding:8px 10px'
|
||||||
|
const title = document.createElement('div')
|
||||||
|
title.style.cssText = 'display:flex;align-items:center;gap:6px'
|
||||||
|
|
||||||
|
const name = document.createElement('span')
|
||||||
|
name.textContent = cloud.cloudTypeName
|
||||||
|
name.style.cssText = 'font-size:13px;font-weight:600;color:#1f2937'
|
||||||
|
|
||||||
|
const rarity = document.createElement('span')
|
||||||
|
rarity.textContent = label
|
||||||
|
rarity.style.cssText = `font-size:10px;padding:1px 5px;border-radius:3px;color:${border};background:${border}22`
|
||||||
|
|
||||||
|
const meta = document.createElement('div')
|
||||||
|
meta.textContent = `📷 ${cloud.username} · ${timeAgo(cloud.capturedAt)}`
|
||||||
|
meta.style.cssText = 'font-size:11px;color:#9ca3af;margin-top:3px'
|
||||||
|
|
||||||
|
title.append(name, rarity)
|
||||||
|
body.append(title, meta)
|
||||||
|
card.append(image, body)
|
||||||
|
return card
|
||||||
|
}
|
||||||
|
|
||||||
|
function showHoverCard(cloud: CloudMarkerData, pos: [number, number]) {
|
||||||
|
if (!AMapLib || !mapInst) return
|
||||||
|
hoverIW?.close()
|
||||||
|
const iw = new AMapLib.InfoWindow({
|
||||||
|
content: hoverCardContent(cloud),
|
||||||
|
offset: new AMapLib.Pixel(-100, -60),
|
||||||
|
isCustom: true,
|
||||||
|
} as AMap.InfoWindowOptions)
|
||||||
|
iw.open(mapInst, pos)
|
||||||
|
hoverIW = iw
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideHoverCard() {
|
||||||
|
hoverIW?.close()
|
||||||
|
hoverIW = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideHeader() {
|
||||||
|
window.dispatchEvent(new CustomEvent(HIDE_HEADER_EVENT))
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncCurrentZoom() {
|
||||||
|
if (!mapInst) return
|
||||||
|
currentZoom.value = mapInst.getZoom(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeZoom(direction: -1 | 1, step = 1) {
|
||||||
|
if (!mapInst) return false
|
||||||
|
|
||||||
|
const zoom = mapInst.getZoom(2)
|
||||||
|
const nextZoom = Math.min(MAX_MAP_ZOOM, Math.max(MIN_MAP_ZOOM, zoom + direction * step))
|
||||||
|
if (Math.abs(nextZoom - zoom) < 0.001) return false
|
||||||
|
|
||||||
|
mapInst.setZoom(nextZoom, true)
|
||||||
|
currentZoom.value = nextZoom
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopZoomAnimation() {
|
||||||
|
if (zoomAnimationFrame !== null) {
|
||||||
|
window.cancelAnimationFrame(zoomAnimationFrame)
|
||||||
|
zoomAnimationFrame = null
|
||||||
|
}
|
||||||
|
zoomContinuousDirection = null
|
||||||
|
zoomLastFrameTime = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearZoomHoldDelay() {
|
||||||
|
if (zoomHoldDelayTimer !== null) {
|
||||||
|
window.clearTimeout(zoomHoldDelayTimer)
|
||||||
|
zoomHoldDelayTimer = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearZoomHold() {
|
||||||
|
clearZoomHoldDelay()
|
||||||
|
stopZoomAnimation()
|
||||||
|
activeZoomPointerId = null
|
||||||
|
zoomHoldStarted = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function runContinuousZoomFrame(timestamp: number) {
|
||||||
|
if (!mapInst || zoomContinuousDirection === null) {
|
||||||
|
zoomAnimationFrame = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zoomLastFrameTime === 0) {
|
||||||
|
zoomLastFrameTime = timestamp
|
||||||
|
zoomAnimationFrame = window.requestAnimationFrame(runContinuousZoomFrame)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedSeconds = Math.min((timestamp - zoomLastFrameTime) / 1000, 0.05)
|
||||||
|
zoomLastFrameTime = timestamp
|
||||||
|
const nextZoom = Math.min(
|
||||||
|
MAX_MAP_ZOOM,
|
||||||
|
Math.max(
|
||||||
|
MIN_MAP_ZOOM,
|
||||||
|
zoomAnimationValue + zoomContinuousDirection * ZOOM_CONTINUOUS_SPEED * elapsedSeconds,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (Math.abs(nextZoom - zoomAnimationValue) < 0.0001) {
|
||||||
|
stopZoomAnimation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomAnimationValue = nextZoom
|
||||||
|
mapInst.setZoom(nextZoom, true)
|
||||||
|
currentZoom.value = nextZoom
|
||||||
|
|
||||||
|
if (nextZoom === MIN_MAP_ZOOM || nextZoom === MAX_MAP_ZOOM) {
|
||||||
|
stopZoomAnimation()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
zoomAnimationFrame = window.requestAnimationFrame(runContinuousZoomFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginContinuousZoom(direction: -1 | 1) {
|
||||||
|
if (!mapInst) return
|
||||||
|
|
||||||
|
zoomHoldStarted = true
|
||||||
|
zoomContinuousDirection = direction
|
||||||
|
zoomAnimationValue = mapInst.getZoom(4)
|
||||||
|
zoomLastFrameTime = 0
|
||||||
|
zoomAnimationFrame = window.requestAnimationFrame(runContinuousZoomFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startZoomHold(direction: -1 | 1, event: PointerEvent) {
|
||||||
|
if (event.pointerType === 'mouse' && event.button !== 0) return
|
||||||
|
if (activeZoomPointerId !== null) return
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
clearZoomHold()
|
||||||
|
activeZoomPointerId = event.pointerId
|
||||||
|
|
||||||
|
const button = event.currentTarget as HTMLButtonElement
|
||||||
|
button.setPointerCapture?.(event.pointerId)
|
||||||
|
|
||||||
|
const zoom = mapInst?.getZoom(4)
|
||||||
|
if (
|
||||||
|
zoom === undefined
|
||||||
|
|| (direction === 1 && zoom >= MAX_MAP_ZOOM)
|
||||||
|
|| (direction === -1 && zoom <= MIN_MAP_ZOOM)
|
||||||
|
) {
|
||||||
|
activeZoomPointerId = null
|
||||||
|
if (button.hasPointerCapture?.(event.pointerId)) {
|
||||||
|
button.releasePointerCapture(event.pointerId)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomHoldDelayTimer = window.setTimeout(() => {
|
||||||
|
zoomHoldDelayTimer = null
|
||||||
|
beginContinuousZoom(direction)
|
||||||
|
}, ZOOM_HOLD_DELAY_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishZoomHold(direction: -1 | 1, event: PointerEvent) {
|
||||||
|
if (activeZoomPointerId === null || event.pointerId !== activeZoomPointerId) return
|
||||||
|
|
||||||
|
const pointerId = activeZoomPointerId
|
||||||
|
const wasContinuous = zoomHoldStarted
|
||||||
|
activeZoomPointerId = null
|
||||||
|
clearZoomHoldDelay()
|
||||||
|
stopZoomAnimation()
|
||||||
|
zoomHoldStarted = false
|
||||||
|
|
||||||
|
const button = event.currentTarget as HTMLButtonElement
|
||||||
|
if (button.hasPointerCapture?.(pointerId)) {
|
||||||
|
button.releasePointerCapture(pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!wasContinuous) {
|
||||||
|
changeZoom(direction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelZoomHold(event: PointerEvent) {
|
||||||
|
if (activeZoomPointerId === null || event.pointerId !== activeZoomPointerId) return
|
||||||
|
|
||||||
|
const pointerId = activeZoomPointerId
|
||||||
|
activeZoomPointerId = null
|
||||||
|
clearZoomHoldDelay()
|
||||||
|
stopZoomAnimation()
|
||||||
|
zoomHoldStarted = false
|
||||||
|
|
||||||
|
const button = event.currentTarget as HTMLButtonElement
|
||||||
|
if (button.hasPointerCapture?.(pointerId)) {
|
||||||
|
button.releasePointerCapture(pointerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleZoomClick(direction: -1 | 1, event: MouseEvent) {
|
||||||
|
if (event.detail === 0) {
|
||||||
|
changeZoom(direction)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toCloudMarker(row: ApiCloud): CloudMarkerData {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
latitude: row.latitude as number,
|
||||||
|
longitude: row.longitude as number,
|
||||||
|
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 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 []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRealtimeClouds() {
|
||||||
|
const { start, end } = getLocalDateRange(formatDateInput(new Date()))
|
||||||
|
return fetchCloudsByRange('captured_at', start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadArchiveClouds() {
|
||||||
|
const range = archiveKind.value === 'day'
|
||||||
|
? getLocalDateRange(archiveDay.value)
|
||||||
|
: getLocalMonthRange(archiveMonth.value)
|
||||||
|
return fetchCloudsByRange('captured_at', range.start, range.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVisibleClouds(clouds: CloudMarkerData[]) {
|
||||||
|
if (mapMode.value === 'archive') return clouds
|
||||||
|
|
||||||
|
return clouds.filter(cloud => {
|
||||||
|
const opacity = getMarkerOpacity(cloud)
|
||||||
|
return opacity !== null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMarkers() {
|
||||||
|
hideHoverCard()
|
||||||
|
for (const m of mks) m.setMap(null)
|
||||||
|
mks = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawMarkers(clouds: CloudMarkerData[]) {
|
||||||
|
if (!AMapLib) return
|
||||||
|
clearMarkers()
|
||||||
|
const visibleClouds = getVisibleClouds(clouds)
|
||||||
|
statusText.value = mapMode.value === 'realtime'
|
||||||
|
? `${visibleClouds.length} 朵云(${selectedTimeLabel.value} 前 2 小时)`
|
||||||
|
: `${visibleClouds.length} 朵云(${archiveTitle.value} 拍摄)`
|
||||||
|
|
||||||
|
if (previewCloud.value && !visibleClouds.some(item => item.id === previewCloud.value?.id)) {
|
||||||
|
previewCloud.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const c of visibleClouds) {
|
||||||
|
const m = new AMapLib.Marker({
|
||||||
|
position: [c.longitude, c.latitude],
|
||||||
|
content: bubbleContent(c),
|
||||||
|
offset: new AMapLib.Pixel(-23, -23),
|
||||||
|
zIndex: 200,
|
||||||
|
} as AMap.MarkerOptions)
|
||||||
|
m.on('mouseover', () => showHoverCard(c, [c.longitude, c.latitude]))
|
||||||
|
m.on('mouseout', () => hideHoverCard())
|
||||||
|
m.on('click', () => {
|
||||||
|
previewCloud.value = c
|
||||||
|
})
|
||||||
|
m.setMap(mapInst!)
|
||||||
|
mks.push(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
if (mapMode.value === 'realtime') {
|
||||||
|
syncCurrentMinute()
|
||||||
|
}
|
||||||
|
statusText.value = '加载中...'
|
||||||
|
allClouds = mapMode.value === 'archive'
|
||||||
|
? await loadArchiveClouds()
|
||||||
|
: await loadRealtimeClouds()
|
||||||
|
drawMarkers(allClouds)
|
||||||
|
}
|
||||||
|
|
||||||
|
function startMarkerDecayTimer() {
|
||||||
|
if (redrawTimer !== null) window.clearInterval(redrawTimer)
|
||||||
|
redrawTimer = window.setInterval(() => {
|
||||||
|
if (mapMode.value === 'realtime') {
|
||||||
|
syncCurrentMinute()
|
||||||
|
}
|
||||||
|
drawMarkers(allClouds)
|
||||||
|
}, 60 * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSat() {
|
||||||
|
if (!AMapLib || !mapInst) return
|
||||||
|
if (satelliteOn.value) {
|
||||||
|
if (satLayer) mapInst.remove(satLayer as unknown as Parameters<typeof mapInst.remove>[0])
|
||||||
|
if (roadLayer) mapInst.remove(roadLayer as unknown as Parameters<typeof mapInst.remove>[0])
|
||||||
|
satelliteOn.value = false
|
||||||
|
} else {
|
||||||
|
satLayer = satLayer || new AMapLib.TileLayer.Satellite()
|
||||||
|
roadLayer = roadLayer || new AMapLib.TileLayer.RoadNet()
|
||||||
|
mapInst.add(satLayer as unknown as Parameters<typeof mapInst.add>[0])
|
||||||
|
mapInst.add(roadLayer as unknown as Parameters<typeof mapInst.add>[0])
|
||||||
|
satelliteOn.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openQuickUpload() {
|
||||||
|
if (authStore.loading || !authStore.isLoggedIn) return
|
||||||
|
quickUploadOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeQuickUpload() {
|
||||||
|
quickUploadOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleQuickUploaded() {
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTimeSliderInput(event: Event) {
|
||||||
|
syncCurrentMinute()
|
||||||
|
selectedMinuteOfDay.value = Math.min(Number((event.target as HTMLInputElement).value), currentMinuteOfDay.value)
|
||||||
|
mapMode.value = 'realtime'
|
||||||
|
archivePanelOpen.value = false
|
||||||
|
drawMarkers(allClouds)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSliderPointerDown() {
|
||||||
|
sliderDragging.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSliderPointerUp() {
|
||||||
|
sliderDragging.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleTimelineControls() {
|
||||||
|
timelineControlsOpen.value = !timelineControlsOpen.value
|
||||||
|
if (timelineControlsOpen.value) {
|
||||||
|
syncCurrentMinute()
|
||||||
|
selectedMinuteOfDay.value = currentMinuteOfDay.value
|
||||||
|
} else {
|
||||||
|
archivePanelOpen.value = false
|
||||||
|
sliderDragging.value = false
|
||||||
|
if (mapMode.value === 'archive') {
|
||||||
|
await returnRealtime()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyArchiveFilter() {
|
||||||
|
mapMode.value = 'archive'
|
||||||
|
archivePanelOpen.value = false
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function returnRealtime() {
|
||||||
|
mapMode.value = 'realtime'
|
||||||
|
archivePanelOpen.value = false
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
window.addEventListener('blur', clearZoomHold)
|
||||||
|
try {
|
||||||
|
AMapLib = await loadAMap()
|
||||||
|
AMapLib.getConfig().appname = 'amap-jsapi-skill'
|
||||||
|
mapInst = new AMapLib.Map(mapEl.value!, {
|
||||||
|
viewMode: '3D',
|
||||||
|
pitch: 0,
|
||||||
|
rotation: 0,
|
||||||
|
zoom: currentZoom.value,
|
||||||
|
zooms: [MIN_MAP_ZOOM, MAX_MAP_ZOOM],
|
||||||
|
center: [104.07, 30.67],
|
||||||
|
mapStyle: 'amap://styles/normal',
|
||||||
|
features: ['bg', 'road', 'building', 'point'],
|
||||||
|
resizeEnable: true,
|
||||||
|
skyColor: '#c8dce8',
|
||||||
|
maxPitch: 70,
|
||||||
|
} as AMap.MapOptions)
|
||||||
|
|
||||||
|
mapInst.addControl(new AMapLib.Scale())
|
||||||
|
mapInst.addControl(new AMapLib.ControlBar({ position: { right: '50px', bottom: '224px' } } as Record<string, unknown>))
|
||||||
|
mapInst.on('click', () => { previewCloud.value = null; hideHoverCard() })
|
||||||
|
mapInst.on('zoomstart', hideHeader)
|
||||||
|
mapInst.on('zoomchange', syncCurrentZoom)
|
||||||
|
mapInst.on('movestart', hideHeader)
|
||||||
|
mapInst.on('dragstart', hideHeader)
|
||||||
|
syncCurrentZoom()
|
||||||
|
await refresh()
|
||||||
|
startMarkerDecayTimer()
|
||||||
|
} catch (e) {
|
||||||
|
statusText.value = `加载失败: ${e instanceof Error ? e.message : String(e)}`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('blur', clearZoomHold)
|
||||||
|
clearZoomHold()
|
||||||
|
if (redrawTimer !== null) {
|
||||||
|
window.clearInterval(redrawTimer)
|
||||||
|
redrawTimer = null
|
||||||
|
}
|
||||||
|
mapInst?.off('zoomchange', syncCurrentZoom)
|
||||||
|
mapInst?.destroy()
|
||||||
|
mapInst = null
|
||||||
|
AMapLib = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative h-[100dvh] min-h-screen">
|
||||||
|
<div ref="mapEl" class="w-full h-full"></div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="absolute left-4 top-32 z-20 flex w-10 flex-col gap-2 md:top-20"
|
||||||
|
role="group"
|
||||||
|
:aria-label="`地图缩放控制,当前级别 ${currentZoom.toFixed(2)}`"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-map-button touch-none select-none"
|
||||||
|
title="放大地图(按住可连续放大)"
|
||||||
|
aria-label="放大地图,按住可连续放大"
|
||||||
|
:disabled="currentZoom >= MAX_MAP_ZOOM"
|
||||||
|
@pointerdown="startZoomHold(1, $event)"
|
||||||
|
@pointerup="finishZoomHold(1, $event)"
|
||||||
|
@pointercancel="cancelZoomHold"
|
||||||
|
@lostpointercapture="cancelZoomHold"
|
||||||
|
@click="handleZoomClick(1, $event)"
|
||||||
|
>
|
||||||
|
<NIcon size="20"><Plus /></NIcon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-map-button touch-none select-none"
|
||||||
|
title="缩小地图(按住可连续缩小)"
|
||||||
|
aria-label="缩小地图,按住可连续缩小"
|
||||||
|
:disabled="currentZoom <= MIN_MAP_ZOOM"
|
||||||
|
@pointerdown="startZoomHold(-1, $event)"
|
||||||
|
@pointerup="finishZoomHold(-1, $event)"
|
||||||
|
@pointercancel="cancelZoomHold"
|
||||||
|
@lostpointercapture="cancelZoomHold"
|
||||||
|
@click="handleZoomClick(-1, $event)"
|
||||||
|
>
|
||||||
|
<NIcon size="20"><Minus /></NIcon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="absolute bottom-6 right-7 z-20 flex w-10 flex-col items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-map-button"
|
||||||
|
:title="authStore.isLoggedIn ? '快速上传图片' : '请先登录后上传图片'"
|
||||||
|
aria-label="快速上传图片"
|
||||||
|
:disabled="authStore.loading || !authStore.isLoggedIn"
|
||||||
|
@click="openQuickUpload"
|
||||||
|
>
|
||||||
|
<NIcon size="20" style="display: inline-flex; vertical-align: middle;">
|
||||||
|
<CloudUpload />
|
||||||
|
</NIcon>
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="refresh" class="oc-map-button" title="刷新" aria-label="刷新地图">
|
||||||
|
<span class="text-lg">
|
||||||
|
<NIcon>
|
||||||
|
<Refresh/>
|
||||||
|
</NIcon>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="toggleSat" class="oc-map-button" :title="satelliteOn ? '切换普通视图' : '切换卫星视图'" :aria-label="satelliteOn ? '切换普通视图' : '切换卫星视图'">
|
||||||
|
<NIcon size="20" style="display: inline-flex; vertical-align: middle;">
|
||||||
|
<Map v-if="satelliteOn" />
|
||||||
|
<Satellite v-else />
|
||||||
|
</NIcon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-map-button"
|
||||||
|
:title="timelineControlsOpen ? '收起时间控件' : '展开时间控件'"
|
||||||
|
:aria-label="timelineControlsOpen ? '收起时间控件' : '展开时间控件'"
|
||||||
|
@click="toggleTimelineControls"
|
||||||
|
>
|
||||||
|
<NIcon size="20" style="display: inline-flex; vertical-align: middle;">
|
||||||
|
<X v-if="timelineControlsOpen" />
|
||||||
|
<Adjustments v-else />
|
||||||
|
</NIcon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
<div class="relative w-[min(42vw,420px)] min-w-[220px]">
|
||||||
|
<div
|
||||||
|
v-if="sliderDragging"
|
||||||
|
class="absolute bottom-8 border border-slate-200 bg-white/95 px-3 py-1.5 text-xs font-medium text-slate-800 shadow-md backdrop-blur"
|
||||||
|
:style="{ left: `${selectedMinutePercent}%`, transform: 'translateX(-50%)' }"
|
||||||
|
>
|
||||||
|
{{ selectedTimeLabel }}
|
||||||
|
</div>
|
||||||
|
<div class="relative">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1439"
|
||||||
|
step="10"
|
||||||
|
:value="selectedMinuteOfDay"
|
||||||
|
class="map-time-slider block w-full accent-sky-500"
|
||||||
|
:disabled="mapMode === 'archive'"
|
||||||
|
@pointerdown="handleSliderPointerDown"
|
||||||
|
@pointerup="handleSliderPointerUp"
|
||||||
|
@pointercancel="handleSliderPointerUp"
|
||||||
|
@blur="handleSliderPointerUp"
|
||||||
|
@input="handleTimeSliderInput"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
v-if="mapMode === 'realtime'"
|
||||||
|
class="absolute bottom-0 top-0 z-10"
|
||||||
|
:style="{ left: `${currentMinutePercent}%`, right: '0' }"
|
||||||
|
@pointerdown.prevent
|
||||||
|
@click.prevent
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-map-button"
|
||||||
|
title="按日期或月份查看"
|
||||||
|
aria-label="按日期或月份查看"
|
||||||
|
@click="archivePanelOpen = !archivePanelOpen"
|
||||||
|
>
|
||||||
|
<NIcon size="20" style="display: inline-flex; vertical-align: middle;">
|
||||||
|
<Calendar />
|
||||||
|
</NIcon>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="archivePanelOpen"
|
||||||
|
class="absolute bottom-16 right-0 w-72 border border-slate-200 bg-white p-4 shadow-[8px_8px_0_0_rgba(15,23,42,0.08)]"
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<p class="text-sm font-semibold text-slate-900">历史位置</p>
|
||||||
|
<button type="button" class="oc-text-button text-xs" @click="returnRealtime">
|
||||||
|
返回实时
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-choice-button"
|
||||||
|
:class="archiveKind === 'day' ? 'oc-choice-button--active' : 'oc-choice-button--inactive'"
|
||||||
|
:aria-pressed="archiveKind === 'day'"
|
||||||
|
@click="archiveKind = 'day'"
|
||||||
|
>
|
||||||
|
某天
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-choice-button"
|
||||||
|
:class="archiveKind === 'month' ? 'oc-choice-button--active' : 'oc-choice-button--inactive'"
|
||||||
|
:aria-pressed="archiveKind === 'month'"
|
||||||
|
@click="archiveKind = 'month'"
|
||||||
|
>
|
||||||
|
某月
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
v-if="archiveKind === 'day'"
|
||||||
|
v-model="archiveDay"
|
||||||
|
type="date"
|
||||||
|
class="oc-field-control mt-3"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-else
|
||||||
|
v-model="archiveMonth"
|
||||||
|
type="month"
|
||||||
|
class="oc-field-control mt-3"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="oc-panel-button oc-panel-button--sky mt-3 w-full"
|
||||||
|
@click="applyArchiveFilter"
|
||||||
|
>
|
||||||
|
查看拍摄位置
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ImageDetailModal
|
||||||
|
v-if="previewCloud"
|
||||||
|
:open="!!previewCloud"
|
||||||
|
:image-url="previewCloud.imageUrl"
|
||||||
|
:thumbnail-url="previewCloud.thumbnailUrl"
|
||||||
|
:image-alt="previewCloud.cloudTypeName"
|
||||||
|
:title="previewCloud.cloudTypeName"
|
||||||
|
:subtitle="`上传者:${previewCloud.username}`"
|
||||||
|
:badge-label="rarityMeta[previewCloud.rarity].label"
|
||||||
|
:badge-class="rarityMeta[previewCloud.rarity].chip"
|
||||||
|
@close="previewCloud = null"
|
||||||
|
>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div class="rounded-2xl bg-slate-50 p-4">
|
||||||
|
<p class="oc-section-label">上传时间</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(previewCloud.createdAt) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl bg-slate-50 p-4">
|
||||||
|
<p class="oc-section-label">拍摄时间</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(previewCloud.capturedAt) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl bg-slate-50 p-4">
|
||||||
|
<p class="oc-section-label">位置</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">{{ previewCloud.locationName || '未填写位置名称' }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-2xl bg-slate-50 p-4">
|
||||||
|
<p class="oc-section-label">模糊化经纬度</p>
|
||||||
|
<p class="mt-2 text-sm font-medium text-slate-900">
|
||||||
|
纬度 {{ formatCoordinate(previewCloud.latitude) }} / 经度 {{ formatCoordinate(previewCloud.longitude) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-5 rounded-[28px] border border-slate-200 bg-white p-5">
|
||||||
|
<p class="oc-section-label">图片说明</p>
|
||||||
|
<p class="mt-3 text-sm leading-7 text-slate-700">
|
||||||
|
{{ previewCloud.description || '上传者没有留下额外说明。' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<MiniLocationMap
|
||||||
|
:latitude="previewCloud.latitude"
|
||||||
|
:longitude="previewCloud.longitude"
|
||||||
|
:location-name="previewCloud.locationName"
|
||||||
|
/>
|
||||||
|
</ImageDetailModal>
|
||||||
|
|
||||||
|
<QuickUploadModal
|
||||||
|
:open="quickUploadOpen"
|
||||||
|
@close="closeQuickUpload"
|
||||||
|
@uploaded="handleQuickUploaded"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
:deep(.amap-controlbar) {
|
||||||
|
transform: translateX(50%) scale(1);
|
||||||
|
transform-origin: bottom right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-archive-button.map-archive-button {
|
||||||
|
border-radius: 9999px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-time-slider:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-time-slider {
|
||||||
|
appearance: none;
|
||||||
|
background: transparent;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-time-slider::-webkit-slider-runnable-track {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid rgba(186, 230, 253, 0.85);
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
box-shadow: 0 3px 12px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-time-slider::-webkit-slider-thumb {
|
||||||
|
appearance: none;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
margin-top: -10px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 9999px;
|
||||||
|
background-color: #e0f2fe;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='%230ea5e9' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6.5 18a4.5 4.5 0 1 1 .5-8.97A6 6 0 0 1 18.35 11.5A3.5 3.5 0 1 1 18 18Z'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: 18px 18px;
|
||||||
|
box-shadow: 0 2px 10px rgba(14, 165, 233, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-time-slider::-moz-range-track {
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 9999px;
|
||||||
|
border: 1px solid rgba(186, 230, 253, 0.85);
|
||||||
|
background: rgba(255, 255, 255, 0.82);
|
||||||
|
box-shadow: 0 3px 12px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-time-slider::-moz-range-thumb {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.95);
|
||||||
|
border-radius: 9999px;
|
||||||
|
background-color: #e0f2fe;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='%230ea5e9' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6.5 18a4.5 4.5 0 1 1 .5-8.97A6 6 0 0 1 18.35 11.5A3.5 3.5 0 1 1 18 18Z'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: center;
|
||||||
|
background-size: 18px 18px;
|
||||||
|
box-shadow: 0 2px 10px rgba(14, 165, 233, 0.34);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-timeline-controls-enter-active,
|
||||||
|
.map-timeline-controls-leave-active {
|
||||||
|
overflow: hidden;
|
||||||
|
transition:
|
||||||
|
opacity 180ms ease,
|
||||||
|
transform 180ms cubic-bezier(0.22, 1, 0.36, 1),
|
||||||
|
max-width 180ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-timeline-controls-enter-from,
|
||||||
|
.map-timeline-controls-leave-to {
|
||||||
|
max-width: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-8px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-timeline-controls-enter-to,
|
||||||
|
.map-timeline-controls-leave-from {
|
||||||
|
max-width: 560px;
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { NAlert, NButton, NCard, NInput, useMessage } from 'naive-ui'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
const message = useMessage()
|
||||||
|
|
||||||
|
const usernameDraft = ref('')
|
||||||
|
const newPassword = ref('')
|
||||||
|
const confirmNewPassword = ref('')
|
||||||
|
const error = ref('')
|
||||||
|
const success = ref('')
|
||||||
|
const usernameSaving = ref(false)
|
||||||
|
const passwordSaving = ref(false)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
usernameDraft.value = authStore.profile?.username || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
async function saveUsername() {
|
||||||
|
const nextUsername = usernameDraft.value.trim()
|
||||||
|
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
|
||||||
|
if (!nextUsername) {
|
||||||
|
error.value = '昵称不能为空。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (nextUsername.length < 2) {
|
||||||
|
error.value = '昵称至少需要 2 个字符。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (nextUsername === authStore.profile?.username) {
|
||||||
|
success.value = '昵称没有变化。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
usernameSaving.value = true
|
||||||
|
try {
|
||||||
|
await authStore.updateUsername(nextUsername)
|
||||||
|
success.value = '昵称已更新。'
|
||||||
|
message.success('昵称已更新')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '昵称更新失败'
|
||||||
|
} finally {
|
||||||
|
usernameSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePassword() {
|
||||||
|
error.value = ''
|
||||||
|
success.value = ''
|
||||||
|
|
||||||
|
if (newPassword.value.length < 8) {
|
||||||
|
error.value = '新密码至少需要 8 位。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (newPassword.value !== confirmNewPassword.value) {
|
||||||
|
error.value = '两次输入的新密码不一致。'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
passwordSaving.value = true
|
||||||
|
try {
|
||||||
|
await authStore.updatePassword(newPassword.value)
|
||||||
|
newPassword.value = ''
|
||||||
|
confirmNewPassword.value = ''
|
||||||
|
success.value = '密码已更新。'
|
||||||
|
message.success('密码已更新')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = e instanceof Error ? e.message : '密码更新失败'
|
||||||
|
} finally {
|
||||||
|
passwordSaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)] px-4 py-10">
|
||||||
|
<div class="mx-auto max-w-4xl">
|
||||||
|
<RouterLink to="/profile">
|
||||||
|
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">← 返回个人主页</NButton>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<p class="oc-page-eyebrow">Account Settings</p>
|
||||||
|
<h1 class="oc-page-title">个人资料设置</h1>
|
||||||
|
<p class="oc-page-description">
|
||||||
|
修改公开昵称和登录密码。昵称会显示在画廊、图鉴和地图中,且不能与其他用户重复。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 grid gap-6">
|
||||||
|
<NAlert
|
||||||
|
v-if="error"
|
||||||
|
type="error"
|
||||||
|
:show-icon="false"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</NAlert>
|
||||||
|
<NAlert
|
||||||
|
v-if="success"
|
||||||
|
type="success"
|
||||||
|
:show-icon="false"
|
||||||
|
:bordered="false"
|
||||||
|
>
|
||||||
|
{{ success }}
|
||||||
|
</NAlert>
|
||||||
|
|
||||||
|
<NCard class="oc-panel-card border border-slate-200 bg-white shadow-sm" :bordered="false">
|
||||||
|
<div class="grid gap-5 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-slate-900">公开昵称</h2>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-slate-500">
|
||||||
|
用于公开展示你的上传记录。保存前会检查是否与其他用户重复。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto]">
|
||||||
|
<NInput
|
||||||
|
v-model:value="usernameDraft"
|
||||||
|
placeholder="输入新的昵称"
|
||||||
|
maxlength="32"
|
||||||
|
show-count
|
||||||
|
/>
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--teal"
|
||||||
|
:loading="usernameSaving"
|
||||||
|
@click="saveUsername"
|
||||||
|
>
|
||||||
|
保存昵称
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NCard>
|
||||||
|
|
||||||
|
<NCard class="oc-panel-card border border-slate-200 bg-white shadow-sm" :bordered="false">
|
||||||
|
<div class="grid gap-5 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-2xl font-bold text-slate-900">登录密码</h2>
|
||||||
|
<p class="mt-2 text-sm leading-6 text-slate-500">
|
||||||
|
修改后下次登录需要使用新密码。建议使用至少 8 位且不易猜测的密码。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<NInput
|
||||||
|
v-model:value="newPassword"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="新密码"
|
||||||
|
/>
|
||||||
|
<NInput
|
||||||
|
v-model:value="confirmNewPassword"
|
||||||
|
type="password"
|
||||||
|
show-password-on="click"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="确认新密码"
|
||||||
|
/>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--teal"
|
||||||
|
:loading="passwordSaving"
|
||||||
|
@click="savePassword"
|
||||||
|
>
|
||||||
|
保存密码
|
||||||
|
</NButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user