Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dc456e22c | ||
|
|
3aed4005f7 | ||
|
|
b8dc2a649e | ||
|
|
9f1d87910e | ||
|
|
67316085dd | ||
|
|
c3153bb998 | ||
|
|
834574b39e | ||
|
|
45a4e834dd |
@@ -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
|
|
||||||
}
|
|
||||||
@@ -175,4 +175,4 @@ map.on('click', function() {
|
|||||||
3. **内存管理**: 组件销毁前解绑事件,防止内存泄漏
|
3. **内存管理**: 组件销毁前解绑事件,防止内存泄漏
|
||||||
4. **事件冒泡**: 覆盖物事件会冒泡到地图,必要时阻止冒泡
|
4. **事件冒泡**: 覆盖物事件会冒泡到地图,必要时阻止冒泡
|
||||||
5. **触摸事件**: 移动端需要监听 touch 系列事件
|
5. **触摸事件**: 移动端需要监听 touch 系列事件
|
||||||
6. **异步加载**: complete 事件在地图资源加载完成后触发
|
6. **异步加载**: complete 事件在地图资源加载完成后触发
|
||||||
@@ -303,4 +303,4 @@ map.clearInfoWindow();
|
|||||||
2. **自定义样式**: `isCustom: true` 时不显示默认的边框和关闭按钮
|
2. **自定义样式**: `isCustom: true` 时不显示默认的边框和关闭按钮
|
||||||
3. **偏移量**: 根据标记图标大小调整 offset,避免遮挡
|
3. **偏移量**: 根据标记图标大小调整 offset,避免遮挡
|
||||||
4. **自动调整**: `autoMove: true` 可以自动调整地图视野使窗体可见
|
4. **自动调整**: `autoMove: true` 可以自动调整地图视野使窗体可见
|
||||||
5. **事件冒泡**: 窗体内的点击事件需要阻止冒泡,否则可能触发地图点击事件
|
5. **事件冒泡**: 窗体内的点击事件需要阻止冒泡,否则可能触发地图点击事件
|
||||||
@@ -244,4 +244,4 @@ export default function MapComponent() {
|
|||||||
|
|
||||||
return <div id="map-container" style={{ width: '100%', height: '500px' }} />;
|
return <div id="map-container" style={{ width: '100%', height: '500px' }} />;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -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,235 +1,354 @@
|
|||||||
---
|
---
|
||||||
name: opencloud-style
|
name: opencloud-style
|
||||||
description: "Maintain OpenCloud's current visual system when changing Vue templates, Tailwind classes, CSS, Naive UI theming, layout, typography, colors, buttons, cards, modals, maps, or responsive behavior. Preserve the clean sky-atlas language: airy sky-blue surfaces, fresh eucalyptus green, square atlas-like panels, pixel-cloud identity, and hard offset shadows."
|
description: "Use when modifying OpenCloud visual design, Tailwind classes, component styling, layout, typography, or page-level visual hierarchy. Ensures the project keeps its current clean sky-atlas visual language — airy, fresh, bright, observational — and avoids inconsistent dark or purple defaults."
|
||||||
---
|
---
|
||||||
|
|
||||||
# OpenCloud Visual Style
|
# OpenCloud Visual Style
|
||||||
|
|
||||||
Treat the current design tokens and shared `oc-*` classes as the visual contract. Keep the interface airy, bright, observational, and slightly editorial—like a field atlas for clouds.
|
Use this skill whenever changing OpenCloud visual design, component styling, Tailwind classes, layout, typography, or any page-level appearance.
|
||||||
|
|
||||||
## Start From the Sources of Truth
|
|
||||||
|
|
||||||
Inspect these files before making a material visual change:
|
|
||||||
|
|
||||||
1. `src/styles/tokens.css` — palette remapping, semantic colors, gradients, shadows, motion.
|
|
||||||
2. `src/styles/base.css` — body baseline and the global square-corner rule.
|
|
||||||
3. `src/styles/components.css` — reusable page, surface, form, and interaction contracts.
|
|
||||||
4. `src/lib/theme.ts` — Naive UI overrides; keep semantic colors aligned with `tokens.css`.
|
|
||||||
5. `docs/style-system.md` — naming and implementation guidance.
|
|
||||||
6. `scripts/check-style-system.mjs` — prohibited legacy patterns.
|
|
||||||
|
|
||||||
Use `src/style.css` only as the stylesheet assembly point. Put stable values in `tokens.css`, cross-page patterns in `components.css`, and truly component-specific behavior in scoped styles.
|
|
||||||
|
|
||||||
## Visual Direction
|
## Visual Direction
|
||||||
|
|
||||||
- Use white, slate-50, sky-50, and soft translucent white for primary surfaces.
|
OpenCloud should feel like a clean sky atlas: airy, fresh, bright, and observational.
|
||||||
- Use sky blue for atmosphere, page heroes, upload/create actions, and the pixel-cloud brand surface.
|
|
||||||
- Use fresh eucalyptus green for identity, navigation selection, account access, confirmation, and interactive hover states.
|
|
||||||
- Use leaf green only for explicit success, approved, public, or completed states.
|
|
||||||
- Use amber for pending/review/uncommon states and rose/red for destructive/rejected states.
|
|
||||||
- Keep slate-900 text dominant. Do not turn normal page sections into dark panels.
|
|
||||||
- Avoid purple, indigo, generic gray utilities, neon green, and decorative color proliferation.
|
|
||||||
|
|
||||||
## Current Palette
|
- Base surfaces: `white`, `slate-50`, `sky-50`, soft translucent white (`bg-white/80`, `bg-white/88`).
|
||||||
|
- Main accent: teal/cyan for navigation, identity, account surfaces.
|
||||||
|
- Secondary accent: sky blue for upload and action highlights.
|
||||||
|
- Supporting accent: amber only for rarity, badges, pending/review states.
|
||||||
|
- Danger accent: rose/red only for destructive or rejected states.
|
||||||
|
- Avoid purple as a default accent.
|
||||||
|
- Avoid black selected states except for text or rare high-contrast badges.
|
||||||
|
|
||||||
Tailwind `teal-*` and `emerald-*` are compatibility names remapped in `tokens.css`; they are not Tailwind defaults.
|
---
|
||||||
|
|
||||||
| Role | Main | Dark | Light surface |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| Eucalyptus brand/interaction | `#34745d` | `#274c40` | `#f2faf6` |
|
|
||||||
| Leaf success | `#4d7f3b` | `#35522c` | `#f4faef` |
|
|
||||||
| Sky information/action | `#0369a1` | `#075985` | `#e0f2fe` |
|
|
||||||
| Canvas | `#eef4f8` | — | `#f8fbfd` |
|
|
||||||
| Primary text | `#0f172a` | — | — |
|
|
||||||
| Secondary text | `#334155` | — | — |
|
|
||||||
| Muted text | `#64748b` | — | — |
|
|
||||||
| Border | `#d9e3ee` | — | — |
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
|
|
||||||
- Do not use Tailwind `green-*` or `lime-*`.
|
|
||||||
- Do not reintroduce Tailwind's vivid teal values such as `#14b8a6` or `#0f766e`.
|
|
||||||
- Use semantic tokens or existing `teal-*`/`emerald-*` compatibility utilities instead of new hard-coded greens.
|
|
||||||
- Keep green text and actions dark enough for readable contrast; use pale green primarily as a surface.
|
|
||||||
- Synchronize changes to brand or success colors between `tokens.css` and `src/lib/theme.ts`.
|
|
||||||
|
|
||||||
## Shape and Border Rules
|
|
||||||
|
|
||||||
The product is square by default. `src/styles/base.css` forces Tailwind `rounded*` utilities to `0px`.
|
|
||||||
|
|
||||||
- Use square corners for page panels, cards, buttons, inputs, navigation, dropdowns, map controls, and ordinary modals.
|
|
||||||
- Use a one-pixel slate or semantic-color border to define surfaces.
|
|
||||||
- Do not add rounded rectangles as a generic polish treatment.
|
|
||||||
|
|
||||||
Documented exceptions:
|
|
||||||
|
|
||||||
- Circular close/overlay controls in `ImageDetailModal`.
|
|
||||||
- Circular location/marker dots and AMap-generated marker bubbles.
|
|
||||||
- The media-focused `ImageDetailModal` shell.
|
|
||||||
- AMap-generated information cards and the deliberately organic timeline control details in `MapView`.
|
|
||||||
|
|
||||||
Do not treat map floating buttons as exceptions: `.oc-map-button` is a square `40px × 40px` control with `0px` radius and a hard shadow, including its disabled state.
|
|
||||||
|
|
||||||
## Shadows and Interaction
|
|
||||||
|
|
||||||
Use hard offset shadows with zero blur as the signature depth treatment:
|
|
||||||
|
|
||||||
| Token | Value | Typical use |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `--oc-shadow-xs` | `3px 3px` | Small controls and map buttons |
|
|
||||||
| `--oc-shadow-sm` | `4px 4px` | Header logo and compact CTAs |
|
|
||||||
| `--oc-shadow-md` | `6px 6px` | Standard cards and navigation |
|
|
||||||
| `--oc-shadow-lg` | `8px 8px` | Menus and floating panels |
|
|
||||||
| `--oc-shadow-xl` | `12px 12px` | Large system/auth cards |
|
|
||||||
| `--oc-shadow-sky` | sky-tinted `4px 4px` | Sky actions |
|
|
||||||
| `--oc-shadow-primary` | eucalyptus-tinted `4px 4px` | Green interactions |
|
|
||||||
|
|
||||||
- On hover, interactive controls may move `translate(-1px, -1px)` and grow the hard shadow.
|
|
||||||
- On active, return the control to its origin.
|
|
||||||
- Preserve a visible `2px` sky focus outline with offset.
|
|
||||||
- Do not replace the hard-shadow language with generic soft shadows across ordinary UI.
|
|
||||||
- Soft/blurred shadows remain acceptable for media overlays, AMap internals, and image hover depth.
|
|
||||||
|
|
||||||
## Backgrounds and Brand Identity
|
|
||||||
|
|
||||||
Use the semantic gradients in `tokens.css`:
|
|
||||||
|
|
||||||
- App shell: a subtle eucalyptus radial glow over the pale blue-gray canvas.
|
|
||||||
- Standard page hero: `#e0f2fe` to `#f8fafc`.
|
|
||||||
- Soft page hero: `#f2faf6` to `#f8fafc`.
|
|
||||||
- App grid: subtle slate lines at `32px`.
|
|
||||||
|
|
||||||
The header identity is fixed:
|
|
||||||
|
|
||||||
- Use the pixel-style cloud/sun SVG, not an emoji or unrelated icon set.
|
|
||||||
- Put the icon in a square `40px` container with `bg-sky-100`, `border-sky-200`, and a `4px 4px` neutral hard shadow.
|
|
||||||
- Keep “LIVE SKY ATLAS” as the small tracked eyebrow and “OpenCloud” as the bold brand name.
|
|
||||||
- Keep the header white/translucent; green belongs to navigation and account interaction, not the logo background.
|
|
||||||
|
|
||||||
## Typography
|
## Typography
|
||||||
|
|
||||||
Use:
|
**Font stack** (`src/style.css`, `App.vue`):
|
||||||
|
```
|
||||||
```text
|
|
||||||
"IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif
|
"IBM Plex Sans", "Noto Sans SC", "PingFang SC", sans-serif
|
||||||
```
|
```
|
||||||
|
Monospace: `"IBM Plex Mono", "SFMono-Regular", monospace`
|
||||||
|
|
||||||
Use IBM Plex Mono for small atlas-like labels when a monospace accent is appropriate.
|
**Type scale:**
|
||||||
|
|
||||||
- Page eyebrow: `text-sm`, uppercase, `tracking-[0.24em]`, sky-700.
|
| Size | Use |
|
||||||
- Page title: `text-4xl`, bold, slate-900.
|
|------|-----|
|
||||||
- Descriptions: `text-sm`, `leading-7`, slate-600.
|
| `text-[11px]` | Header subtitle, heatmap weekday labels |
|
||||||
- Section/stat labels: `text-xs`, uppercase, tracking `0.18em–0.20em`, slate-500.
|
| `text-xs` | Uppercase section labels, captions, metadata, tags, help text |
|
||||||
- Header brand eyebrow: `text-[11px]`, uppercase, `tracking-[0.22em]`.
|
| `text-sm` | Body text, descriptions, nav items, form labels, detail sections |
|
||||||
- Reserve `font-black` and very large display text for authentication hero statements.
|
| `text-base` | Hero description paragraphs |
|
||||||
|
| `text-lg` | Logo brand name, card titles, item counters |
|
||||||
|
| `text-xl` | Modal titles, subsection headings, stat cards |
|
||||||
|
| `text-2xl` | Subsection headings, stat values, settings section titles |
|
||||||
|
| `text-3xl` | Stat values, avatar initials |
|
||||||
|
| `text-4xl` | Page H1 headings (all page heroes) |
|
||||||
|
| `text-5xl` | Login/Register hero text only |
|
||||||
|
| `text-6xl`+ | Decorative single characters, error page emoji |
|
||||||
|
|
||||||
## Layout and Responsive Behavior
|
**Font weights:**
|
||||||
|
- `font-medium` (500) — body text, nav items, form labels, tags
|
||||||
|
- `font-semibold` (600) — modal titles, item names, emphasis
|
||||||
|
- `font-bold` (700) — headings, stat values, card titles
|
||||||
|
- `font-black` (900) — Login/Register hero only, with `leading-[1.05]`
|
||||||
|
|
||||||
Prefer the shared page structure:
|
**Letter tracking:**
|
||||||
|
|
||||||
```html
|
| Tracking | Where |
|
||||||
<section class="oc-page-hero">
|
|----------|-------|
|
||||||
<div class="oc-container oc-container--content oc-page-hero__inner">
|
| `tracking-[0.12em]` | Nav links, upload/button text (uppercase) |
|
||||||
<p class="oc-page-eyebrow">Cloud Upload</p>
|
| `tracking-[0.18em]` | Info panel section labels |
|
||||||
<h1 class="oc-page-title">上传云图</h1>
|
| `tracking-[0.20em]` | Stat card labels |
|
||||||
<p class="oc-page-description">页面描述</p>
|
| `tracking-[0.22em]` | Header subtitle, modal subtitle, quick-upload header |
|
||||||
</div>
|
| `tracking-[0.24em]` | Page hero section labels |
|
||||||
</section>
|
| `tracking-[0.26em]` | Login/Register hero labels |
|
||||||
|
|
||||||
<main class="oc-container oc-container--content oc-page-content">
|
---
|
||||||
<!-- content -->
|
|
||||||
</main>
|
## Border Radius
|
||||||
|
|
||||||
|
**All border-radius is `0px` by default** — enforced via Naive UI theme override and global CSS:
|
||||||
|
|
||||||
|
```css
|
||||||
|
[class~='rounded'], [class*='rounded-'] { border-radius: 0 !important; }
|
||||||
```
|
```
|
||||||
|
|
||||||
Container widths:
|
Only exceptions (all `!important` or explicit inline):
|
||||||
|
- Close/map buttons: `rounded-full` / `border-radius: 9999px`
|
||||||
|
- ImageDetailModal shell: `rounded-[30px]`
|
||||||
|
- MapView info panel cards: `rounded-2xl`
|
||||||
|
- MiniLocationMap marker dot: `rounded-full`
|
||||||
|
- UploadView native inputs: `rounded-lg`
|
||||||
|
|
||||||
- `oc-container--narrow` — `max-w-4xl`
|
---
|
||||||
- `oc-container--content` — `max-w-6xl`
|
|
||||||
- `oc-container--wide` — `max-w-7xl`
|
|
||||||
|
|
||||||
Use `px-4 sm:px-6 lg:px-8` for page gutters. Validate at both desktop and a real `375px` mobile viewport. Do not accept horizontal overflow.
|
## Shadows
|
||||||
|
|
||||||
## Reusable Component Contracts
|
**Signature pattern: hard offset box shadows** — `shadow-[Xpx_Xpx_0_0_rgba(...)]` with zero blur.
|
||||||
|
|
||||||
Use existing semantic classes before writing repeated Tailwind bundles:
|
| Shadow | Offset | Use |
|
||||||
|
|--------|--------|-----|
|
||||||
|
| `shadow-[3px_3px_0_0_rgba(...)]` | 3/3 | Small header buttons, login button |
|
||||||
|
| `shadow-[4px_4px_0_0_rgba(...)]` | 4/4 | Logo box, primary CTAs, hidden header, profile avatar |
|
||||||
|
| `shadow-[6px_6px_0_0_rgba(...)]` | 6/6 | Navigation bar, search box |
|
||||||
|
| `shadow-[8px_8px_0_0_rgba(...)]` | 8/8 | Dropdown menus, archive panel, account card |
|
||||||
|
| `shadow-[10px_10px_0_0_rgba(...)]` | 10/10 | Login/Register hero sections |
|
||||||
|
| `shadow-[12px_12px_0_0_rgba(...)]` | 12/12 | Login/Register cards, CommunityView section, AuthConfirm card |
|
||||||
|
|
||||||
- Page structure: `oc-page-hero`, `oc-page-hero--soft`, `oc-container`, `oc-page-content`.
|
**Shadow colors by context:**
|
||||||
- Typography: `oc-page-eyebrow`, `oc-page-title`, `oc-page-description`, `oc-section-label`, `oc-stat-label`, `oc-stat-value`, `oc-meta`.
|
- `rgba(15,23,42,...)` — slate-900, neutral (most common)
|
||||||
- Forms: `oc-field-label`, `oc-field-required`, `oc-field-control`, `oc-field-help`, `oc-field-error`.
|
- `rgba(14,165,233,...)` — sky-500 (sky-colored buttons)
|
||||||
- Surfaces: `oc-surface`, `oc-inset-panel`, `oc-panel-card`, `oc-panel-card-soft`, `oc-empty-card`.
|
- `rgba(20,184,166,...)` — teal-500 (teal accents)
|
||||||
- Interaction: `oc-primary-button`, `oc-panel-button`, `oc-header-button`, `oc-icon-button`, `oc-map-button`, `oc-choice-button`, `oc-text-button`, `oc-menu-item`, `oc-nav-link`.
|
|
||||||
|
|
||||||
Class names describe function. Keep modifiers attached to their base class, for example:
|
**Standard Tailwind shadows** also used sparingly:
|
||||||
|
- `shadow-sm` — stat cards, gallery cards, encyclopedia cards
|
||||||
|
- `shadow-lg` — cloud card hover lift
|
||||||
|
- `shadow-2xl` — modal shells
|
||||||
|
|
||||||
```html
|
---
|
||||||
<NButton type="default" class="oc-panel-button oc-panel-button--sky">
|
|
||||||
保存
|
## Gradients
|
||||||
</NButton>
|
|
||||||
|
All page backgrounds use gradients — avoid flat single-color backgrounds.
|
||||||
|
|
||||||
|
| Gradient | Where |
|
||||||
|
|----------|-------|
|
||||||
|
| `linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)` | Page hero sections (standard) |
|
||||||
|
| `linear-gradient(180deg,#f0fdfa_0%,#f8fafc_100%)` | Community page, soft app pages |
|
||||||
|
| `linear-gradient(135deg,#f0fdfa_0%,#fff_48%,#eff6ff_100%)` | Login hero |
|
||||||
|
| `linear-gradient(135deg,#eff6ff_0%,#fff_42%,#f0fdfa_100%)` | Register hero |
|
||||||
|
| `linear-gradient(180deg,#f8fafc_0%,#eef6ff_55%,#f8fafc_100%)` | Admin page |
|
||||||
|
| `linear-gradient(135deg,#ecfeff_0%,#ccfbf1_100%)` | Brand chip/avatar |
|
||||||
|
| `linear-gradient(180deg,#f0fdfa_0%,#fff_100%)` | Account card header |
|
||||||
|
|
||||||
|
**App shell background** (`App.vue`):
|
||||||
|
```
|
||||||
|
radial-gradient(circle_at_top_left, rgba(12,148,136,0.12), transparent 28%)
|
||||||
|
linear-gradient(180deg, #f8fbfd 0%, #eef4f8 100%)
|
||||||
|
```
|
||||||
|
Plus a dotted grid overlay: `bg-[size:32px_32px] opacity-35`.
|
||||||
|
|
||||||
|
**Body background** (`style.css`):
|
||||||
|
```
|
||||||
|
linear-gradient(180deg, rgba(15,23,42,0.02), rgba(15,23,42,0)), #eef4f8
|
||||||
```
|
```
|
||||||
|
|
||||||
Available shared variants:
|
---
|
||||||
|
|
||||||
- Primary: `oc-primary-button--teal`, `oc-primary-button--sky`
|
## Layout
|
||||||
- Panel: `--neutral`, `--sky`, `--teal`, `--amber`, `--danger`
|
|
||||||
- Header: `--neutral`, `--sky`, `--teal`
|
|
||||||
- Icon: `--small`, `--tiny`, `--sky`, `--danger`
|
|
||||||
- Text/menu: muted, amber, and danger variants where defined
|
|
||||||
|
|
||||||
## Button Semantics
|
**Page max-widths:**
|
||||||
|
- `max-w-7xl` — AppHeader, GalleryView, AdminView
|
||||||
|
- `max-w-6xl` — UploadView, ProfileView, EncyclopediaView
|
||||||
|
- `max-w-4xl` — ProfileSettingsView, CommunityView
|
||||||
|
- `max-w-3xl` — QuickUploadModal, MapPickerModal, AuthConfirmView
|
||||||
|
- `max-w-2xl` — ResetPasswordView, NotFoundView, ForbiddenView, CloudEditModal
|
||||||
|
|
||||||
- Teal/eucalyptus: login, account access, active toggle, confirm.
|
**Page hero pattern** (consistent across all views):
|
||||||
- Sky: register, create, upload, forward action.
|
```
|
||||||
- Neutral: cancel and utility actions.
|
border-b border-sky-100 bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)]
|
||||||
- Amber: pending/review/state-toggle actions.
|
max-w-{6xl|7xl} mx-auto px-4 py-10
|
||||||
- Danger: deletion and destructive actions.
|
text-sm font-medium uppercase tracking-[0.24em] text-sky-700 (subtitle)
|
||||||
|
h1 mt-3 text-4xl font-bold text-slate-900 (title)
|
||||||
|
p mt-4 max-w-2xl text-sm leading-7 text-slate-600 (description)
|
||||||
|
```
|
||||||
|
|
||||||
For Naive UI buttons:
|
**Horizontal padding:** `px-4` / `sm:px-6` / `lg:px-8`
|
||||||
|
|
||||||
- Use `type="default"` with the shared `oc-*` class when a custom visual variant is intended.
|
**Column systems:**
|
||||||
- Do not rely on Naive UI default primary/secondary styling for page CTAs or panel actions.
|
- Gallery masonry: `columns-2 gap-3 md:columns-3 xl:columns-4 [column-gap:0.75rem]`
|
||||||
- Keep `src/lib/theme.ts` as the global Naive UI contract; it is mounted by `App.vue`.
|
- Card grids: `gap-4 md:grid-cols-2 xl:grid-cols-3` or `gap-4 md:grid-cols-4`
|
||||||
- Use dark text on light primary surfaces. Do not assume primary buttons should have white text.
|
- Two-column splits: `lg:grid-cols-[1.1fr_0.9fr]`, `lg:grid-cols-[1.4fr_0.9fr]`
|
||||||
|
|
||||||
## Icons, Cards, and Modals
|
**Gap scale:** `gap-4`, `gap-5`, `gap-6` for card grids; `gap-2` for nav items; `space-y-6`/`space-y-8` between sections.
|
||||||
|
|
||||||
- Use `@vicons/tabler` through `NIcon` for interface icons.
|
---
|
||||||
- Preserve the pixel-cloud SVG for the primary brand mark.
|
|
||||||
- Use one bordered surface and one appropriate hard-shadow token for ordinary cards.
|
|
||||||
- Keep modal structure square unless it is a documented media exception.
|
|
||||||
- Use `oc-overlay-button` for controls over dark media and `oc-icon-button` for controls on light surfaces.
|
|
||||||
- Keep semantic status colors stable: emerald success, amber pending, rose rejected/destructive, slate hidden/neutral.
|
|
||||||
|
|
||||||
## Implementation Workflow
|
## Component Conventions
|
||||||
|
|
||||||
1. Inspect the source-of-truth files and the target component.
|
### Buttons
|
||||||
2. Reuse an existing semantic class if it expresses the intent.
|
|
||||||
3. Use Tailwind only for one-off layout and responsive composition.
|
**Primary CTA (upload, action):**
|
||||||
4. Add a token before repeating a stable raw value.
|
```
|
||||||
5. Add a shared `oc-*` class only for cross-page patterns or product-wide rules.
|
inline-flex h-8 items-center border border-sky-200 bg-sky-100 px-3
|
||||||
6. Keep feature-specific AMap, slider, media, or transition details scoped to the component.
|
text-sm font-medium uppercase tracking-[0.12em] text-sky-800
|
||||||
7. When changing a global color, audit all matching Tailwind utilities, hard-coded hex/RGB values, gradients, shadows, canvas drawing, and Naive UI tokens.
|
shadow-[4px_4px_0_0_rgba(14,165,233,0.14)]
|
||||||
8. Preserve unrelated user changes in the worktree.
|
transition-colors hover:bg-sky-50 hover:text-sky-900
|
||||||
|
```
|
||||||
|
Mobile: `h-8 md:h-10 md:px-4`
|
||||||
|
|
||||||
|
**Secondary/neutral:**
|
||||||
|
```
|
||||||
|
inline-flex h-8 items-center border border-slate-200 bg-white/80 px-3
|
||||||
|
text-sm font-medium text-slate-700
|
||||||
|
shadow-[3px_3px_0_0_rgba(15,23,42,0.06)]
|
||||||
|
hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800
|
||||||
|
```
|
||||||
|
|
||||||
|
**Danger/delete:** `text-rose-600 hover:bg-rose-50 hover:text-rose-700`
|
||||||
|
|
||||||
|
**Map floating buttons:** `w-10 h-10 bg-white rounded-lg shadow-md flex items-center justify-center hover:bg-gray-50`
|
||||||
|
|
||||||
|
**Naive UI buttons** use `secondary strong` as default; `type="primary"` for primary actions; `block size="large"` for block forms.
|
||||||
|
|
||||||
|
### Cards
|
||||||
|
|
||||||
|
**Standard card:**
|
||||||
|
```
|
||||||
|
border bg-white text-left shadow-sm
|
||||||
|
transition-all hover:-translate-y-1 hover:shadow-lg
|
||||||
|
```
|
||||||
|
|
||||||
|
**Encyclopedia unlocked variant:** `border-amber-300 shadow-amber-100/60`
|
||||||
|
|
||||||
|
**Naive UI NCard:** `class="border border-slate-200 shadow-sm"`
|
||||||
|
|
||||||
|
### Modals
|
||||||
|
|
||||||
|
**Overlay:** `fixed inset-0 z-[110|130] bg-slate-950/60` (or `bg-black/85` for image detail)
|
||||||
|
|
||||||
|
**Shell:** `bg-white shadow-2xl` with max-w matching content width. ImageDetailModal uses `rounded-[30px]`; others are square.
|
||||||
|
|
||||||
|
**Close button:** Circular (`rounded-full`, `border-radius: 9999px`), semi-transparent background.
|
||||||
|
|
||||||
|
**Sections:** `border-b border-slate-200` separates header/body/footer.
|
||||||
|
|
||||||
|
### Inputs & Forms
|
||||||
|
|
||||||
|
**Native HTML inputs:**
|
||||||
|
```
|
||||||
|
border border-slate-300 px-3 py-2.5 text-sm
|
||||||
|
focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500
|
||||||
|
```
|
||||||
|
|
||||||
|
**Labels:** `block text-sm font-medium text-slate-700 mb-1`
|
||||||
|
|
||||||
|
**Required marker:** `<span class="text-red-500">*</span>`
|
||||||
|
|
||||||
|
**Error text:** `text-sm text-red-500 mt-1`
|
||||||
|
|
||||||
|
**Checkboxes:** `h-5 w-5 border-slate-300 text-sky-500 focus:ring-sky-500`
|
||||||
|
|
||||||
|
### Tags & Badges
|
||||||
|
|
||||||
|
All use `inline-flex border px-3 py-1 text-xs font-medium` with color variants:
|
||||||
|
|
||||||
|
- `common`: `bg-sky-100 text-sky-700 border-sky-200`
|
||||||
|
- `uncommon`: `bg-amber-100 text-amber-700 border-amber-200`
|
||||||
|
- `rare`: `bg-rose-100 text-rose-700 border-rose-200`
|
||||||
|
- `approved`/`success`: `bg-emerald-100 text-emerald-700 border-emerald-200`
|
||||||
|
- `pending`: `bg-amber-100 text-amber-700 border-amber-200`
|
||||||
|
- `rejected`: `bg-rose-100 text-rose-700 border-rose-200`
|
||||||
|
- `hidden`/neutral: `bg-slate-100 text-slate-600`
|
||||||
|
|
||||||
|
### Menus & Dropdowns
|
||||||
|
|
||||||
|
**Account dropdown:**
|
||||||
|
```
|
||||||
|
border border-slate-200 bg-white shadow-[8px_8px_0_0_rgba(15,23,42,0.08)]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Menu items:**
|
||||||
|
```
|
||||||
|
flex items-center gap-3 px-4 py-2.5 text-sm font-medium text-slate-700
|
||||||
|
transition-colors hover:bg-teal-50 hover:text-teal-800
|
||||||
|
```
|
||||||
|
|
||||||
|
**Danger menu item:** `text-rose-600 hover:bg-rose-50 hover:text-rose-700`
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
|
||||||
|
**Header:** `sticky top-0 z-50 h-28 md:h-16 bg-white/88 backdrop-blur-xl border-b border-slate-200/80`, scroll-hides with `transition-transform duration-300`.
|
||||||
|
|
||||||
|
**Selected nav item:** `bg-teal-100 text-teal-800 ring-1 ring-teal-200`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Palette
|
||||||
|
|
||||||
|
Use these Tailwind families first:
|
||||||
|
|
||||||
|
- `slate` — text, borders, neutral panels
|
||||||
|
- `sky` — page gradients, upload/action emphasis, map/cloud atmosphere
|
||||||
|
- `teal` — navigation selected states, account identity, calm success-adjacent UI
|
||||||
|
- `cyan` — subtle sky gradients and avatar/logo surfaces
|
||||||
|
- `emerald` — explicit success/approved state only
|
||||||
|
- `amber` — pending/review/uncommon rarity
|
||||||
|
- `rose` — rejected/destructive/rare rarity
|
||||||
|
|
||||||
|
**Key hex values:**
|
||||||
|
|
||||||
|
| Role | Value |
|
||||||
|
|------|-------|
|
||||||
|
| Page background | `#eef4f8` / `#f3f7fb` |
|
||||||
|
| Body text | `#0f172a` (slate-900) |
|
||||||
|
| Secondary text | `#334155` (slate-700) |
|
||||||
|
| Muted text | `#64748b` (slate-500) |
|
||||||
|
| Borders | `#d9e3ee`, `#e2e8f0`, `#cbd5e1` |
|
||||||
|
| Selection | `rgba(15,118,110,0.18)` (teal-700) |
|
||||||
|
| Text on primary | `#0f172a` (dark, not white — Naive UI override) |
|
||||||
|
|
||||||
|
**Naive UI theme tokens** (`App.vue`):
|
||||||
|
```
|
||||||
|
primaryColor: '#0f766e' (teal-700)
|
||||||
|
infoColor: '#0369a1' (sky-700)
|
||||||
|
successColor: '#0f766e' (teal-700)
|
||||||
|
warningColor: '#d97706' (amber-600)
|
||||||
|
errorColor: '#dc2626' (red-600)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Animation & Transitions
|
||||||
|
|
||||||
|
**Hover effects:**
|
||||||
|
- Cards: `transition-all hover:-translate-y-1 hover:shadow-lg` (lift)
|
||||||
|
- Images: `transition duration-500 group-hover:scale-[1.04]` (subtle zoom)
|
||||||
|
- Gallery items: `transition-transform duration-300 hover:-translate-y-1`
|
||||||
|
- Badges on cards: `transition-opacity md:opacity-0 md:group-hover:opacity-100`
|
||||||
|
|
||||||
|
**Modal transitions:**
|
||||||
|
- ImageDetailModal: opacity 180ms, shell scale+translateY 220ms, `cubic-bezier(0.22, 1, 0.36, 1)`
|
||||||
|
- Other modals: default Vue transitions
|
||||||
|
|
||||||
|
**Header scroll:** `transition-transform duration-300`
|
||||||
|
|
||||||
|
**Image loading:** `transition-opacity duration-300` for thumbnail-to-hires crossfade, low-res preview gets `blur-[1px]` until full-res loads.
|
||||||
|
|
||||||
|
**Skeleton shimmer:**
|
||||||
|
```
|
||||||
|
animate-pulse bg-[linear-gradient(110deg,#0f172a_0%,#1e293b_45%,#334155_55%,#0f172a_100%)]
|
||||||
|
bg-[length:220%_100%]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Icons
|
||||||
|
|
||||||
|
**Library:** `@vicons/tabler` (Tabler Icons), rendered via `<NIcon size="16|18|20|22|30|42">` from Naive UI.
|
||||||
|
|
||||||
|
Common icons: `User`, `Settings`, `Logout`, `Shield`, `Search`, `X`, `Clock`, `Location`/`MapIcon`, `CloudUpload`, `Map`/`Satellite`, `Adjustments`, `Calendar`, `Refresh`, `InfoCircle`, `Lock`, `Check`, `Eye`/`EyeOff`, `Trash`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Anti-Patterns
|
## Anti-Patterns
|
||||||
|
|
||||||
- Default Tailwind `green-*`, `lime-*`, vivid teal, purple, indigo, or generic `gray-*`.
|
- Do not use purple as a fallback visual direction.
|
||||||
- Ad hoc rounded cards, pills, or map buttons.
|
- Do not use `bg-slate-900 text-white` for ordinary selected navigation.
|
||||||
- Soft drop shadows replacing hard atlas shadows across normal UI.
|
- Do not introduce dark-mode-heavy sections unless the surrounding page already uses that language.
|
||||||
- New button styling copied into templates instead of shared `oc-*` variants.
|
- Do not overuse rounded rectangles; the project uses sharp, atlas-like panels.
|
||||||
- `NButton type="primary"` used as a shortcut around the shared button contract.
|
- Do not mix random accent colors within the same feature. Pick one accent family and keep it consistent.
|
||||||
- White text on pale green or sky action surfaces.
|
- Do not use flat single-color backgrounds where a gradient belongs.
|
||||||
- Emoji used as the site identity.
|
- Do not add border-radius without an explicit reason — `0px` is the project default.
|
||||||
- Flat page backgrounds where the shared hero/canvas gradient applies.
|
- Do not use white text on primary buttons — Naive UI primary buttons use dark text (`#0f172a`).
|
||||||
- Green used indiscriminately for both brand interaction and success state.
|
|
||||||
|
---
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
|
|
||||||
After style changes, run:
|
After style changes, run:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run check:styles
|
npx vue-tsc -b
|
||||||
git diff --check
|
|
||||||
npm run build
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For material visual changes, render representative pages and inspect desktop and `375px` mobile screenshots. Check computed styles when shape, shadow, color token, or responsive overflow is part of the request.
|
Run `npm run build` if the change touches routes, imported components, or package dependencies.
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- `npm run dev` — Vite dev server with HMR
|
- `npm run dev` — Vite dev server with HMR
|
||||||
- `npm run check:styles` — validates shared style contracts and blocks deprecated palettes/classes
|
|
||||||
- `npm run build` — `vue-tsc -b && vite build` (typecheck must pass or build aborts)
|
- `npm run build` — `vue-tsc -b && vite build` (typecheck must pass or build aborts)
|
||||||
- `npx vue-tsc -b` — standalone typecheck (no script in package.json)
|
- `npx vue-tsc -b` — standalone typecheck (no script in package.json)
|
||||||
- `npm run preview` — previews the production build on strict port `5173`
|
- No linter, formatter, or test runner exists
|
||||||
- No general linter, formatter, or test runner exists; `check:styles` is the project-specific visual-system check
|
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -28,13 +26,9 @@
|
|||||||
| `src/components/cloud/` | Cloud-related modals and widgets: ImageDetailModal, CloudEditModal, MapPickerModal, QuickUploadModal, MiniLocationMap |
|
| `src/components/cloud/` | Cloud-related modals and widgets: ImageDetailModal, CloudEditModal, MapPickerModal, QuickUploadModal, MiniLocationMap |
|
||||||
| `src/components/layout/` | AppHeader (top nav bar with auth state) |
|
| `src/components/layout/` | AppHeader (top nav bar with auth state) |
|
||||||
| `src/components/profile/` | ContributionHeatmap |
|
| `src/components/profile/` | ContributionHeatmap |
|
||||||
| `src/style.css` | Global stylesheet assembly point; imports Tailwind, tokens, base rules, and shared components |
|
| `src/style.css` | Global visual system hooks: shared button/card utility classes, base typography, body background |
|
||||||
| `src/styles/` | Visual system source: semantic tokens, global square-corner baseline, shared `oc-*` component contracts |
|
|
||||||
| `src/lib/theme.ts` | Naive UI theme overrides aligned with the semantic tokens |
|
|
||||||
| `src/views/` | Route-level page components (see Routes below) |
|
| `src/views/` | Route-level page components (see Routes below) |
|
||||||
| `src/types/` | TypeScript types: domain models (`database.ts`), API DTOs (`api.ts`), AMap declarations (`amap.d.ts`), router meta (`router.d.ts`) |
|
| `src/types/` | TypeScript types: domain models (`database.ts`), API DTOs (`api.ts`), AMap declarations (`amap.d.ts`), router meta (`router.d.ts`) |
|
||||||
| `docs/style-system.md` | Concise implementation and naming guide for the visual system |
|
|
||||||
| `scripts/check-style-system.mjs` | Automated guard against deprecated palettes and malformed shared classes |
|
|
||||||
|
|
||||||
## Routes
|
## Routes
|
||||||
|
|
||||||
@@ -132,33 +126,12 @@ Optional:
|
|||||||
|
|
||||||
Backend-only variables such as database credentials, JWT secrets, SMTP settings, cookie policy, CORS origins, and upload paths belong in `../opencloud-backend/.env`, never in the Vite environment.
|
Backend-only variables such as database credentials, JWT secrets, SMTP settings, cookie policy, CORS origins, and upload paths belong in `../opencloud-backend/.env`, never in the Vite environment.
|
||||||
|
|
||||||
## Visual System
|
|
||||||
|
|
||||||
- The design language is a clean sky atlas: airy sky-blue surfaces, fresh eucalyptus green, square editorial panels, pixel-cloud identity, and hard offset shadows.
|
|
||||||
- Read `.agents/skills/opencloud-style/SKILL.md` before material visual changes. Treat `src/styles/tokens.css`, `src/styles/base.css`, `src/styles/components.css`, and `src/lib/theme.ts` as the implementation sources of truth.
|
|
||||||
- Shared/stable decisions belong in layers:
|
|
||||||
- tokens and palette remapping → `src/styles/tokens.css`
|
|
||||||
- global baseline and square-corner policy → `src/styles/base.css`
|
|
||||||
- cross-page semantic classes → `src/styles/components.css`
|
|
||||||
- feature-specific map/slider/media details → the component's scoped style
|
|
||||||
- one-off layout and responsive composition → Tailwind in the template
|
|
||||||
- The global interface is square by default. Ordinary cards, inputs, dropdowns, buttons, modals, and map controls use `0px` radius. Circular marker dots, media overlay controls, AMap-generated bubbles/cards, and the media-focused image-detail shell are explicit exceptions.
|
|
||||||
- Use hard zero-blur offset shadows (`--oc-shadow-xs` through `--oc-shadow-xl`) for ordinary UI depth. Interactive controls may lift by `translate(-1px, -1px)` on hover.
|
|
||||||
- Brand/interaction green is fresh eucalyptus: main `#34745d`, dark `#274c40`, light surface `#f2faf6`.
|
|
||||||
- Success green is a distinct leaf green: main `#4d7f3b`, dark `#35522c`, light surface `#f4faef`.
|
|
||||||
- Existing `teal-*` and `emerald-*` Tailwind class names are compatibility aliases remapped in `tokens.css`; do not assume Tailwind's default values.
|
|
||||||
- Do not introduce Tailwind `green-*`/`lime-*`, vivid default teal, purple, indigo, or generic gray palettes. Use slate, sky, remapped teal/emerald, amber, and rose according to semantic purpose.
|
|
||||||
- The header brand mark is the pixel cloud/sun SVG inside a square `40px` `sky-100` surface with a `sky-200` border and neutral `4px` hard shadow. Do not replace it with emoji or give it a green background.
|
|
||||||
- Map floating buttons use `oc-map-button`: square `40px × 40px`, white background, slate border, `0px` radius, and a hard shadow even when disabled.
|
|
||||||
- For material visual changes, inspect representative desktop and real `375px` mobile screenshots and verify there is no horizontal overflow.
|
|
||||||
|
|
||||||
## Naive UI
|
## Naive UI
|
||||||
|
|
||||||
- Used for modals, buttons, inputs, tags, progress bars, alerts, dropdowns, empty states, skeletons, and message toasts.
|
- Used for modals, buttons, inputs, tags, progress bars, alerts, dropdowns, empty states, skeletons, and message toasts.
|
||||||
- No SSR — pure client-side rendering.
|
- No SSR — pure client-side rendering.
|
||||||
- Custom CSS overrides via scoped `<style>` blocks for slider styling, transitions, and component tweaks.
|
- Custom CSS overrides via scoped `<style>` blocks for slider styling, transitions, and component tweaks.
|
||||||
- Global overrides are defined in `src/lib/theme.ts` and mounted by `App.vue`; keep brand/success colors aligned with `src/styles/tokens.css`.
|
- **Do not rely on default Naive UI primary/secondary colors for page CTAs or panel actions.** The project now uses shared global classes in `src/style.css` to keep buttons visually consistent with the sky-atlas theme.
|
||||||
- **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
|
||||||
@@ -173,15 +146,12 @@ Backend-only variables such as database credentials, JWT secrets, SMTP settings,
|
|||||||
- `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.
|
|
||||||
|
|
||||||
## Visual Notes
|
## Visual Notes
|
||||||
|
|
||||||
- The site icon and header brand mark are a matching pixel-style cloud motif on a light sky-blue square; avoid emoji or unrelated icon styles for primary brand surfaces.
|
- The site icon and header brand mark are a matching pixel-style cloud motif; avoid reintroducing emoji or unrelated icon styles for primary brand surfaces.
|
||||||
- Header action buttons use slight hover lift (`translate(-1px, -1px)`) with hard-offset shadow growth; new header-like actions should follow that interaction pattern.
|
- Header action buttons use slight hover lift (`translate(-1px, -1px)`) with hard-offset shadow growth; new header-like actions should follow that interaction pattern.
|
||||||
- Heatmap view toggles in `ContributionHeatmap` intentionally use separate buttons with gap spacing instead of `NButtonGroup`, because grouped buttons visually collide once hard shadows and hover transforms are applied.
|
- Heatmap view toggles in `ContributionHeatmap` intentionally use separate buttons with gap spacing instead of `NButtonGroup`, because grouped buttons visually collide once hard shadows and hover transforms are applied.
|
||||||
- Reuse existing `oc-*` classes before duplicating Tailwind bundles. New shared modifiers must always appear with their base class.
|
|
||||||
- Validate style work with `npm run check:styles`, `git diff --check`, and `npm run build`.
|
|
||||||
|
|
||||||
## MVP Constraints
|
## MVP Constraints
|
||||||
|
|
||||||
|
|||||||
+2
-20
@@ -15,21 +15,6 @@
|
|||||||
|
|
||||||
`src/style.css` 只负责按以上顺序装配全局样式,不再直接堆放组件规则。
|
`src/style.css` 只负责按以上顺序装配全局样式,不再直接堆放组件规则。
|
||||||
|
|
||||||
## 清新绿色策略
|
|
||||||
|
|
||||||
OpenCloud 的绿色应接近清晨的桉叶与嫩叶,而不是灰暗的苔藓色或高饱和的荧光青绿。
|
|
||||||
|
|
||||||
- 品牌、导航与账户交互使用清透但稳重的桉叶绿。
|
|
||||||
- 成功、通过和公开状态使用更偏嫩叶的成功绿色,与品牌色保持区分。
|
|
||||||
- `teal-*` 和 `emerald-*` 是现有模板的兼容类名,实际色值由 `tokens.css` 重映射;不要假定它们仍是 Tailwind 默认色。
|
|
||||||
- 不直接使用 Tailwind 默认 `green-*`、`lime-*`,也不要在组件里写亮绿色十六进制值或 RGB。
|
|
||||||
- 新增绿色前先判断它属于品牌交互还是成功状态,并优先使用已有语义类或设计令牌。
|
|
||||||
|
|
||||||
| 语义 | 主色 | 深色 | 浅色表面 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 品牌/交互 | `#34745d` | `#274c40` | `#f2faf6` |
|
|
||||||
| 成功/通过 | `#4d7f3b` | `#35522c` | `#f4faef` |
|
|
||||||
|
|
||||||
## 命名规则
|
## 命名规则
|
||||||
|
|
||||||
- 所有共享类使用 `oc-` 前缀,避免与 Naive UI、AMap 和 Tailwind 冲突。
|
- 所有共享类使用 `oc-` 前缀,避免与 Naive UI、AMap 和 Tailwind 冲突。
|
||||||
@@ -58,8 +43,6 @@ OpenCloud 的绿色应接近清晨的桉叶与嫩叶,而不是灰暗的苔藓
|
|||||||
|
|
||||||
容器宽度只有三个语义档位:`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
|
||||||
@@ -78,8 +61,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`:空状态表面。
|
||||||
|
|
||||||
@@ -96,7 +78,7 @@ Naive UI 按钮继续使用 `type="default"`,视觉语义由共享类控制:
|
|||||||
</NButton>
|
</NButton>
|
||||||
```
|
```
|
||||||
|
|
||||||
页面级主操作使用 `oc-primary-button`;面板内操作使用 `oc-panel-button`。可用变体为 `neutral`、`sky`、`teal`、`amber`、`danger`,不要临时创建新的强调色。其中 `teal` 是兼容命名,视觉上使用的是清新桉叶绿。
|
页面级主操作使用 `oc-primary-button`;面板内操作使用 `oc-panel-button`。可用变体为 `neutral`、`sky`、`teal`、`amber`、`danger`,不要临时创建新的强调色。
|
||||||
|
|
||||||
## 新增或修改样式的决策顺序
|
## 新增或修改样式的决策顺序
|
||||||
|
|
||||||
|
|||||||
@@ -7,55 +7,30 @@ 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/teal 语义色,不要重新引入灰色或紫色系。',
|
||||||
},
|
|
||||||
{
|
|
||||||
pattern: /\b(?:bg|border|ring|text)-(?:green|lime)-\d+\b/g,
|
|
||||||
message: '绿色必须使用项目重映射后的 teal 品牌色或 emerald 成功色,不要引入默认 green/lime 荧光色。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pattern: /#(?:f0fdfa|ccfbf1|99f6e4|5eead4|2dd4bf|14b8a6|0d9488|0f766e|115e59|134e4a)\b/gi,
|
|
||||||
message: '检测到旧版高饱和 teal 色值,请改用自然绿色设计令牌。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pattern: /#(?:c084fc|a855f7|9333ea|7e22ce)\b/gi,
|
|
||||||
message: '检测到禁用的紫色硬编码,请改用 rose 或项目语义令牌。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pattern: /#(?:1f2937|9ca3af)\b/gi,
|
|
||||||
message: '检测到默认 gray 调色板硬编码,请改用对应的 slate 色值或语义令牌。',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
pattern: /#60a5fa\b/gi,
|
|
||||||
message: '检测到默认 blue-400 硬编码,请按用途改用 sky 或 amber 等项目语义色。',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
pattern: /text-xs uppercase tracking-\[0\.18em\] text-slate-500/g,
|
pattern: /text-xs uppercase tracking-\[0\.18em\] text-slate-500/g,
|
||||||
message: '重复的小节标签应使用 oc-section-label。',
|
message: '重复的小节标签应使用 oc-section-label。',
|
||||||
vueOnly: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
pattern: /border border-slate-200 bg-slate-50 p-4/g,
|
pattern: /border border-slate-200 bg-slate-50 p-4/g,
|
||||||
message: '标准内嵌信息面板应使用 oc-inset-panel。',
|
message: '标准内嵌信息面板应使用 oc-inset-panel。',
|
||||||
vueOnly: true,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
pattern: /border-b border-sky-100 bg-\[linear-gradient\(180deg,#e0f2fe_0%,#f8fafc_100%\)\]/g,
|
pattern: /border-b border-sky-100 bg-\[linear-gradient\(180deg,#e0f2fe_0%,#f8fafc_100%\)\]/g,
|
||||||
message: '标准页面头部应使用 oc-page-hero。',
|
message: '标准页面头部应使用 oc-page-hero。',
|
||||||
vueOnly: true,
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const styleSourceExtensions = new Set(['.vue', '.css', '.ts'])
|
async function collectVueFiles(directory) {
|
||||||
|
|
||||||
async function collectStyleSourceFiles(directory) {
|
|
||||||
const entries = await readdir(directory, { withFileTypes: true })
|
const entries = await readdir(directory, { withFileTypes: true })
|
||||||
const files = await Promise.all(entries.map(async (entry) => {
|
const files = await Promise.all(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() ? collectVueFiles(path) : [path]
|
||||||
}))
|
}))
|
||||||
|
|
||||||
return files.flat().filter((path) => styleSourceExtensions.has(extname(path)))
|
return files.flat().filter((path) => extname(path) === '.vue')
|
||||||
}
|
}
|
||||||
|
|
||||||
function lineNumberAt(source, index) {
|
function lineNumberAt(source, index) {
|
||||||
@@ -64,12 +39,10 @@ function lineNumberAt(source, index) {
|
|||||||
|
|
||||||
const violations = []
|
const violations = []
|
||||||
|
|
||||||
for (const file of await collectStyleSourceFiles(sourceRoot.pathname)) {
|
for (const file of await collectVueFiles(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
|
|
||||||
|
|
||||||
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),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { NAlert, NButton, NIcon } from 'naive-ui'
|
import { NAlert, NButton } from 'naive-ui'
|
||||||
import { Map as MapIcon, X } from '@vicons/tabler'
|
|
||||||
import MapPickerModal from '@/components/cloud/MapPickerModal.vue'
|
import MapPickerModal from '@/components/cloud/MapPickerModal.vue'
|
||||||
import type { CloudType } from '@/types/database'
|
import type { CloudType } from '@/types/database'
|
||||||
|
|
||||||
export interface CloudEditFormValue {
|
export interface CloudEditFormValue {
|
||||||
cloudCategoryId: number | null
|
cloudCategoryId: number | 'other' | null
|
||||||
|
customCloudType: string
|
||||||
capturedAt: string
|
capturedAt: string
|
||||||
latitude: number | null
|
latitude: number | null
|
||||||
longitude: number | null
|
longitude: number | null
|
||||||
|
locationName: string
|
||||||
description: string
|
description: string
|
||||||
isPublic: boolean
|
isPublic: boolean
|
||||||
}
|
}
|
||||||
@@ -33,12 +34,16 @@ const emit = defineEmits<{
|
|||||||
const form = ref<CloudEditFormValue>(createEmptyForm())
|
const form = ref<CloudEditFormValue>(createEmptyForm())
|
||||||
const mapPickerOpen = ref(false)
|
const mapPickerOpen = ref(false)
|
||||||
|
|
||||||
|
const hasCoordinates = computed(() => form.value.latitude !== null || form.value.longitude !== null)
|
||||||
|
|
||||||
function createEmptyForm(): CloudEditFormValue {
|
function createEmptyForm(): CloudEditFormValue {
|
||||||
return {
|
return {
|
||||||
cloudCategoryId: null,
|
cloudCategoryId: null,
|
||||||
|
customCloudType: '',
|
||||||
capturedAt: '',
|
capturedAt: '',
|
||||||
latitude: null,
|
latitude: null,
|
||||||
longitude: null,
|
longitude: null,
|
||||||
|
locationName: '',
|
||||||
description: '',
|
description: '',
|
||||||
isPublic: true,
|
isPublic: true,
|
||||||
}
|
}
|
||||||
@@ -50,7 +55,9 @@ function cloneForm(value: CloudEditFormValue | null) {
|
|||||||
|
|
||||||
function onCategoryChange(event: Event) {
|
function onCategoryChange(event: Event) {
|
||||||
const value = (event.target as HTMLSelectElement).value
|
const value = (event.target as HTMLSelectElement).value
|
||||||
if (!value) {
|
if (value === 'other') {
|
||||||
|
form.value.cloudCategoryId = 'other'
|
||||||
|
} else if (!value) {
|
||||||
form.value.cloudCategoryId = null
|
form.value.cloudCategoryId = null
|
||||||
} else {
|
} else {
|
||||||
form.value.cloudCategoryId = Number(value)
|
form.value.cloudCategoryId = Number(value)
|
||||||
@@ -67,6 +74,11 @@ function onLngInput(event: Event) {
|
|||||||
form.value.longitude = Number.isNaN(value) ? null : 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 }) {
|
function confirmMapPicker(payload: { latitude: number; longitude: number }) {
|
||||||
form.value.latitude = payload.latitude
|
form.value.latitude = payload.latitude
|
||||||
form.value.longitude = payload.longitude
|
form.value.longitude = payload.longitude
|
||||||
@@ -103,7 +115,7 @@ watch(() => props.initialValue, value => {
|
|||||||
class="fixed inset-0 z-[130] flex items-center justify-center bg-slate-950/60 px-4 py-6"
|
class="fixed inset-0 z-[130] flex items-center justify-center bg-slate-950/60 px-4 py-6"
|
||||||
@click="close"
|
@click="close"
|
||||||
>
|
>
|
||||||
<div class="oc-modal-shell w-full max-w-2xl" @click.stop>
|
<div class="w-full max-w-2xl bg-white shadow-2xl" @click.stop>
|
||||||
<div class="flex items-start justify-between border-b border-slate-200 px-6 py-5">
|
<div class="flex items-start justify-between border-b border-slate-200 px-6 py-5">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-xl font-semibold text-slate-900">编辑图片信息</h3>
|
<h3 class="text-xl font-semibold text-slate-900">编辑图片信息</h3>
|
||||||
@@ -111,12 +123,10 @@ watch(() => props.initialValue, value => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="oc-icon-button"
|
class="p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||||
aria-label="关闭编辑窗口"
|
|
||||||
title="关闭编辑窗口"
|
|
||||||
@click="close"
|
@click="close"
|
||||||
>
|
>
|
||||||
<NIcon size="20"><X /></NIcon>
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -141,11 +151,21 @@ watch(() => props.initialValue, value => {
|
|||||||
>
|
>
|
||||||
<option value="" disabled>请选择</option>
|
<option value="" disabled>请选择</option>
|
||||||
<option v-for="ct in cloudTypes" :key="ct.id" :value="ct.id">
|
<option v-for="ct in cloudTypes" :key="ct.id" :value="ct.id">
|
||||||
{{ ct.name }}{{ ct.genus ? '(' + ct.genus + ')' : '' }}
|
{{ ct.name }}({{ ct.name_en }})
|
||||||
</option>
|
</option>
|
||||||
|
<option value="other">其他</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<label class="oc-field-label">拍摄时间</label>
|
<label class="oc-field-label">拍摄时间</label>
|
||||||
<input
|
<input
|
||||||
@@ -156,7 +176,27 @@ watch(() => props.initialValue, value => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="oc-field-label">经纬度 <span class="oc-field-required">*</span></label>
|
<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="text-xs font-medium text-slate-400 transition-colors hover:text-slate-700"
|
||||||
|
@click="clearCoordinates"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
|
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
|
||||||
<input
|
<input
|
||||||
:value="form.latitude !== null ? form.latitude : ''"
|
:value="form.latitude !== null ? form.latitude : ''"
|
||||||
@@ -176,12 +216,12 @@ watch(() => props.initialValue, value => {
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="oc-icon-button oc-icon-button--sky"
|
class="flex h-10 w-10 items-center justify-center border border-sky-200 bg-sky-50 text-sky-700 transition-colors hover:bg-sky-100"
|
||||||
title="地图选点"
|
title="地图选点"
|
||||||
aria-label="地图选点"
|
aria-label="地图选点"
|
||||||
@click="mapPickerOpen = true"
|
@click="mapPickerOpen = true"
|
||||||
>
|
>
|
||||||
<NIcon size="20"><MapIcon /></NIcon>
|
<span class="text-lg leading-none">🗺️</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,7 +231,7 @@ watch(() => props.initialValue, value => {
|
|||||||
<textarea
|
<textarea
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
rows="4"
|
rows="4"
|
||||||
class="oc-field-control resize-none"
|
class="w-full resize-none border border-slate-300 px-3 py-2.5 text-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500"
|
||||||
placeholder="描述一下这张图片..."
|
placeholder="描述一下这张图片..."
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -49,10 +49,10 @@ const layout = computed(() => {
|
|||||||
? naturalWidth.value / naturalHeight.value
|
? naturalWidth.value / naturalHeight.value
|
||||||
: 4 / 3
|
: 4 / 3
|
||||||
const maxModalWidth = viewportWidth.value > 0
|
const maxModalWidth = viewportWidth.value > 0
|
||||||
? Math.max(Math.min(viewportWidth.value * 0.95, viewportWidth.value - 32), 280)
|
? Math.max(Math.min(viewportWidth.value * 0.92, viewportWidth.value - 32), 280)
|
||||||
: 320
|
: 320
|
||||||
const maxImageHeight = viewportHeight.value > 0
|
const maxImageHeight = viewportHeight.value > 0
|
||||||
? Math.max(viewportHeight.value * 0.9, 240)
|
? Math.max(viewportHeight.value * 0.86, 240)
|
||||||
: 360
|
: 360
|
||||||
const imageWidth = Math.min(maxModalWidth, maxImageHeight * aspectRatio)
|
const imageWidth = Math.min(maxModalWidth, maxImageHeight * aspectRatio)
|
||||||
|
|
||||||
@@ -181,17 +181,17 @@ onBeforeUnmount(() => {
|
|||||||
@click="close"
|
@click="close"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="image-detail-shell relative w-fit max-w-[92vw] overflow-hidden shadow-2xl"
|
class="image-detail-shell relative w-fit max-w-[92vw] overflow-hidden rounded-[30px] shadow-2xl"
|
||||||
@click.stop
|
@click.stop
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="close"
|
@click="close"
|
||||||
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"
|
class="image-detail-close-button absolute right-4 top-4 z-30 flex h-10 min-h-10 w-10 min-w-10 max-w-10 appearance-none items-center justify-center overflow-hidden border-0 bg-transparent p-0 text-white/70 shadow-none transition-colors hover:text-white [&_svg]:stroke-[2.5]"
|
||||||
aria-label="关闭大图"
|
aria-label="关闭大图"
|
||||||
title="关闭大图"
|
title="关闭大图"
|
||||||
>
|
>
|
||||||
<NIcon size="20">
|
<NIcon size="22">
|
||||||
<X />
|
<X />
|
||||||
</NIcon>
|
</NIcon>
|
||||||
</button>
|
</button>
|
||||||
@@ -223,7 +223,7 @@ onBeforeUnmount(() => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="toggleInfo"
|
@click="toggleInfo"
|
||||||
class="image-detail-info-button oc-overlay-button absolute bottom-4 right-4 z-30 h-11 w-11 drop-shadow-[0_2px_8px_rgba(15,23,42,0.9)]"
|
class="absolute bottom-4 right-4 z-30 flex h-11 w-11 items-center justify-center rounded-full bg-transparent text-white drop-shadow-[0_2px_8px_rgba(15,23,42,0.9)] transition-colors hover:text-sky-100"
|
||||||
:aria-label="infoOpen ? '隐藏详细信息' : '查看详细信息'"
|
:aria-label="infoOpen ? '隐藏详细信息' : '查看详细信息'"
|
||||||
:title="infoOpen ? '隐藏详细信息' : '查看详细信息'"
|
:title="infoOpen ? '隐藏详细信息' : '查看详细信息'"
|
||||||
>
|
>
|
||||||
@@ -242,7 +242,7 @@ onBeforeUnmount(() => {
|
|||||||
<h2 class="text-xl font-bold text-slate-900">{{ title }}</h2>
|
<h2 class="text-xl font-bold text-slate-900">{{ title }}</h2>
|
||||||
<span
|
<span
|
||||||
v-if="badgeLabel"
|
v-if="badgeLabel"
|
||||||
class="border px-3 py-1 text-xs font-medium"
|
class="rounded-full border px-3 py-1 text-xs font-medium"
|
||||||
:class="badgeClass"
|
:class="badgeClass"
|
||||||
>
|
>
|
||||||
{{ badgeLabel }}
|
{{ badgeLabel }}
|
||||||
@@ -254,11 +254,11 @@ onBeforeUnmount(() => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="infoOpen = false"
|
@click="infoOpen = false"
|
||||||
class="image-detail-panel-close oc-icon-button oc-icon-button--small absolute right-3 top-3"
|
class="absolute right-3 top-3 rounded-full p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||||
aria-label="隐藏详细信息"
|
aria-label="隐藏详细信息"
|
||||||
title="隐藏详细信息"
|
title="隐藏详细信息"
|
||||||
>
|
>
|
||||||
<NIcon size="16"><X /></NIcon>
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -301,7 +301,7 @@ onBeforeUnmount(() => {
|
|||||||
transform: translateY(10px) scale(0.97);
|
transform: translateY(10px) scale(0.97);
|
||||||
}
|
}
|
||||||
|
|
||||||
.image-detail-info-button.image-detail-info-button {
|
.image-detail-close-button.image-detail-close-button {
|
||||||
border-radius: 9999px !important;
|
border-radius: 9999px !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
import { NButton, NIcon } from 'naive-ui'
|
import { NButton } from 'naive-ui'
|
||||||
import { X } from '@vicons/tabler'
|
|
||||||
import { loadAMap } from '@/lib/amap'
|
import { loadAMap } from '@/lib/amap'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(defineProps<{
|
||||||
@@ -50,8 +49,6 @@ function placeMarker(longitude: number, latitude: number) {
|
|||||||
mapPickerMarker = new mapPickerAMap.Marker({
|
mapPickerMarker = new mapPickerAMap.Marker({
|
||||||
position: [longitude, latitude],
|
position: [longitude, latitude],
|
||||||
title: '拍摄位置',
|
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)
|
} as AMap.MarkerOptions)
|
||||||
mapPickerMarker.setMap(mapPickerInstance)
|
mapPickerMarker.setMap(mapPickerInstance)
|
||||||
} else {
|
} else {
|
||||||
@@ -88,7 +85,6 @@ async function initializeMap() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
mapPickerAMap = await loadAMap()
|
mapPickerAMap = await loadAMap()
|
||||||
mapPickerAMap.getConfig().appname = 'amap-jsapi-skill'
|
|
||||||
|
|
||||||
const hasCoordinates =
|
const hasCoordinates =
|
||||||
typeof props.latitude === 'number' &&
|
typeof props.latitude === 'number' &&
|
||||||
@@ -154,7 +150,7 @@ onBeforeUnmount(() => {
|
|||||||
class="fixed inset-0 z-[140] flex items-center justify-center bg-slate-950/55 px-4 py-6"
|
class="fixed inset-0 z-[140] flex items-center justify-center bg-slate-950/55 px-4 py-6"
|
||||||
@click="close"
|
@click="close"
|
||||||
>
|
>
|
||||||
<div class="oc-modal-shell w-full max-w-3xl overflow-hidden" @click.stop>
|
<div class="w-full max-w-3xl overflow-hidden bg-white shadow-2xl" @click.stop>
|
||||||
<div class="flex items-start justify-between border-b border-slate-200 px-6 py-5">
|
<div class="flex items-start justify-between border-b border-slate-200 px-6 py-5">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-xl font-semibold text-slate-900">{{ title }}</h3>
|
<h3 class="text-xl font-semibold text-slate-900">{{ title }}</h3>
|
||||||
@@ -162,12 +158,10 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="oc-icon-button"
|
class="p-2 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||||
aria-label="关闭地图选点窗口"
|
|
||||||
title="关闭地图选点窗口"
|
|
||||||
@click="close"
|
@click="close"
|
||||||
>
|
>
|
||||||
<NIcon size="20"><X /></NIcon>
|
✕
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ async function initializeMap() {
|
|||||||
marker = new amapLib.Marker({
|
marker = new amapLib.Marker({
|
||||||
position: center.value,
|
position: center.value,
|
||||||
title: props.locationName || '拍摄位置',
|
title: props.locationName || '拍摄位置',
|
||||||
content: '<div class="h-4 w-4 rounded-full border-2 border-white bg-teal-500 shadow-[0_0_0_4px_rgba(52,116,93,0.24)]"></div>',
|
content: '<div class="h-4 w-4 rounded-full border-2 border-white bg-teal-500 shadow-[0_0_0_4px_rgba(20,184,166,0.22)]"></div>',
|
||||||
offset: new amapLib.Pixel(-8, -8),
|
offset: new amapLib.Pixel(-8, -8),
|
||||||
} as AMap.MarkerOptions)
|
} as AMap.MarkerOptions)
|
||||||
marker.setMap(mapInstance)
|
marker.setMap(mapInstance)
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ function handleRemove(id: string) {
|
|||||||
function onCategoryChange(event: Event) {
|
function onCategoryChange(event: Event) {
|
||||||
if (!activeItem.value) return
|
if (!activeItem.value) return
|
||||||
const value = (event.target as HTMLSelectElement).value
|
const value = (event.target as HTMLSelectElement).value
|
||||||
if (value === '') {
|
if (value === 'other') {
|
||||||
|
activeItem.value.cloudCategoryId = 'other'
|
||||||
|
} else if (value === '') {
|
||||||
activeItem.value.cloudCategoryId = null
|
activeItem.value.cloudCategoryId = null
|
||||||
} else {
|
} else {
|
||||||
activeItem.value.cloudCategoryId = Number(value)
|
activeItem.value.cloudCategoryId = Number(value)
|
||||||
@@ -100,7 +102,6 @@ function onCategoryChange(event: Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDatetimeLocal(iso: string): string {
|
function formatDatetimeLocal(iso: string): string {
|
||||||
if (!iso) return ''
|
|
||||||
const date = new Date(iso)
|
const date = new Date(iso)
|
||||||
const pad = (value: number) => String(value).padStart(2, '0')
|
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())}`
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
@@ -109,7 +110,8 @@ function formatDatetimeLocal(iso: string): string {
|
|||||||
function onCapturedAtChange(event: Event) {
|
function onCapturedAtChange(event: Event) {
|
||||||
if (!activeItem.value) return
|
if (!activeItem.value) return
|
||||||
const value = (event.target as HTMLInputElement).value
|
const value = (event.target as HTMLInputElement).value
|
||||||
activeItem.value.capturedAt = value ? new Date(value).toISOString() : ''
|
if (!value) return
|
||||||
|
activeItem.value.capturedAt = new Date(value).toISOString()
|
||||||
delete activeItem.value.errors.capturedAt
|
delete activeItem.value.errors.capturedAt
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +169,7 @@ async function handleSubmit() {
|
|||||||
|
|
||||||
const result = await uploadAll()
|
const result = await uploadAll()
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
errorMsg.value = result.errorMessage || '上传失败,请稍后重试'
|
errorMsg.value = '上传失败,请稍后重试'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,7 +198,7 @@ onBeforeUnmount(() => {
|
|||||||
class="fixed inset-0 z-[130] flex items-center justify-center bg-slate-950/55 px-4 py-6 backdrop-blur-sm"
|
class="fixed inset-0 z-[130] flex items-center justify-center bg-slate-950/55 px-4 py-6 backdrop-blur-sm"
|
||||||
@click="close"
|
@click="close"
|
||||||
>
|
>
|
||||||
<div class="oc-modal-shell w-full max-w-3xl overflow-hidden" @click.stop>
|
<div class="w-full max-w-3xl overflow-hidden border border-slate-200 bg-white shadow-2xl" @click.stop>
|
||||||
<div class="flex items-start justify-between border-b border-slate-200 px-5 py-4">
|
<div class="flex items-start justify-between border-b border-slate-200 px-5 py-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-sky-700">Quick Upload</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.22em] text-sky-700">Quick Upload</p>
|
||||||
@@ -205,7 +207,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="oc-icon-button"
|
class="quick-upload-close flex h-9 w-9 items-center justify-center border border-slate-200 bg-slate-50 text-slate-500 transition-colors hover:bg-slate-100 hover:text-slate-800"
|
||||||
aria-label="关闭快捷上传"
|
aria-label="关闭快捷上传"
|
||||||
title="关闭快捷上传"
|
title="关闭快捷上传"
|
||||||
@click="close"
|
@click="close"
|
||||||
@@ -215,7 +217,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="max-h-[72vh] overflow-y-auto px-5 py-5">
|
<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" />
|
<input ref="fileInput" type="file" accept="image/*" class="hidden" @change="handleFileSelect" />
|
||||||
|
|
||||||
<NAlert v-if="successMsg" type="success" :bordered="false" title="已提交审核" class="mb-4">
|
<NAlert v-if="successMsg" type="success" :bordered="false" title="已提交审核" class="mb-4">
|
||||||
图片审核通过后会出现在画廊和地图中。
|
图片审核通过后会出现在画廊和地图中。
|
||||||
@@ -226,13 +228,7 @@ onBeforeUnmount(() => {
|
|||||||
<span class="font-medium text-slate-700">正在上传 {{ currentItemIndex }} / {{ totalItems }}</span>
|
<span class="font-medium text-slate-700">正在上传 {{ currentItemIndex }} / {{ totalItems }}</span>
|
||||||
<span class="font-medium text-sky-700">{{ overallProgress }}%</span>
|
<span class="font-medium text-sky-700">{{ overallProgress }}%</span>
|
||||||
</div>
|
</div>
|
||||||
<NProgress
|
<NProgress type="line" :percentage="overallProgress" :show-indicator="false" color="#0ea5e9" rail-color="#dbeafe" />
|
||||||
type="line"
|
|
||||||
:percentage="overallProgress"
|
|
||||||
:show-indicator="false"
|
|
||||||
color="var(--oc-color-info)"
|
|
||||||
rail-color="var(--oc-color-border)"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -246,7 +242,7 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
<NIcon size="42" class="text-sky-600"><CloudUpload /></NIcon>
|
<NIcon size="42" class="text-sky-600"><CloudUpload /></NIcon>
|
||||||
<p class="mt-3 text-base font-semibold text-slate-800">选择一张云图</p>
|
<p class="mt-3 text-base font-semibold text-slate-800">选择一张云图</p>
|
||||||
<p class="oc-field-help">支持 JPEG、PNG、WebP,图片不超过 20MB。</p>
|
<p class="oc-field-help">支持 JPG、PNG,可点击或拖拽上传。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else-if="activeItem">
|
<template v-else-if="activeItem">
|
||||||
@@ -255,9 +251,9 @@ onBeforeUnmount(() => {
|
|||||||
<div class="overflow-hidden border border-slate-900 bg-slate-950">
|
<div class="overflow-hidden border border-slate-900 bg-slate-950">
|
||||||
<img :src="activeItem.preview" alt="图片预览" class="h-56 w-full object-contain" />
|
<img :src="activeItem.preview" alt="图片预览" class="h-56 w-full object-contain" />
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-3 flex items-center gap-2">
|
<div class="mt-3 flex items-center justify-between gap-2">
|
||||||
<button type="button" class="oc-panel-button oc-panel-button--neutral flex-1" @click="fileInput?.click()">重新选择</button>
|
<button type="button" class="text-sm font-medium text-sky-700 hover:text-sky-900" @click="fileInput?.click()">重新选择</button>
|
||||||
<button type="button" class="oc-panel-button oc-panel-button--danger flex-1" @click="handleRemove(activeItem.id)">移除</button>
|
<button type="button" class="text-sm font-medium text-rose-600 hover:text-rose-800" @click="handleRemove(activeItem.id)">移除</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -271,15 +267,23 @@ onBeforeUnmount(() => {
|
|||||||
>
|
>
|
||||||
<option value="" disabled>请选择</option>
|
<option value="" disabled>请选择</option>
|
||||||
<option v-for="cloudType in cloudsStore.cloudTypes" :key="cloudType.id" :value="cloudType.id">
|
<option v-for="cloudType in cloudsStore.cloudTypes" :key="cloudType.id" :value="cloudType.id">
|
||||||
{{ cloudType.name }}{{ cloudType.genus ? '(' + cloudType.genus + ')' : '' }}
|
{{ cloudType.name }}({{ cloudType.name_en }})
|
||||||
</option>
|
</option>
|
||||||
|
<option value="other">其他</option>
|
||||||
</select>
|
</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>
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="oc-field-label">拍摄时间</label>
|
<label class="oc-field-label">拍摄时间 <span class="oc-field-required">*</span></label>
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
:value="formatDatetimeLocal(activeItem.capturedAt)"
|
:value="formatDatetimeLocal(activeItem.capturedAt)"
|
||||||
@@ -290,7 +294,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="oc-field-label">经纬度 <span class="oc-field-required">*</span></label>
|
<label class="oc-field-label">经纬度</label>
|
||||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_40px] gap-2">
|
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_40px] gap-2">
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
@@ -316,7 +320,7 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="oc-icon-button oc-icon-button--sky"
|
class="flex h-10 w-10 items-center justify-center border border-sky-200 bg-sky-50 text-sky-700 transition-colors hover:bg-sky-100"
|
||||||
title="地图选点"
|
title="地图选点"
|
||||||
aria-label="地图选点"
|
aria-label="地图选点"
|
||||||
@click="openMapPicker"
|
@click="openMapPicker"
|
||||||
@@ -326,7 +330,17 @@ onBeforeUnmount(() => {
|
|||||||
</NIcon>
|
</NIcon>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-slate-400">必填,可手动输入,也可以点击右侧地图按钮选点。</p>
|
<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>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
@@ -334,11 +348,9 @@ onBeforeUnmount(() => {
|
|||||||
<textarea
|
<textarea
|
||||||
v-model="activeItem.description"
|
v-model="activeItem.description"
|
||||||
rows="3"
|
rows="3"
|
||||||
maxlength="128"
|
|
||||||
placeholder="描述一下这张图片..."
|
placeholder="描述一下这张图片..."
|
||||||
class="oc-field-control resize-none"
|
class="oc-field-control resize-none"
|
||||||
></textarea>
|
></textarea>
|
||||||
<p v-if="activeItem.errors.description" class="oc-field-error">{{ activeItem.errors.description }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="flex items-center justify-between gap-4 border border-slate-200 bg-slate-50 px-4 py-3">
|
<label class="flex items-center justify-between gap-4 border border-slate-200 bg-slate-50 px-4 py-3">
|
||||||
@@ -382,3 +394,9 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.quick-upload-close.quick-upload-close {
|
||||||
|
border-radius: 9999px !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<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 } 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 '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
import { RouterLink, useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ async function handleLogout() {
|
|||||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
<div class="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-slate-300 bg-[linear-gradient(135deg,#ecfeff_0%,#ccfbf1_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"
|
||||||
@@ -170,24 +170,24 @@ async function handleLogout() {
|
|||||||
</div>
|
</div>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
<nav class="oc-panel-card hidden items-center gap-2 border border-slate-200 bg-white px-2 py-2 md:flex">
|
<nav class="hidden items-center gap-2 border border-slate-200 bg-white px-2 py-2 shadow-[6px_6px_0_0_rgba(15,23,42,0.05)] md:flex">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/"
|
to="/"
|
||||||
class="oc-nav-link"
|
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
||||||
:class="route.name === 'map' ? activeNavClass : inactiveNavClass"
|
:class="route.name === 'map' ? activeNavClass : inactiveNavClass"
|
||||||
>
|
>
|
||||||
地图
|
地图
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/community"
|
to="/community"
|
||||||
class="oc-nav-link"
|
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
||||||
:class="route.name === 'community' ? activeNavClass : inactiveNavClass"
|
:class="route.name === 'community' ? activeNavClass : inactiveNavClass"
|
||||||
>
|
>
|
||||||
社区
|
社区
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/gallery"
|
to="/gallery"
|
||||||
class="oc-nav-link"
|
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
||||||
:class="route.name === 'gallery' ? activeNavClass : inactiveNavClass"
|
:class="route.name === 'gallery' ? activeNavClass : inactiveNavClass"
|
||||||
>
|
>
|
||||||
画廊
|
画廊
|
||||||
@@ -195,10 +195,10 @@ async function handleLogout() {
|
|||||||
<RouterLink
|
<RouterLink
|
||||||
v-if="authStore.isLoggedIn"
|
v-if="authStore.isLoggedIn"
|
||||||
to="/encyclopedia"
|
to="/encyclopedia"
|
||||||
class="oc-nav-link"
|
class="px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
||||||
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? activeNavClass : inactiveNavClass"
|
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? activeNavClass : inactiveNavClass"
|
||||||
>
|
>
|
||||||
百科
|
图鉴
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -210,7 +210,7 @@ async function handleLogout() {
|
|||||||
class="no-underline"
|
class="no-underline"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="oc-header-button oc-header-button--sky uppercase tracking-[0.12em]"
|
class="inline-flex h-8 items-center border border-sky-200 bg-sky-100 px-3 text-sm font-medium uppercase tracking-[0.12em] text-sky-800 shadow-[4px_4px_0_0_rgba(14,165,233,0.14)] transition-[background-color,color,transform,box-shadow] hover:-translate-x-px hover:-translate-y-px hover:bg-sky-50 hover:text-sky-900 hover:shadow-[5px_5px_0_0_rgba(14,165,233,0.18)] md:h-10 md:px-4"
|
||||||
:class="route.name === 'upload' ? 'ring-1 ring-sky-300' : ''"
|
:class="route.name === 'upload' ? 'ring-1 ring-sky-300' : ''"
|
||||||
>
|
>
|
||||||
上传
|
上传
|
||||||
@@ -227,8 +227,8 @@ async function handleLogout() {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="oc-header-button oc-header-button--teal max-w-[8.5rem] gap-2 md:max-w-none"
|
class="inline-flex h-8 max-w-[8.5rem] items-center gap-2 border border-teal-100 bg-white/80 px-3 text-sm font-medium text-teal-700 shadow-[3px_3px_0_0_rgba(20,184,166,0.08)] transition-[background-color,color,transform,box-shadow,border-color] hover:-translate-x-px hover:-translate-y-px hover:border-teal-200 hover:bg-teal-50/70 hover:text-teal-800 hover:shadow-[4px_4px_0_0_rgba(20,184,166,0.12)] md:h-10 md:max-w-none"
|
||||||
:class="route.name === 'profile' || route.name === 'profile-settings' ? 'border-teal-200 bg-teal-50/80 text-teal-800 ring-1 ring-teal-200' : ''"
|
:class="route.name === 'profile' || route.name === 'profile-settings' ? 'border-teal-200 bg-teal-50/80 text-teal-800 ring-1 ring-teal-200 shadow-[4px_4px_0_0_rgba(20,184,166,0.12)]' : ''"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
:aria-expanded="accountCardOpen"
|
:aria-expanded="accountCardOpen"
|
||||||
>
|
>
|
||||||
@@ -243,10 +243,10 @@ async function handleLogout() {
|
|||||||
class="absolute right-0 top-full z-50 w-72 pt-3"
|
class="absolute right-0 top-full z-50 w-72 pt-3"
|
||||||
role="menu"
|
role="menu"
|
||||||
>
|
>
|
||||||
<div class="oc-floating-panel border border-slate-200 bg-white">
|
<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="border-b border-slate-200 bg-[linear-gradient(180deg,#f0fdfa_0%,#ffffff_100%)] px-4 py-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex h-11 w-11 shrink-0 items-center justify-center border border-teal-200 bg-[linear-gradient(135deg,#eef9f3_0%,#d9efe3_100%)] text-lg font-bold text-teal-800">
|
<div class="flex h-11 w-11 shrink-0 items-center justify-center border border-teal-200 bg-[linear-gradient(135deg,#ecfeff_0%,#ccfbf1_100%)] text-lg font-bold text-teal-800">
|
||||||
{{ displayUsername.slice(0, 1).toUpperCase() }}
|
{{ displayUsername.slice(0, 1).toUpperCase() }}
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
@@ -265,7 +265,7 @@ async function handleLogout() {
|
|||||||
<div class="py-2">
|
<div class="py-2">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/profile"
|
to="/profile"
|
||||||
class="oc-menu-item"
|
class="flex items-center gap-3 px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-teal-50 hover:text-teal-800"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
<NIcon size="18"><User /></NIcon>
|
<NIcon size="18"><User /></NIcon>
|
||||||
@@ -273,7 +273,7 @@ async function handleLogout() {
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/profile/settings"
|
to="/profile/settings"
|
||||||
class="oc-menu-item"
|
class="flex items-center gap-3 px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-teal-50 hover:text-teal-800"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
<NIcon size="18"><Settings /></NIcon>
|
<NIcon size="18"><Settings /></NIcon>
|
||||||
@@ -282,7 +282,7 @@ async function handleLogout() {
|
|||||||
<RouterLink
|
<RouterLink
|
||||||
v-if="authStore.isAdmin"
|
v-if="authStore.isAdmin"
|
||||||
to="/admin"
|
to="/admin"
|
||||||
class="oc-menu-item oc-menu-item--amber"
|
class="flex items-center gap-3 px-4 py-2.5 text-sm font-medium text-slate-700 transition-colors hover:bg-amber-50 hover:text-amber-700"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
>
|
>
|
||||||
<NIcon size="18"><Shield /></NIcon>
|
<NIcon size="18"><Shield /></NIcon>
|
||||||
@@ -293,7 +293,7 @@ async function handleLogout() {
|
|||||||
<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="flex w-full items-center gap-3 px-2 py-2.5 text-left text-sm font-medium text-rose-600 transition-colors hover:bg-rose-50 hover:text-rose-700"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
@click="handleLogout"
|
@click="handleLogout"
|
||||||
>
|
>
|
||||||
@@ -312,7 +312,7 @@ async function handleLogout() {
|
|||||||
class="no-underline"
|
class="no-underline"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="oc-header-button oc-header-button--neutral"
|
class="inline-flex h-8 items-center border border-slate-200 bg-white/80 px-3 text-sm font-medium text-slate-700 shadow-[3px_3px_0_0_rgba(15,23,42,0.06)] transition-[background-color,color,transform,box-shadow,border-color] hover:-translate-x-px hover:-translate-y-px hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800 hover:shadow-[4px_4px_0_0_rgba(15,23,42,0.08)] md:h-10 md:px-4"
|
||||||
:class="route.name === 'login' ? 'bg-teal-50 ring-0 ring-teal-100 text-teal-800' : ''"
|
:class="route.name === 'login' ? 'bg-teal-50 ring-0 ring-teal-100 text-teal-800' : ''"
|
||||||
>
|
>
|
||||||
登录
|
登录
|
||||||
@@ -323,7 +323,7 @@ async function handleLogout() {
|
|||||||
class="no-underline"
|
class="no-underline"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="oc-header-button oc-header-button--sky"
|
class="inline-flex h-8 items-center border border-sky-200 bg-sky-100 px-3 text-sm font-medium text-sky-800 shadow-[4px_4px_0_0_rgba(14,165,233,0.14)] transition-[background-color,color,transform,box-shadow] hover:-translate-x-px hover:-translate-y-px hover:bg-sky-50 hover:text-sky-900 hover:shadow-[5px_5px_0_0_rgba(14,165,233,0.18)] md:h-10 md:px-4"
|
||||||
:class="route.name === 'register' ? 'ring-0 ring-sky-300' : ''"
|
:class="route.name === 'register' ? 'ring-0 ring-sky-300' : ''"
|
||||||
>
|
>
|
||||||
注册
|
注册
|
||||||
@@ -337,21 +337,21 @@ async function handleLogout() {
|
|||||||
<nav class="flex items-center gap-2 overflow-x-auto border-t border-slate-200/80 py-2 md:hidden">
|
<nav class="flex items-center gap-2 overflow-x-auto border-t border-slate-200/80 py-2 md:hidden">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/"
|
to="/"
|
||||||
class="oc-nav-link shrink-0"
|
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
||||||
:class="route.name === 'map' ? activeNavClass : inactiveNavClass"
|
:class="route.name === 'map' ? activeNavClass : inactiveNavClass"
|
||||||
>
|
>
|
||||||
地图
|
地图
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/community"
|
to="/community"
|
||||||
class="oc-nav-link shrink-0"
|
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
||||||
:class="route.name === 'community' ? activeNavClass : inactiveNavClass"
|
:class="route.name === 'community' ? activeNavClass : inactiveNavClass"
|
||||||
>
|
>
|
||||||
社区
|
社区
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/gallery"
|
to="/gallery"
|
||||||
class="oc-nav-link shrink-0"
|
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
||||||
:class="route.name === 'gallery' ? activeNavClass : inactiveNavClass"
|
:class="route.name === 'gallery' ? activeNavClass : inactiveNavClass"
|
||||||
>
|
>
|
||||||
画廊
|
画廊
|
||||||
@@ -359,56 +359,31 @@ async function handleLogout() {
|
|||||||
<RouterLink
|
<RouterLink
|
||||||
v-if="authStore.isLoggedIn"
|
v-if="authStore.isLoggedIn"
|
||||||
to="/encyclopedia"
|
to="/encyclopedia"
|
||||||
class="oc-nav-link shrink-0"
|
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
||||||
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? activeNavClass : inactiveNavClass"
|
:class="route.name === 'encyclopedia' || route.name === 'cloud-type' ? activeNavClass : inactiveNavClass"
|
||||||
>
|
>
|
||||||
百科
|
图鉴
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink
|
||||||
|
v-if="!authStore.loading && authStore.isLoggedIn"
|
||||||
|
to="/profile"
|
||||||
|
class="shrink-0 px-3 py-2 text-sm font-medium tracking-[0.12em] uppercase transition-colors"
|
||||||
|
:class="route.name === 'profile' ? activeNavClass : inactiveNavClass"
|
||||||
|
>
|
||||||
|
主页
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Transition name="header-reveal">
|
<button
|
||||||
<div
|
v-if="headerHidden"
|
||||||
v-if="headerHidden"
|
type="button"
|
||||||
class="pointer-events-none absolute inset-x-0 top-3 flex justify-center"
|
class="absolute left-1/2 top-3 flex -translate-x-1/2 items-center gap-2 border border-slate-300 bg-white/94 px-3 py-1.5 text-xs font-medium tracking-[0.16em] text-slate-700 shadow-[4px_4px_0_0_rgba(15,23,42,0.08)] backdrop-blur transition-colors hover:border-slate-900 hover:text-slate-900"
|
||||||
>
|
@click="showHeader(true)"
|
||||||
<button
|
>
|
||||||
type="button"
|
<span>▼</span>
|
||||||
class="oc-header-button oc-header-button--neutral pointer-events-auto gap-2 bg-white/94 text-xs tracking-[0.16em] backdrop-blur"
|
<span>展开导航</span>
|
||||||
@click="showHeader(true)"
|
</button>
|
||||||
>
|
|
||||||
<NIcon size="15"><ChevronDown /></NIcon>
|
|
||||||
<span>展开导航</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</Transition>
|
|
||||||
</div>
|
</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>
|
|
||||||
|
|||||||
@@ -22,11 +22,15 @@ const props = withDefaults(defineProps<{
|
|||||||
cells: ContributionHeatmapCell[]
|
cells: ContributionHeatmapCell[]
|
||||||
totalCount: number
|
totalCount: number
|
||||||
activeDays: number
|
activeDays: number
|
||||||
|
currentStreak: number
|
||||||
|
bestStreak: number
|
||||||
activeDate?: string | null
|
activeDate?: string | null
|
||||||
title?: string
|
title?: string
|
||||||
|
subtitle?: string
|
||||||
}>(), {
|
}>(), {
|
||||||
activeDate: null,
|
activeDate: null,
|
||||||
title: '上传活跃度',
|
title: '上传活跃度',
|
||||||
|
subtitle: '按上传日期统计',
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -150,38 +154,31 @@ const visibleCells = computed(() => {
|
|||||||
return cells
|
return cells
|
||||||
})
|
})
|
||||||
|
|
||||||
const contributionQuartiles = computed(() => {
|
const maxCount = computed(() => {
|
||||||
const counts = visibleCells.value
|
return visibleCells.value.reduce((max, cell) => Math.max(max, cell.count), 0)
|
||||||
.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) {
|
function getLevel(count: number) {
|
||||||
if (count <= 0) return 0
|
if (count <= 0) return 0
|
||||||
|
|
||||||
const [firstQuartile, secondQuartile, thirdQuartile] = contributionQuartiles.value
|
const max = Math.max(maxCount.value, 1)
|
||||||
|
const ratio = count / max
|
||||||
|
|
||||||
if (count <= firstQuartile) return 1
|
if (ratio >= 0.75 || count >= 8) return 4
|
||||||
if (count <= secondQuartile) return 2
|
if (ratio >= 0.5 || count >= 5) return 3
|
||||||
if (count <= thirdQuartile) return 3
|
if (ratio >= 0.25 || count >= 3) return 2
|
||||||
return 4
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCellClass(cell: { count: number; inRange: boolean }) {
|
function getCellClass(cell: { count: number; inRange: boolean }) {
|
||||||
if (!cell.inRange) return 'bg-transparent border-transparent'
|
if (!cell.inRange) return 'bg-transparent border-transparent'
|
||||||
|
|
||||||
const level = getLevel(cell.count)
|
const level = getLevel(cell.count)
|
||||||
return `oc-heatmap-cell--level-${level}`
|
if (level === 0) return 'bg-zinc-100 border-zinc-200'
|
||||||
|
if (level === 1) return 'bg-[#e0f2fe] border-[#bae6fd]'
|
||||||
|
if (level === 2) return 'bg-[#7dd3fc] border-[#38bdf8]'
|
||||||
|
if (level === 3) return 'bg-[#0ea5e9] border-[#0284c7]'
|
||||||
|
return 'bg-[#0f3d63] border-[#0b2f4d]'
|
||||||
}
|
}
|
||||||
|
|
||||||
const weeks = computed(() => {
|
const weeks = computed(() => {
|
||||||
@@ -287,7 +284,7 @@ function formatMonthTooltip(monthKey: string) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="oc-panel-card border border-slate-200 bg-white p-6">
|
<div class="oc-panel-card border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
<div class="flex flex-wrap items-start justify-between gap-6">
|
<div class="flex flex-wrap items-start justify-between gap-6">
|
||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<h3 class="text-2xl font-bold text-slate-900">{{ title }}</h3>
|
<h3 class="text-2xl font-bold text-slate-900">{{ title }}</h3>
|
||||||
@@ -382,7 +379,7 @@ function formatMonthTooltip(monthKey: string) {
|
|||||||
v-for="cell in week"
|
v-for="cell in week"
|
||||||
:key="cell.date"
|
:key="cell.date"
|
||||||
type="button"
|
type="button"
|
||||||
class="oc-heatmap-cell relative h-3.5 w-3.5 border hover:scale-125"
|
class="relative h-3.5 w-3.5 border transition-transform hover:scale-125"
|
||||||
:class="[
|
:class="[
|
||||||
getCellClass(cell),
|
getCellClass(cell),
|
||||||
props.activeDate === cell.date ? 'ring-2 ring-slate-900 ring-offset-1' : '',
|
props.activeDate === cell.date ? 'ring-2 ring-slate-900 ring-offset-1' : '',
|
||||||
@@ -399,53 +396,12 @@ function formatMonthTooltip(monthKey: string) {
|
|||||||
|
|
||||||
<div class="mt-5 flex items-center justify-end gap-2 text-xs text-slate-500">
|
<div class="mt-5 flex items-center justify-end gap-2 text-xs text-slate-500">
|
||||||
<span>少</span>
|
<span>少</span>
|
||||||
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-0"></span>
|
<span class="h-3 w-3 border border-slate-200 bg-slate-100"></span>
|
||||||
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-1"></span>
|
<span class="h-3 w-3 border border-sky-200 bg-sky-100"></span>
|
||||||
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-2"></span>
|
<span class="h-3 w-3 border border-sky-400 bg-sky-300"></span>
|
||||||
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-3"></span>
|
<span class="h-3 w-3 border border-teal-600 bg-teal-500"></span>
|
||||||
<span class="oc-heatmap-legend-cell oc-heatmap-cell--level-4"></span>
|
<span class="h-3 w-3 border border-slate-900 bg-slate-900"></span>
|
||||||
<span>多</span>
|
<span>多</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import { apiRequest } from '@/lib/api'
|
|||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useProfileStore } from '@/stores/profile'
|
import { useProfileStore } from '@/stores/profile'
|
||||||
import type { CloudCreateResponse } from '@/types/api'
|
import type { CloudCreateResponse } from '@/types/api'
|
||||||
|
import type { CloudType } from '@/types/database'
|
||||||
|
|
||||||
export interface UploadItem {
|
export interface UploadItem {
|
||||||
id: string
|
id: string
|
||||||
file: File
|
file: File
|
||||||
preview: string
|
preview: string
|
||||||
cloudCategoryId: number | null
|
cloudCategoryId: number | 'other' | null
|
||||||
|
customCloudType: string
|
||||||
|
locationName: string
|
||||||
description: string
|
description: string
|
||||||
isHidden: boolean
|
isHidden: boolean
|
||||||
latitude: number | null
|
latitude: number | null
|
||||||
@@ -17,9 +20,17 @@ export interface UploadItem {
|
|||||||
errors: Record<string, string>
|
errors: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UnlockedBadge {
|
||||||
|
cloudTypeId: number
|
||||||
|
cloudName: string
|
||||||
|
cloudNameEn: string
|
||||||
|
rarity: CloudType['rarity']
|
||||||
|
unlockedAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface UploadResult {
|
export interface UploadResult {
|
||||||
ok: boolean
|
ok: boolean
|
||||||
errorMessage?: string
|
unlockedBadges: UnlockedBadge[]
|
||||||
}
|
}
|
||||||
|
|
||||||
let nextId = 0
|
let nextId = 0
|
||||||
@@ -35,7 +46,7 @@ function readFileAsArrayBuffer(file: File): Promise<ArrayBuffer> {
|
|||||||
|
|
||||||
function extractExifDate(buffer: ArrayBuffer): string | null {
|
function extractExifDate(buffer: ArrayBuffer): string | null {
|
||||||
const view = new DataView(buffer)
|
const view = new DataView(buffer)
|
||||||
if (view.byteLength < 4 || view.getUint16(0, false) !== 0xffd8) return null
|
if (view.getUint16(0, false) !== 0xffd8) return null
|
||||||
|
|
||||||
let offset = 2
|
let offset = 2
|
||||||
while (offset < view.byteLength - 2) {
|
while (offset < view.byteLength - 2) {
|
||||||
@@ -78,8 +89,8 @@ function extractExifDate(buffer: ArrayBuffer): string | null {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendOptional(form: FormData, key: string, value: string) {
|
function appendOptional(form: FormData, key: string, value: string | number | null) {
|
||||||
if (value.trim()) form.append(key, value.trim())
|
if (value !== null && value !== '') form.append(key, String(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpload() {
|
export function useUpload() {
|
||||||
@@ -92,13 +103,14 @@ export function useUpload() {
|
|||||||
const totalItems = ref(0)
|
const totalItems = ref(0)
|
||||||
|
|
||||||
async function addFiles(files: File[]) {
|
async function addFiles(files: File[]) {
|
||||||
const imageFiles = files.filter(file => ['image/jpeg', 'image/png', 'image/webp'].includes(file.type))
|
const imageFiles = files.filter(file => file.type.startsWith('image/'))
|
||||||
for (const file of imageFiles) {
|
for (const file of imageFiles) {
|
||||||
let capturedAt = ''
|
let capturedAt = new Date().toISOString()
|
||||||
try {
|
try {
|
||||||
capturedAt = extractExifDate(await readFileAsArrayBuffer(file)) || ''
|
const exifDate = extractExifDate(await readFileAsArrayBuffer(file))
|
||||||
|
if (exifDate) capturedAt = exifDate
|
||||||
} catch {
|
} catch {
|
||||||
capturedAt = ''
|
// 无 EXIF 时使用当前时间。
|
||||||
}
|
}
|
||||||
|
|
||||||
items.value.push({
|
items.value.push({
|
||||||
@@ -106,6 +118,8 @@ export function useUpload() {
|
|||||||
file,
|
file,
|
||||||
preview: URL.createObjectURL(file),
|
preview: URL.createObjectURL(file),
|
||||||
cloudCategoryId: null,
|
cloudCategoryId: null,
|
||||||
|
customCloudType: '',
|
||||||
|
locationName: '',
|
||||||
description: '',
|
description: '',
|
||||||
isHidden: false,
|
isHidden: false,
|
||||||
latitude: null,
|
latitude: null,
|
||||||
@@ -131,24 +145,15 @@ export function useUpload() {
|
|||||||
function validateItem(item: UploadItem) {
|
function validateItem(item: UploadItem) {
|
||||||
const errors: Record<string, string> = {}
|
const errors: Record<string, string> = {}
|
||||||
if (!item.cloudCategoryId) errors.cloudCategory = '请选择类别'
|
if (!item.cloudCategoryId) errors.cloudCategory = '请选择类别'
|
||||||
if (item.file.size === 0) errors.file = '图片文件为空'
|
if (item.cloudCategoryId === 'other' && !item.customCloudType.trim()) {
|
||||||
if (item.file.size > 20 * 1024 * 1024) errors.file = '图片不能超过 20MB'
|
errors.cloudCategory = '请输入自定义云型名称'
|
||||||
if (!['image/jpeg', 'image/png', 'image/webp'].includes(item.file.type)) {
|
|
||||||
errors.file = '仅支持 JPEG、PNG、WebP 格式'
|
|
||||||
}
|
}
|
||||||
|
if (!item.capturedAt) errors.capturedAt = '请选择拍摄时间'
|
||||||
|
|
||||||
if (item.latitude === null || Number.isNaN(item.latitude)) {
|
const hasLat = typeof item.latitude === 'number' && !isNaN(item.latitude)
|
||||||
errors.latitude = '请填写纬度'
|
const hasLng = typeof item.longitude === 'number' && !isNaN(item.longitude)
|
||||||
} else if (item.latitude < -90 || item.latitude > 90) {
|
if (hasLat && !hasLng) errors.longitude = '经纬度必须同时填写'
|
||||||
errors.latitude = '纬度范围为 -90 到 90'
|
if (hasLng && !hasLat) errors.latitude = '经纬度必须同时填写'
|
||||||
}
|
|
||||||
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
|
item.errors = errors
|
||||||
return Object.keys(errors).length === 0
|
return Object.keys(errors).length === 0
|
||||||
}
|
}
|
||||||
@@ -158,12 +163,13 @@ export function useUpload() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function uploadAll(): Promise<UploadResult> {
|
async function uploadAll(): Promise<UploadResult> {
|
||||||
if (!validateAll() || !authStore.user) return { ok: false }
|
if (!validateAll() || !authStore.user) return { ok: false, unlockedBadges: [] }
|
||||||
|
|
||||||
uploading.value = true
|
uploading.value = true
|
||||||
totalItems.value = items.value.length
|
totalItems.value = items.value.length
|
||||||
currentItemIndex.value = 0
|
currentItemIndex.value = 0
|
||||||
overallProgress.value = 0
|
overallProgress.value = 0
|
||||||
|
const badges = new Map<number, UnlockedBadge>()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (let index = 0; index < items.value.length; index++) {
|
for (let index = 0; index < items.value.length; index++) {
|
||||||
@@ -173,28 +179,40 @@ export function useUpload() {
|
|||||||
|
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append('image', item.file)
|
form.append('image', item.file)
|
||||||
form.append('type_id', String(item.cloudCategoryId))
|
if (item.cloudCategoryId === 'other') {
|
||||||
form.append('latitude', String(item.latitude))
|
form.append('custom_cloud_type', item.customCloudType.trim())
|
||||||
form.append('longitude', String(item.longitude))
|
} else {
|
||||||
appendOptional(form, 'description', item.description)
|
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)
|
appendOptional(form, 'captured_at', item.capturedAt)
|
||||||
form.append('is_hidden', String(item.isHidden))
|
form.append('is_hidden', String(item.isHidden))
|
||||||
|
|
||||||
await apiRequest<CloudCreateResponse>('/cloud', {
|
const result = await apiRequest<CloudCreateResponse>('/clouds', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: form,
|
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)
|
overallProgress.value = Math.round((index + 1) / items.value.length * 100)
|
||||||
}
|
}
|
||||||
|
|
||||||
clearAll()
|
clearAll()
|
||||||
profileStore.invalidateUser(authStore.user.id)
|
profileStore.invalidateUser(authStore.user.id)
|
||||||
return { ok: true }
|
return { ok: true, unlockedBadges: [...badges.values()] }
|
||||||
} catch (error) {
|
} catch {
|
||||||
return {
|
return { ok: false, unlockedBadges: [] }
|
||||||
ok: false,
|
|
||||||
errorMessage: error instanceof Error ? error.message : '上传失败,请稍后重试',
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
uploading.value = false
|
uploading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-123
@@ -1,17 +1,14 @@
|
|||||||
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'
|
||||||
|
|
||||||
|
let accessToken: string | null = null
|
||||||
|
let refreshPromise: Promise<AuthResponse> | null = null
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number
|
status: number
|
||||||
detail: unknown
|
detail: unknown
|
||||||
@@ -27,8 +24,6 @@ export class ApiError extends Error {
|
|||||||
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
|
||||||
// Kept for callers that explicitly disable retries. Cookie sessions do not
|
|
||||||
// use a refresh-and-retry flow, so this option is intentionally ignored.
|
|
||||||
retry?: boolean
|
retry?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,71 +36,43 @@ function isJsonBody(body: ApiRequestOptions['body']): body is Record<string, unk
|
|||||||
&& !(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
|
|
||||||
const messages = issues
|
|
||||||
.map(issue => issue && typeof issue === 'object' && 'message' in issue ? String(issue.message) : '')
|
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
return messages.join(';') || value
|
if (messages.length) return messages.join(';')
|
||||||
} 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
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readPayload(response: Response) {
|
async function toApiError(response: Response) {
|
||||||
const text = await response.text()
|
let detail: unknown
|
||||||
if (!text) return undefined
|
|
||||||
|
|
||||||
const contentType = response.headers.get('content-type') || ''
|
|
||||||
if (!contentType.includes('json')) return text
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return JSON.parse(text) as unknown
|
const payload = await response.json() as { detail?: unknown }
|
||||||
|
detail = payload.detail
|
||||||
} catch {
|
} catch {
|
||||||
return text
|
detail = undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return new ApiError(
|
||||||
|
detailToMessage(detail, `请求失败(${response.status})`),
|
||||||
|
response.status,
|
||||||
|
detail,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toApiError(response: Response) {
|
export function setAccessToken(token: string | null) {
|
||||||
const payload = await readPayload(response)
|
accessToken = token
|
||||||
const fallback = typeof payload === 'string' && payload.trim()
|
}
|
||||||
? payload.trim()
|
|
||||||
: `请求失败(${response.status})`
|
|
||||||
|
|
||||||
return new ApiError(errorMessage(payload, fallback), response.status, payload)
|
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, retry: _retry, ...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
|
||||||
|
|
||||||
@@ -114,6 +81,10 @@ async function request<T>(path: string, options: ApiRequestOptions = {}) {
|
|||||||
requestBody = JSON.stringify(body)
|
requestBody = JSON.stringify(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (auth && accessToken) {
|
||||||
|
headers.set('Authorization', `Bearer ${accessToken}`)
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`${API_URL}${path}`, {
|
const response = await fetch(`${API_URL}${path}`, {
|
||||||
...requestOptions,
|
...requestOptions,
|
||||||
body: requestBody,
|
body: requestBody,
|
||||||
@@ -121,77 +92,43 @@ async function request<T>(path: string, options: ApiRequestOptions = {}) {
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
})
|
})
|
||||||
|
|
||||||
if (response.status === 401 && auth && typeof window !== 'undefined') {
|
if (response.status === 401 && auth && accessToken && retry) {
|
||||||
window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT))
|
try {
|
||||||
|
const authResponse = await refreshSession()
|
||||||
|
window.dispatchEvent(new CustomEvent<AuthResponse>(AUTH_UPDATED_EVENT, { detail: authResponse }))
|
||||||
|
return request<T>(path, { ...options, retry: false })
|
||||||
|
} catch {
|
||||||
|
clearAccessToken()
|
||||||
|
window.dispatchEvent(new Event(AUTH_EXPIRED_EVENT))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) throw await toApiError(response)
|
if (!response.ok) throw await toApiError(response)
|
||||||
return await readPayload(response) as T
|
if (response.status === 204) return undefined as T
|
||||||
|
|
||||||
|
return response.json() as Promise<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
|
||||||
avatar_id: profile.avatar_id,
|
})
|
||||||
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)
|
||||||
|
}
|
||||||
+10
-10
@@ -1,14 +1,14 @@
|
|||||||
export const CLOUD_TYPES = [
|
export const CLOUD_TYPES = [
|
||||||
{ id: 1, name: '积云' },
|
{ id: 1, name: '积云', nameEn: 'Cumulus' },
|
||||||
{ id: 2, name: '层云' },
|
{ id: 2, name: '层云', nameEn: 'Stratus' },
|
||||||
{ id: 3, name: '卷云' },
|
{ id: 3, name: '卷云', nameEn: 'Cirrus' },
|
||||||
{ id: 4, name: '积雨云' },
|
{ id: 4, name: '积雨云', nameEn: 'Cumulonimbus' },
|
||||||
{ id: 5, name: '层积云' },
|
{ id: 5, name: '层积云', nameEn: 'Stratocumulus' },
|
||||||
{ id: 6, name: '高积云' },
|
{ id: 6, name: '高积云', nameEn: 'Altocumulus' },
|
||||||
{ id: 7, name: '高层云' },
|
{ id: 7, name: '高层云', nameEn: 'Altostratus' },
|
||||||
{ id: 8, name: '雨层云' },
|
{ id: 8, name: '雨层云', nameEn: 'Nimbostratus' },
|
||||||
{ id: 9, name: '卷层云' },
|
{ id: 9, name: '卷层云', nameEn: 'Cirrostratus' },
|
||||||
{ id: 10, name: '卷积云' },
|
{ id: 10, name: '卷积云', nameEn: 'Cirrocumulus' },
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
export function getCloudTypeName(id: string | string[]) {
|
export function getCloudTypeName(id: string | string[]) {
|
||||||
|
|||||||
+7
-10
@@ -3,22 +3,19 @@ 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: '#0f766e',
|
||||||
primaryColorHover: '#2d5f4e',
|
primaryColorHover: '#115e59',
|
||||||
primaryColorPressed: '#274c40',
|
primaryColorPressed: '#134e4a',
|
||||||
primaryColorSuppl: '#34745d',
|
primaryColorSuppl: '#0f766e',
|
||||||
infoColor: '#0369a1',
|
infoColor: '#0369a1',
|
||||||
successColor: '#4d7f3b',
|
successColor: '#0f766e',
|
||||||
successColorHover: '#3f6633',
|
|
||||||
successColorPressed: '#35522c',
|
|
||||||
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',
|
||||||
@@ -27,7 +24,7 @@ export const openCloudThemeOverrides: GlobalThemeOverrides = {
|
|||||||
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',
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ 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 './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)
|
||||||
|
|||||||
+5
-13
@@ -27,12 +27,6 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/auth/RegisterView.vue'),
|
component: () => import('@/views/auth/RegisterView.vue'),
|
||||||
meta: { title: '注册', noindex: true },
|
meta: { title: '注册', noindex: true },
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: '/resend-confirmation',
|
|
||||||
name: 'resend-confirmation',
|
|
||||||
component: () => import('@/views/auth/ResendConfirmationView.vue'),
|
|
||||||
meta: { title: '重发验证邮件', noindex: true },
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: '/forgot-password',
|
path: '/forgot-password',
|
||||||
name: 'forgot-password',
|
name: 'forgot-password',
|
||||||
@@ -40,15 +34,13 @@ const router = createRouter({
|
|||||||
meta: { title: '忘记密码', noindex: true },
|
meta: { title: '忘记密码', noindex: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/verify-email',
|
path: '/auth/confirm',
|
||||||
alias: '/auth/confirm',
|
|
||||||
name: 'auth-confirm',
|
name: 'auth-confirm',
|
||||||
component: () => import('@/views/auth/AuthConfirmView.vue'),
|
component: () => import('@/views/auth/AuthConfirmView.vue'),
|
||||||
meta: { title: '确认邮箱', noindex: true },
|
meta: { title: '确认邮箱', noindex: true },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/reset-password',
|
path: '/auth/reset-password',
|
||||||
alias: '/auth/reset-password',
|
|
||||||
name: 'auth-reset-password',
|
name: 'auth-reset-password',
|
||||||
component: () => import('@/views/auth/ResetPasswordView.vue'),
|
component: () => import('@/views/auth/ResetPasswordView.vue'),
|
||||||
meta: { title: '重置密码', noindex: true },
|
meta: { title: '重置密码', noindex: true },
|
||||||
@@ -65,8 +57,8 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/encyclopedia/EncyclopediaView.vue'),
|
component: () => import('@/views/encyclopedia/EncyclopediaView.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
title: '云朵百科',
|
title: '云朵图鉴',
|
||||||
description: '浏览基础云属,了解云型特征和社区公开观测记录。',
|
description: '浏览 10 种基础云属,了解云型特征,并通过拍摄云图点亮个人图鉴。',
|
||||||
noindex: true,
|
noindex: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -117,7 +109,7 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/profile/ProfileView.vue'),
|
component: () => import('@/views/profile/ProfileView.vue'),
|
||||||
meta: {
|
meta: {
|
||||||
title: '用户云图档案',
|
title: '用户云图档案',
|
||||||
description: '按用户名查看用户公开分享的云图记录和拍摄时间线。',
|
description: '查看用户公开分享的云图记录、图鉴进度和拍摄时间线。',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+43
-76
@@ -1,92 +1,54 @@
|
|||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { AUTH_EXPIRED_EVENT, apiRequest, toAuthUser } from '@/lib/api'
|
import {
|
||||||
import type { ActionResponse, BackendUserProfile, LoginResponse, RegisterResponse } from '@/types/api'
|
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'
|
import type { Profile } from '@/types/database'
|
||||||
|
|
||||||
export const useAuthStore = defineStore('auth', () => {
|
export const useAuthStore = defineStore('auth', () => {
|
||||||
const user = ref<ReturnType<typeof toAuthUser> | null>(null)
|
const user = ref<AuthUser | null>(null)
|
||||||
const profile = ref<Profile | null>(null)
|
const profile = ref<Profile | null>(null)
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
let listenerAttached = false
|
let listenersAttached = false
|
||||||
|
|
||||||
const isLoggedIn = computed(() => !!user.value)
|
const isLoggedIn = computed(() => !!user.value)
|
||||||
const isAdmin = computed(() => profile.value?.role === 'admin')
|
const isAdmin = computed(() => profile.value?.role === 'admin')
|
||||||
|
|
||||||
function applyProfile(backendProfile: BackendUserProfile) {
|
function applyAuth(auth: AuthResponse) {
|
||||||
const normalized = toAuthUser(backendProfile)
|
setAccessToken(auth.access_token)
|
||||||
user.value = normalized
|
user.value = auth.user
|
||||||
profile.value = {
|
profile.value = auth.profile
|
||||||
id: normalized.id,
|
|
||||||
username: normalized.username,
|
|
||||||
avatar_id: normalized.avatar_id,
|
|
||||||
role: normalized.role,
|
|
||||||
is_disabled: normalized.is_disabled,
|
|
||||||
created_at: normalized.created_at,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearAuth() {
|
function clearAuth() {
|
||||||
|
clearAccessToken()
|
||||||
user.value = null
|
user.value = null
|
||||||
profile.value = null
|
profile.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchMe() {
|
|
||||||
const backendProfile = await apiRequest<BackendUserProfile>('/profile/me')
|
|
||||||
applyProfile(backendProfile)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function login(email: string, password: string) {
|
async function login(email: string, password: string) {
|
||||||
await apiRequest<LoginResponse>('/auth/login', {
|
const auth = await apiRequest<AuthResponse>('/auth/login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
auth: false,
|
auth: false,
|
||||||
body: { email, password },
|
body: { email, password },
|
||||||
})
|
})
|
||||||
await fetchMe()
|
applyAuth(auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function register(name: string, email: string, password: string) {
|
async function register(email: string, password: string, username: string) {
|
||||||
return await apiRequest<RegisterResponse>('/auth/register', {
|
await apiRequest('/auth/register', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
auth: false,
|
auth: false,
|
||||||
body: { name, email, password },
|
body: { email, password, username },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resendConfirmation(email: string) {
|
|
||||||
return await apiRequest<ActionResponse>('/auth/resend-confirmation', {
|
|
||||||
method: 'POST',
|
|
||||||
auth: false,
|
|
||||||
body: { email },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function confirmEmail(token: string) {
|
|
||||||
return await apiRequest<ActionResponse>('/auth/confirm-email', {
|
|
||||||
method: 'POST',
|
|
||||||
auth: false,
|
|
||||||
body: { token },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendPasswordReset(email: string) {
|
|
||||||
return await apiRequest<ActionResponse>('/auth/forgot-password', {
|
|
||||||
method: 'POST',
|
|
||||||
auth: false,
|
|
||||||
body: { email },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resetPassword(email: string, token: string, newPassword: string) {
|
|
||||||
const response = await apiRequest<ActionResponse>('/auth/reset-password', {
|
|
||||||
method: 'POST',
|
|
||||||
auth: false,
|
|
||||||
body: { email, token, new_password: newPassword },
|
|
||||||
})
|
|
||||||
clearAuth()
|
|
||||||
return response
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout() {
|
async function logout() {
|
||||||
try {
|
try {
|
||||||
await apiRequest('/auth/logout', { method: 'POST', retry: false })
|
await apiRequest('/auth/logout', { method: 'POST', retry: false })
|
||||||
@@ -96,32 +58,40 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function updateUsername(username: string) {
|
async function updateUsername(username: string) {
|
||||||
const backendProfile = await apiRequest<BackendUserProfile>('/profile/me', {
|
const updatedProfile = await apiRequest<Profile>('/profiles/me', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: { user_name: username },
|
body: { username },
|
||||||
})
|
})
|
||||||
applyProfile(backendProfile)
|
profile.value = updatedProfile
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updatePassword(currentPassword: string, newPassword: string) {
|
async function updatePassword(password: string) {
|
||||||
await apiRequest('/auth/password', {
|
const auth = await apiRequest<AuthResponse>('/auth/password', {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: {
|
body: { password },
|
||||||
current_password: currentPassword,
|
})
|
||||||
new_password: newPassword,
|
applyAuth(auth)
|
||||||
},
|
}
|
||||||
|
|
||||||
|
async function sendPasswordReset(email: string) {
|
||||||
|
await apiRequest('/auth/forgot-password', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: false,
|
||||||
|
body: { email },
|
||||||
})
|
})
|
||||||
clearAuth()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
if (!listenerAttached) {
|
if (!listenersAttached) {
|
||||||
|
window.addEventListener(AUTH_UPDATED_EVENT, event => {
|
||||||
|
applyAuth((event as CustomEvent<AuthResponse>).detail)
|
||||||
|
})
|
||||||
window.addEventListener(AUTH_EXPIRED_EVENT, clearAuth)
|
window.addEventListener(AUTH_EXPIRED_EVENT, clearAuth)
|
||||||
listenerAttached = true
|
listenersAttached = true
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetchMe()
|
applyAuth(await refreshSession())
|
||||||
} catch {
|
} catch {
|
||||||
clearAuth()
|
clearAuth()
|
||||||
} finally {
|
} finally {
|
||||||
@@ -137,13 +107,10 @@ export const useAuthStore = defineStore('auth', () => {
|
|||||||
isAdmin,
|
isAdmin,
|
||||||
login,
|
login,
|
||||||
register,
|
register,
|
||||||
resendConfirmation,
|
|
||||||
confirmEmail,
|
|
||||||
sendPasswordReset,
|
|
||||||
resetPassword,
|
|
||||||
logout,
|
logout,
|
||||||
updateUsername,
|
updateUsername,
|
||||||
updatePassword,
|
updatePassword,
|
||||||
|
sendPasswordReset,
|
||||||
initialize,
|
initialize,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { apiRequest, toCloudType } from '@/lib/api'
|
import { apiRequest } from '@/lib/api'
|
||||||
import type { BackendCloudType } from '@/types/api'
|
|
||||||
import type { CloudType } from '@/types/database'
|
import type { CloudType } from '@/types/database'
|
||||||
|
|
||||||
export const useCloudsStore = defineStore('clouds', () => {
|
export const useCloudsStore = defineStore('clouds', () => {
|
||||||
@@ -12,8 +11,7 @@ export const useCloudsStore = defineStore('clouds', () => {
|
|||||||
if (cloudTypes.value.length > 0) return
|
if (cloudTypes.value.length > 0) return
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const rows = await apiRequest<BackendCloudType[]>('/info/cloudtype', { auth: false })
|
cloudTypes.value = await apiRequest<CloudType[]>('/cloud-types', { auth: false })
|
||||||
cloudTypes.value = rows.map(toCloudType)
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,104 @@
|
|||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { apiRequest, toCloudType } from '@/lib/api'
|
import { apiRequest } from '@/lib/api'
|
||||||
import type { BackendCloudType } from '@/types/api'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import type { CloudType } from '@/types/database'
|
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', () => {
|
export const useEncyclopediaStore = defineStore('encyclopedia', () => {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const cloudTypes = ref<CloudType[]>([])
|
const cloudTypes = ref<CloudType[]>([])
|
||||||
|
const myCollection = ref<CollectionEntry[]>([])
|
||||||
const loadingCloudTypes = ref(false)
|
const loadingCloudTypes = ref(false)
|
||||||
|
const loadingCollection = ref(false)
|
||||||
const cloudTypesLoaded = 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) {
|
async function fetchCloudTypes(force = false) {
|
||||||
if (cloudTypesLoaded.value && !force) return
|
if (cloudTypesLoaded.value && !force) return
|
||||||
loadingCloudTypes.value = true
|
loadingCloudTypes.value = true
|
||||||
try {
|
try {
|
||||||
const rows = await apiRequest<BackendCloudType[]>('/info/cloudtype', { auth: false })
|
cloudTypes.value = await apiRequest<CloudType[]>('/cloud-types', { auth: false })
|
||||||
cloudTypes.value = rows.map(toCloudType)
|
|
||||||
cloudTypesLoaded.value = true
|
cloudTypesLoaded.value = true
|
||||||
} finally {
|
} finally {
|
||||||
loadingCloudTypes.value = false
|
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 {
|
return {
|
||||||
cloudTypes,
|
cloudTypes,
|
||||||
|
myCollection,
|
||||||
loadingCloudTypes,
|
loadingCloudTypes,
|
||||||
|
loadingCollection,
|
||||||
|
collectionError,
|
||||||
|
unlockedCount,
|
||||||
|
unlockProgress,
|
||||||
|
unlockPercent,
|
||||||
fetchCloudTypes,
|
fetchCloudTypes,
|
||||||
|
fetchMyCollection,
|
||||||
|
isUnlocked,
|
||||||
|
getCollectionEntry,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+87
-99
@@ -1,118 +1,107 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ApiError, apiRequest, toApiCloud } from '@/lib/api'
|
import { apiRequest } from '@/lib/api'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import type { ActionResponse, ApiCloud, BackendCloudInfo } from '@/types/api'
|
import type { ApiCloud, ApiPage, PublicProfile } from '@/types/api'
|
||||||
import type { Profile } from '@/types/database'
|
import type { CloudType, Profile } from '@/types/database'
|
||||||
|
|
||||||
export interface ProfileCloudItem {
|
export interface ProfileCloudItem {
|
||||||
id: string
|
id: string
|
||||||
cloud_type_id: number
|
cloud_type_id: number | null
|
||||||
|
custom_cloud_type: string | null
|
||||||
image_url: string
|
image_url: string
|
||||||
thumbnail_url: string
|
thumbnail_url: string | null
|
||||||
latitude: number
|
latitude: number | null
|
||||||
longitude: number
|
longitude: number | null
|
||||||
|
location_name: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
captured_at: string | null
|
captured_at: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
status: 'pending' | 'approved' | 'rejected'
|
status: 'pending' | 'approved' | 'rejected'
|
||||||
is_hidden: boolean
|
is_hidden: boolean
|
||||||
cloudTypeName: string
|
cloudTypeName: string
|
||||||
cloudTypeRarity: number
|
cloudTypeRarity: CloudType['rarity']
|
||||||
receivedLikeCount: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function toProfileCloud(row: ApiCloud): ProfileCloudItem {
|
function toProfileCloud(row: ApiCloud): ProfileCloudItem {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
cloud_type_id: row.cloud_type_id as number,
|
cloud_type_id: row.cloud_type_id,
|
||||||
|
custom_cloud_type: row.custom_cloud_type,
|
||||||
image_url: row.image_url,
|
image_url: row.image_url,
|
||||||
thumbnail_url: row.thumbnail_url as string,
|
thumbnail_url: row.thumbnail_url,
|
||||||
latitude: row.latitude as number,
|
latitude: row.latitude,
|
||||||
longitude: row.longitude as number,
|
longitude: row.longitude,
|
||||||
|
location_name: row.location_name,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
captured_at: row.captured_at,
|
captured_at: row.captured_at,
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
is_hidden: row.is_hidden,
|
is_hidden: row.is_hidden,
|
||||||
cloudTypeName: row.cloud_type_name || '未知',
|
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
|
||||||
cloudTypeRarity: row.cloud_type_rarity_value ?? 0,
|
cloudTypeRarity: row.cloud_type_rarity || 'common',
|
||||||
receivedLikeCount: row.received_like_count,
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toProfile(profile: PublicProfile): Profile {
|
||||||
|
return {
|
||||||
|
...profile,
|
||||||
|
role: 'user',
|
||||||
|
is_disabled: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useProfileStore = defineStore('profile-page', () => {
|
export const useProfileStore = defineStore('profile-page', () => {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const profilesByKey = ref<Record<string, Profile>>({})
|
const profilesById = ref<Record<string, Profile>>({})
|
||||||
const cloudsByKey = ref<Record<string, ProfileCloudItem[]>>({})
|
const cloudsByKey = ref<Record<string, ProfileCloudItem[]>>({})
|
||||||
const loadingKeys = ref<Record<string, boolean>>({})
|
const loadingKeys = ref<Record<string, boolean>>({})
|
||||||
const errorByKey = ref<Record<string, string>>({})
|
const errorByKey = ref<Record<string, string>>({})
|
||||||
|
|
||||||
const makeKey = (identifier: string, isOwnProfile: boolean) => `${identifier}:${isOwnProfile ? 'own' : 'public'}`
|
const makeKey = (userId: string, isOwnProfile: boolean) => `${userId}:${isOwnProfile ? 'own' : 'public'}`
|
||||||
|
|
||||||
function getProfile(identifier: string | null) {
|
function getProfile(userId: string | null) {
|
||||||
return identifier ? profilesByKey.value[identifier] ?? null : null
|
return userId ? profilesById.value[userId] ?? null : null
|
||||||
}
|
}
|
||||||
|
|
||||||
function getClouds(identifier: string | null, isOwnProfile: boolean) {
|
function getClouds(userId: string | null, isOwnProfile: boolean) {
|
||||||
return identifier ? cloudsByKey.value[makeKey(identifier, isOwnProfile)] ?? [] : []
|
return userId ? cloudsByKey.value[makeKey(userId, isOwnProfile)] ?? [] : []
|
||||||
}
|
}
|
||||||
|
|
||||||
function getError(identifier: string | null, isOwnProfile: boolean) {
|
function getError(userId: string | null, isOwnProfile: boolean) {
|
||||||
return identifier ? errorByKey.value[makeKey(identifier, isOwnProfile)] ?? '' : ''
|
return userId ? errorByKey.value[makeKey(userId, isOwnProfile)] ?? '' : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLoaded(identifier: string | null, isOwnProfile: boolean) {
|
function isLoaded(userId: string | null, isOwnProfile: boolean) {
|
||||||
return !!identifier && makeKey(identifier, isOwnProfile) in cloudsByKey.value
|
return !!userId && makeKey(userId, isOwnProfile) in cloudsByKey.value
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchAllPublicClouds(username: string) {
|
async function fetchAllClouds(userId: string, isOwnProfile: boolean) {
|
||||||
const rows: BackendCloudInfo[] = []
|
const requestOptions = { auth: isOwnProfile }
|
||||||
for (let page = 1; ; page++) {
|
const first = await apiRequest<ApiPage<ApiCloud>>(`/profiles/${userId}/clouds?page=1&page_size=100`, requestOptions)
|
||||||
const params = new URLSearchParams({
|
if (first.total_pages <= 1) return first.items
|
||||||
page: String(page),
|
|
||||||
page_size: '100',
|
const remaining = await Promise.all(
|
||||||
filter: 'owner',
|
Array.from({ length: first.total_pages - 1 }, (_, index) =>
|
||||||
value: `@${username}`,
|
apiRequest<ApiPage<ApiCloud>>(`/profiles/${userId}/clouds?page=${index + 2}&page_size=100`, requestOptions),
|
||||||
})
|
),
|
||||||
let current: BackendCloudInfo[]
|
)
|
||||||
try {
|
return [first, ...remaining].flatMap(page => page.items)
|
||||||
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) {
|
async function fetchProfilePage(userId: string, isOwnProfile: boolean, force = false) {
|
||||||
const key = makeKey(identifier, isOwnProfile)
|
const key = makeKey(userId, isOwnProfile)
|
||||||
if (!force && key in cloudsByKey.value && profilesByKey.value[identifier]) return
|
if (!force && key in cloudsByKey.value && profilesById.value[userId]) return
|
||||||
|
|
||||||
loadingKeys.value[key] = true
|
loadingKeys.value[key] = true
|
||||||
errorByKey.value[key] = ''
|
errorByKey.value[key] = ''
|
||||||
try {
|
try {
|
||||||
const backendRows = isOwnProfile
|
const profilePromise = isOwnProfile && authStore.profile?.id === userId
|
||||||
? await apiRequest<BackendCloudInfo[]>('/profile/me/clouds')
|
? Promise.resolve(authStore.profile)
|
||||||
: await fetchAllPublicClouds(identifier)
|
: apiRequest<PublicProfile>(`/profiles/${userId}`, { auth: false }).then(toProfile)
|
||||||
const cloudRows = backendRows.map(toApiCloud).map(toProfileCloud)
|
const [profile, cloudRows] = await Promise.all([profilePromise, fetchAllClouds(userId, isOwnProfile)])
|
||||||
|
profilesById.value[userId] = profile
|
||||||
if (isOwnProfile && authStore.profile) {
|
cloudsByKey.value[key] = cloudRows.map(toProfileCloud)
|
||||||
profilesByKey.value[identifier] = authStore.profile
|
|
||||||
} else {
|
|
||||||
const owner = backendRows[0]?.owner
|
|
||||||
profilesByKey.value[identifier] = {
|
|
||||||
id: owner?.id || identifier,
|
|
||||||
username: owner?.name || identifier,
|
|
||||||
avatar_id: null,
|
|
||||||
role: 'user',
|
|
||||||
is_disabled: false,
|
|
||||||
created_at: '',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cloudsByKey.value[key] = cloudRows
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorByKey.value[key] = error instanceof Error ? error.message : '个人主页加载失败'
|
errorByKey.value[key] = error instanceof Error ? error.message : '个人主页加载失败'
|
||||||
cloudsByKey.value[key] = []
|
cloudsByKey.value[key] = []
|
||||||
@@ -121,13 +110,13 @@ export const useProfileStore = defineStore('profile-page', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLoading(identifier: string | null, isOwnProfile: boolean) {
|
function isLoading(userId: string | null, isOwnProfile: boolean) {
|
||||||
return !!identifier && !!loadingKeys.value[makeKey(identifier, isOwnProfile)]
|
return !!userId && !!loadingKeys.value[makeKey(userId, isOwnProfile)]
|
||||||
}
|
}
|
||||||
|
|
||||||
function patchCachedCloud(identifier: string, cloudId: string, patch: Partial<ProfileCloudItem>) {
|
function patchCachedCloud(userId: string, cloudId: string, patch: Partial<ProfileCloudItem>) {
|
||||||
for (const isOwnProfile of [true, false]) {
|
for (const isOwnProfile of [true, false]) {
|
||||||
const key = makeKey(identifier, isOwnProfile)
|
const key = makeKey(userId, isOwnProfile)
|
||||||
const current = cloudsByKey.value[key]
|
const current = cloudsByKey.value[key]
|
||||||
if (!current) continue
|
if (!current) continue
|
||||||
cloudsByKey.value[key] = current
|
cloudsByKey.value[key] = current
|
||||||
@@ -136,62 +125,61 @@ export const useProfileStore = defineStore('profile-page', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeCachedClouds(identifier: string, cloudIds: string[]) {
|
function removeCachedClouds(userId: string, cloudIds: string[]) {
|
||||||
const ids = new Set(cloudIds)
|
const idSet = new Set(cloudIds)
|
||||||
for (const isOwnProfile of [true, false]) {
|
for (const isOwnProfile of [true, false]) {
|
||||||
const key = makeKey(identifier, isOwnProfile)
|
const key = makeKey(userId, isOwnProfile)
|
||||||
const current = cloudsByKey.value[key]
|
const current = cloudsByKey.value[key]
|
||||||
if (current) cloudsByKey.value[key] = current.filter(item => !ids.has(item.id))
|
if (current) cloudsByKey.value[key] = current.filter(item => !idSet.has(item.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function invalidateUser(identifier: string) {
|
function invalidateUser(userId: string) {
|
||||||
delete cloudsByKey.value[makeKey(identifier, true)]
|
delete cloudsByKey.value[makeKey(userId, true)]
|
||||||
delete cloudsByKey.value[makeKey(identifier, false)]
|
delete cloudsByKey.value[makeKey(userId, false)]
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateCloud(identifier: string, cloudId: string, patch: {
|
async function updateCloud(userId: string, cloudId: string, patch: {
|
||||||
type_id: number
|
cloud_type_id: number | null
|
||||||
latitude: number
|
custom_cloud_type: string | null
|
||||||
longitude: number
|
latitude: number | null
|
||||||
description?: string
|
longitude: number | null
|
||||||
captured_at?: string
|
location_name: string | null
|
||||||
|
description: string | null
|
||||||
|
captured_at: string | null
|
||||||
is_hidden: boolean
|
is_hidden: boolean
|
||||||
}) {
|
}) {
|
||||||
await apiRequest<ActionResponse>(`/cloud/${cloudId}`, {
|
const updated = toProfileCloud(await apiRequest<ApiCloud>(`/clouds/${cloudId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: patch,
|
body: patch,
|
||||||
})
|
}))
|
||||||
const updated = toProfileCloud(toApiCloud(await apiRequest<BackendCloudInfo>(`/cloud/${cloudId}`)))
|
patchCachedCloud(userId, cloudId, updated)
|
||||||
patchCachedCloud(identifier, cloudId, updated)
|
|
||||||
return updated
|
return updated
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateCloudVisibility(identifier: string, cloudId: string, isHidden: boolean) {
|
async function updateCloudVisibility(userId: string, cloudId: string, isHidden: boolean) {
|
||||||
await apiRequest<ActionResponse>(`/cloud/${cloudId}`, {
|
await apiRequest<ApiCloud>(`/clouds/${cloudId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: { is_hidden: isHidden },
|
body: { is_hidden: isHidden },
|
||||||
})
|
})
|
||||||
patchCachedCloud(identifier, cloudId, { is_hidden: isHidden })
|
patchCachedCloud(userId, cloudId, { is_hidden: isHidden })
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteClouds(identifier: string, cloudIds: string[]) {
|
async function deleteClouds(userId: string, cloudIds: string[]) {
|
||||||
if (!cloudIds.length) return 0
|
if (!cloudIds.length) return 0
|
||||||
|
|
||||||
if (cloudIds.length === 1) {
|
if (cloudIds.length === 1) {
|
||||||
await apiRequest<ActionResponse>(`/cloud/${cloudIds[0]}`, { method: 'DELETE' })
|
await apiRequest(`/clouds/${cloudIds[0]}`, { method: 'DELETE' })
|
||||||
} else {
|
} else {
|
||||||
for (let offset = 0; offset < cloudIds.length; offset += 100) {
|
for (let offset = 0; offset < cloudIds.length; offset += 100) {
|
||||||
await apiRequest<ActionResponse>('/cloud', {
|
await apiRequest('/clouds/batch-delete', {
|
||||||
method: 'DELETE',
|
method: 'POST',
|
||||||
body: {
|
body: { ids: cloudIds.slice(offset, offset + 100) },
|
||||||
cloud_ids: cloudIds.slice(offset, offset + 100).map(cloudId => ({ cloud_id: cloudId })),
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
removeCachedClouds(identifier, cloudIds)
|
removeCachedClouds(userId, cloudIds)
|
||||||
return cloudIds.length
|
return cloudIds.length
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+37
-355
@@ -40,21 +40,6 @@
|
|||||||
@apply py-10;
|
@apply py-10;
|
||||||
}
|
}
|
||||||
|
|
||||||
.oc-page-hero__split {
|
|
||||||
@apply grid gap-6;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 900px) {
|
|
||||||
.oc-page-hero__split {
|
|
||||||
grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.9fr);
|
|
||||||
align-items: start;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-page-hero__split--stretch {
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-page-content {
|
.oc-page-content {
|
||||||
@apply py-8;
|
@apply py-8;
|
||||||
}
|
}
|
||||||
@@ -71,11 +56,6 @@
|
|||||||
@apply mt-4 max-w-2xl text-sm leading-7 text-slate-600;
|
@apply mt-4 max-w-2xl text-sm leading-7 text-slate-600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.oc-brand-mark {
|
|
||||||
@apply flex h-10 w-10 items-center justify-center border border-sky-200 bg-sky-100;
|
|
||||||
box-shadow: var(--oc-shadow-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Reusable text hierarchy */
|
/* Reusable text hierarchy */
|
||||||
.oc-section-label {
|
.oc-section-label {
|
||||||
@apply text-xs uppercase tracking-[0.18em] text-slate-500;
|
@apply text-xs uppercase tracking-[0.18em] text-slate-500;
|
||||||
@@ -119,14 +99,6 @@
|
|||||||
box-shadow: var(--oc-shadow-md) !important;
|
box-shadow: var(--oc-shadow-md) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.oc-panel-card-xl {
|
|
||||||
box-shadow: var(--oc-shadow-xl) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-floating-panel {
|
|
||||||
box-shadow: var(--oc-shadow-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-panel-card-soft {
|
.oc-panel-card-soft {
|
||||||
box-shadow: 6px 6px 0 0 rgb(15 23 42 / 4%) !important;
|
box-shadow: 6px 6px 0 0 rgb(15 23 42 / 4%) !important;
|
||||||
}
|
}
|
||||||
@@ -140,293 +112,13 @@
|
|||||||
box-shadow: var(--oc-shadow-md);
|
box-shadow: var(--oc-shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.oc-modal-shell {
|
|
||||||
@apply border border-slate-200 bg-white;
|
|
||||||
box-shadow: var(--oc-shadow-xl);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-inset-panel {
|
.oc-inset-panel {
|
||||||
@apply border border-slate-200 bg-slate-50 p-4;
|
@apply border border-slate-200 bg-slate-50 p-4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.oc-map-button {
|
.oc-map-button {
|
||||||
@apply flex h-10 w-10 items-center justify-center border border-slate-200 bg-white text-slate-600;
|
@apply flex h-10 w-10 items-center justify-center bg-white shadow-md transition-colors hover:bg-slate-50;
|
||||||
box-shadow: var(--oc-shadow-xs);
|
border-radius: 0.5rem !important;
|
||||||
}
|
|
||||||
|
|
||||||
/* Shared interactive contracts */
|
|
||||||
.oc-panel-button:not(.n-button),
|
|
||||||
.oc-header-button,
|
|
||||||
.oc-icon-button,
|
|
||||||
.oc-map-button,
|
|
||||||
.oc-choice-button,
|
|
||||||
.oc-text-button,
|
|
||||||
.oc-menu-item,
|
|
||||||
.oc-nav-link,
|
|
||||||
.oc-interactive-card {
|
|
||||||
transition:
|
|
||||||
background-color var(--oc-duration-fast) var(--oc-ease-standard),
|
|
||||||
color var(--oc-duration-fast) var(--oc-ease-standard),
|
|
||||||
border-color var(--oc-duration-fast) var(--oc-ease-standard),
|
|
||||||
box-shadow var(--oc-duration-fast) var(--oc-ease-standard),
|
|
||||||
transform var(--oc-duration-fast) var(--oc-ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-panel-button:not(.n-button) {
|
|
||||||
@apply inline-flex min-h-9 items-center justify-center border px-3 py-2 text-sm font-medium;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-panel-button:not(.n-button):not(:disabled):hover,
|
|
||||||
.oc-header-button:not(:disabled):hover,
|
|
||||||
.oc-icon-button:not(:disabled):hover,
|
|
||||||
.oc-map-button:not(:disabled):hover,
|
|
||||||
.oc-choice-button:not(:disabled):hover {
|
|
||||||
transform: translate(-1px, -1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-panel-button--neutral:not(.n-button) {
|
|
||||||
@apply border-slate-200 bg-white text-slate-600;
|
|
||||||
box-shadow: var(--oc-shadow-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-panel-button--neutral:not(.n-button):hover,
|
|
||||||
.oc-panel-button--neutral:not(.n-button):focus-visible {
|
|
||||||
@apply border-teal-200 bg-teal-50 text-teal-800;
|
|
||||||
box-shadow: var(--oc-shadow-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-panel-button--sky:not(.n-button) {
|
|
||||||
@apply border-sky-200 bg-sky-100 text-sky-800;
|
|
||||||
box-shadow: var(--oc-shadow-sky);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-panel-button--sky:not(.n-button):hover,
|
|
||||||
.oc-panel-button--sky:not(.n-button):focus-visible {
|
|
||||||
@apply border-sky-300 bg-sky-50 text-sky-900;
|
|
||||||
box-shadow: 5px 5px 0 0 rgb(14 165 233 / 18%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-panel-button--teal:not(.n-button) {
|
|
||||||
@apply border-teal-200 bg-teal-50 text-teal-800;
|
|
||||||
box-shadow: var(--oc-shadow-teal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-panel-button--teal:not(.n-button):hover,
|
|
||||||
.oc-panel-button--teal:not(.n-button):focus-visible {
|
|
||||||
@apply border-teal-300 bg-teal-100 text-teal-900;
|
|
||||||
box-shadow: var(--oc-shadow-primary-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-panel-button--danger:not(.n-button) {
|
|
||||||
@apply border-rose-200 bg-rose-50 text-rose-700;
|
|
||||||
box-shadow: 4px 4px 0 0 rgb(244 63 94 / 10%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-panel-button--danger:not(.n-button):hover,
|
|
||||||
.oc-panel-button--danger:not(.n-button):focus-visible {
|
|
||||||
@apply border-rose-300 bg-rose-100 text-rose-800;
|
|
||||||
box-shadow: 5px 5px 0 0 rgb(244 63 94 / 14%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-panel-button--amber:not(.n-button) {
|
|
||||||
@apply border-amber-200 bg-amber-50 text-amber-800;
|
|
||||||
box-shadow: 4px 4px 0 0 rgb(245 158 11 / 10%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-panel-button--amber:not(.n-button):hover,
|
|
||||||
.oc-panel-button--amber:not(.n-button):focus-visible {
|
|
||||||
@apply border-amber-300 bg-amber-100 text-amber-900;
|
|
||||||
box-shadow: 5px 5px 0 0 rgb(245 158 11 / 14%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-icon-button {
|
|
||||||
@apply flex h-10 w-10 shrink-0 items-center justify-center border border-slate-200 bg-white text-slate-600;
|
|
||||||
box-shadow: var(--oc-shadow-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-icon-button:hover,
|
|
||||||
.oc-icon-button:focus-visible,
|
|
||||||
.oc-map-button:hover,
|
|
||||||
.oc-map-button:focus-visible {
|
|
||||||
@apply border-teal-200 bg-teal-50 text-teal-800;
|
|
||||||
box-shadow: var(--oc-shadow-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-icon-button--small {
|
|
||||||
@apply h-8 w-8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-icon-button--tiny {
|
|
||||||
@apply h-6 w-6 text-xs;
|
|
||||||
box-shadow: 2px 2px 0 0 rgb(15 23 42 / 8%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-icon-button--sky {
|
|
||||||
@apply border-sky-200 bg-sky-50 text-sky-700;
|
|
||||||
box-shadow: 3px 3px 0 0 rgb(14 165 233 / 12%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-icon-button--sky:hover,
|
|
||||||
.oc-icon-button--sky:focus-visible {
|
|
||||||
@apply border-sky-300 bg-sky-100 text-sky-900;
|
|
||||||
box-shadow: 4px 4px 0 0 rgb(14 165 233 / 16%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-icon-button--danger {
|
|
||||||
@apply border-rose-300 bg-rose-500 text-white;
|
|
||||||
box-shadow: 3px 3px 0 0 rgb(159 18 57 / 18%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-icon-button--danger:hover,
|
|
||||||
.oc-icon-button--danger:focus-visible {
|
|
||||||
@apply border-rose-400 bg-rose-600 text-white;
|
|
||||||
box-shadow: 4px 4px 0 0 rgb(159 18 57 / 22%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-overlay-button {
|
|
||||||
@apply flex shrink-0 items-center justify-center text-white/75 hover:bg-white/10 hover:text-white;
|
|
||||||
transition:
|
|
||||||
background-color var(--oc-duration-fast) var(--oc-ease-standard),
|
|
||||||
color var(--oc-duration-fast) var(--oc-ease-standard),
|
|
||||||
transform var(--oc-duration-fast) var(--oc-ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-text-button {
|
|
||||||
@apply inline-flex items-center font-medium text-sky-700 underline-offset-4 hover:text-sky-900 hover:underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-text-button--muted {
|
|
||||||
@apply text-slate-500 hover:text-slate-800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-text-button--danger {
|
|
||||||
@apply text-rose-600 hover:text-rose-800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-choice-button {
|
|
||||||
@apply inline-flex min-h-9 items-center justify-center border px-3 py-2 text-sm font-medium;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-choice-button--inactive {
|
|
||||||
@apply border-slate-200 bg-white text-slate-600 hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800;
|
|
||||||
box-shadow: var(--oc-shadow-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-choice-button--active {
|
|
||||||
@apply border-teal-200 bg-teal-50 text-teal-800;
|
|
||||||
box-shadow: var(--oc-shadow-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-menu-item {
|
|
||||||
@apply flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm font-medium text-slate-700 hover:bg-teal-50 hover:text-teal-800;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-menu-item--danger {
|
|
||||||
@apply text-rose-600 hover:bg-rose-50 hover:text-rose-700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-menu-item--amber {
|
|
||||||
@apply hover:bg-amber-50 hover:text-amber-700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-nav-link {
|
|
||||||
@apply px-3 py-2 text-sm font-medium uppercase tracking-[0.12em];
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-header-button {
|
|
||||||
@apply inline-flex h-8 items-center justify-center border px-3 text-sm font-medium md:h-10 md:px-4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-header-button--neutral {
|
|
||||||
@apply border-slate-200 bg-white/80 text-slate-700 hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800;
|
|
||||||
box-shadow: var(--oc-shadow-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-header-button--sky {
|
|
||||||
@apply border-sky-200 bg-sky-100 text-sky-800 hover:border-sky-300 hover:bg-sky-50 hover:text-sky-900;
|
|
||||||
box-shadow: var(--oc-shadow-sky);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-header-button--teal {
|
|
||||||
@apply border-teal-100 bg-white/80 text-teal-700 hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800;
|
|
||||||
box-shadow: var(--oc-shadow-teal);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-header-button:hover {
|
|
||||||
box-shadow: 5px 5px 0 0 rgb(15 23 42 / 8%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-interactive-card:focus-visible {
|
|
||||||
@apply outline-none ring-2 ring-sky-600 ring-offset-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-interactive-card {
|
|
||||||
box-shadow: var(--oc-shadow-md);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-interactive-card:hover {
|
|
||||||
box-shadow: var(--oc-shadow-lg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-interactive-card--selected {
|
|
||||||
@apply border-sky-500;
|
|
||||||
box-shadow: var(--oc-shadow-sky) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-interactive-row {
|
|
||||||
transition:
|
|
||||||
background-color var(--oc-duration-fast) var(--oc-ease-standard),
|
|
||||||
color var(--oc-duration-fast) var(--oc-ease-standard),
|
|
||||||
box-shadow var(--oc-duration-fast) var(--oc-ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-interactive-row:hover {
|
|
||||||
@apply bg-sky-50/60;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-interactive-row:focus-visible,
|
|
||||||
.oc-heatmap-cell:focus-visible {
|
|
||||||
@apply outline-none ring-2 ring-sky-600 ring-offset-2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-heatmap-cell {
|
|
||||||
transition:
|
|
||||||
border-color var(--oc-duration-fast) var(--oc-ease-standard),
|
|
||||||
box-shadow var(--oc-duration-fast) var(--oc-ease-standard),
|
|
||||||
transform var(--oc-duration-fast) var(--oc-ease-standard);
|
|
||||||
}
|
|
||||||
|
|
||||||
:is(
|
|
||||||
.oc-primary-button,
|
|
||||||
.oc-panel-button,
|
|
||||||
.oc-header-button,
|
|
||||||
.oc-icon-button,
|
|
||||||
.oc-overlay-button,
|
|
||||||
.oc-map-button,
|
|
||||||
.oc-choice-button,
|
|
||||||
.oc-text-button,
|
|
||||||
.oc-menu-item,
|
|
||||||
.oc-nav-link
|
|
||||||
):focus-visible {
|
|
||||||
outline: 2px solid rgb(2 132 199);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-overlay-button:focus-visible {
|
|
||||||
outline-color: rgb(125 211 252);
|
|
||||||
}
|
|
||||||
|
|
||||||
:is(.oc-panel-button, .oc-header-button, .oc-icon-button, .oc-map-button, .oc-choice-button):disabled {
|
|
||||||
@apply cursor-not-allowed border-slate-200 bg-slate-100 text-slate-400 shadow-none;
|
|
||||||
transform: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-map-button:disabled {
|
|
||||||
box-shadow: var(--oc-shadow-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
:is(.oc-panel-button, .oc-primary-button, .oc-header-button, .oc-icon-button, .oc-map-button, .oc-choice-button):not(:disabled):active {
|
|
||||||
transform: translate(0, 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.oc-system-page {
|
.oc-system-page {
|
||||||
@@ -449,16 +141,6 @@
|
|||||||
transform var(--oc-duration-fast) var(--oc-ease-standard);
|
transform var(--oc-duration-fast) var(--oc-ease-standard);
|
||||||
}
|
}
|
||||||
|
|
||||||
.oc-primary-button.n-button.n-button--disabled,
|
|
||||||
.oc-panel-button.n-button.n-button--disabled {
|
|
||||||
border-color: rgb(226 232 240) !important;
|
|
||||||
background: rgb(241 245 249) !important;
|
|
||||||
color: var(--oc-color-text-disabled) !important;
|
|
||||||
box-shadow: none !important;
|
|
||||||
opacity: 0.72;
|
|
||||||
transform: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.oc-primary-button.n-button .n-button__content,
|
.oc-primary-button.n-button .n-button__content,
|
||||||
.oc-panel-button.n-button .n-button__content {
|
.oc-panel-button.n-button .n-button__content {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -475,21 +157,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.oc-primary-button--teal.n-button {
|
.oc-primary-button--teal.n-button {
|
||||||
border-color: var(--color-teal-200) !important;
|
border-color: rgb(153 246 228) !important;
|
||||||
background: white !important;
|
background: rgb(255 255 255) !important;
|
||||||
color: var(--color-teal-700) !important;
|
color: rgb(17 94 89) !important;
|
||||||
box-shadow: var(--oc-shadow-teal) !important;
|
box-shadow: var(--oc-shadow-teal) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.oc-primary-button--teal.n-button:hover,
|
.oc-primary-button--teal.n-button:hover,
|
||||||
.oc-primary-button--teal.n-button:focus-visible {
|
.oc-primary-button--teal.n-button:focus-visible {
|
||||||
border-color: var(--color-teal-300) !important;
|
border-color: rgb(94 234 212) !important;
|
||||||
background: var(--color-teal-50) !important;
|
background: rgb(234 250 245) !important;
|
||||||
color: var(--color-teal-800) !important;
|
color: rgb(19 78 74) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.oc-primary-button--teal.n-button:not(.n-button--disabled):hover {
|
.oc-primary-button--teal.n-button:not(.n-button--disabled):hover {
|
||||||
box-shadow: var(--oc-shadow-primary-hover) !important;
|
box-shadow: 5px 5px 0 0 rgb(20 184 166 / 14%) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.oc-primary-button--sky.n-button {
|
.oc-primary-button--sky.n-button {
|
||||||
@@ -511,27 +193,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.oc-panel-button.n-button {
|
.oc-panel-button.n-button {
|
||||||
--n-color: white !important;
|
--n-color: rgb(255 255 255) !important;
|
||||||
--n-color-hover: var(--color-teal-50) !important;
|
--n-color-hover: rgb(240 253 250) !important;
|
||||||
--n-color-pressed: var(--color-teal-100) !important;
|
--n-color-pressed: rgb(236 253 245) !important;
|
||||||
--n-border: 1px solid rgb(226 232 240) !important;
|
--n-border: 1px solid rgb(226 232 240) !important;
|
||||||
--n-border-hover: 1px solid var(--color-teal-200) !important;
|
--n-border-hover: 1px solid rgb(153 246 228) !important;
|
||||||
--n-border-pressed: 1px solid var(--color-teal-300) !important;
|
--n-border-pressed: 1px solid rgb(94 234 212) !important;
|
||||||
--n-text-color: rgb(71 85 105) !important;
|
--n-text-color: rgb(71 85 105) !important;
|
||||||
--n-text-color-hover: var(--color-teal-800) !important;
|
--n-text-color-hover: rgb(19 78 74) !important;
|
||||||
--n-text-color-pressed: var(--color-teal-700) !important;
|
--n-text-color-pressed: rgb(17 94 89) !important;
|
||||||
border-color: rgb(226 232 240) !important;
|
border-color: rgb(226 232 240) !important;
|
||||||
background: white !important;
|
background: rgb(255 255 255) !important;
|
||||||
color: rgb(71 85 105) !important;
|
color: rgb(71 85 105) !important;
|
||||||
box-shadow: var(--oc-shadow-xs) !important;
|
box-shadow: var(--oc-shadow-xs) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.oc-panel-button--neutral.n-button:hover,
|
.oc-panel-button--neutral.n-button:hover,
|
||||||
.oc-panel-button--neutral.n-button:focus-visible {
|
.oc-panel-button--neutral.n-button:focus-visible {
|
||||||
border-color: var(--color-teal-200) !important;
|
border-color: rgb(153 246 228) !important;
|
||||||
background: var(--color-teal-50) !important;
|
background: rgb(240 253 250) !important;
|
||||||
color: var(--color-teal-800) !important;
|
color: rgb(19 78 74) !important;
|
||||||
box-shadow: var(--oc-shadow-primary) !important;
|
box-shadow: 4px 4px 0 0 rgb(20 184 166 / 10%) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.oc-panel-button--sky.n-button {
|
.oc-panel-button--sky.n-button {
|
||||||
@@ -559,27 +241,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.oc-panel-button--teal.n-button {
|
.oc-panel-button--teal.n-button {
|
||||||
--n-color: var(--color-teal-50) !important;
|
--n-color: rgb(242 250 247) !important;
|
||||||
--n-color-hover: var(--color-teal-100) !important;
|
--n-color-hover: rgb(234 250 245) !important;
|
||||||
--n-color-pressed: var(--color-teal-200) !important;
|
--n-color-pressed: rgb(220 252 231) !important;
|
||||||
--n-border: 1px solid var(--color-teal-200) !important;
|
--n-border: 1px solid rgb(153 246 228) !important;
|
||||||
--n-border-hover: 1px solid var(--color-teal-300) !important;
|
--n-border-hover: 1px solid rgb(94 234 212) !important;
|
||||||
--n-border-pressed: 1px solid var(--color-teal-400) !important;
|
--n-border-pressed: 1px solid rgb(45 212 191) !important;
|
||||||
--n-text-color: var(--color-teal-700) !important;
|
--n-text-color: rgb(17 94 89) !important;
|
||||||
--n-text-color-hover: var(--color-teal-800) !important;
|
--n-text-color-hover: rgb(19 78 74) !important;
|
||||||
--n-text-color-pressed: var(--color-teal-800) !important;
|
--n-text-color-pressed: rgb(19 78 74) !important;
|
||||||
border-color: var(--color-teal-200) !important;
|
border-color: rgb(153 246 228) !important;
|
||||||
background: var(--color-teal-50) !important;
|
background: rgb(242 250 247) !important;
|
||||||
color: var(--color-teal-700) !important;
|
color: rgb(17 94 89) !important;
|
||||||
box-shadow: var(--oc-shadow-teal) !important;
|
box-shadow: var(--oc-shadow-teal) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.oc-panel-button--teal.n-button:hover,
|
.oc-panel-button--teal.n-button:hover,
|
||||||
.oc-panel-button--teal.n-button:focus-visible {
|
.oc-panel-button--teal.n-button:focus-visible {
|
||||||
border-color: var(--color-teal-300) !important;
|
border-color: rgb(94 234 212) !important;
|
||||||
background: var(--color-teal-100) !important;
|
background: rgb(234 250 245) !important;
|
||||||
color: var(--color-teal-800) !important;
|
color: rgb(19 78 74) !important;
|
||||||
box-shadow: var(--oc-shadow-primary-hover) !important;
|
box-shadow: 5px 5px 0 0 rgb(20 184 166 / 14%) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.oc-panel-button--danger.n-button {
|
.oc-panel-button--danger.n-button {
|
||||||
|
|||||||
+8
-51
@@ -7,33 +7,6 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
|
|
||||||
/*
|
|
||||||
* Tailwind compatibility palettes.
|
|
||||||
* Existing teal/emerald utilities intentionally resolve to OpenCloud's
|
|
||||||
* fresh eucalyptus and leaf colors instead of Tailwind's vivid defaults.
|
|
||||||
*/
|
|
||||||
--color-teal-50: #f2faf6;
|
|
||||||
--color-teal-100: #ddf2e6;
|
|
||||||
--color-teal-200: #bce4cf;
|
|
||||||
--color-teal-300: #8fceb0;
|
|
||||||
--color-teal-400: #60b18d;
|
|
||||||
--color-teal-500: #438f71;
|
|
||||||
--color-teal-600: #34745d;
|
|
||||||
--color-teal-700: #2d5f4e;
|
|
||||||
--color-teal-800: #274c40;
|
|
||||||
--color-teal-900: #213e35;
|
|
||||||
|
|
||||||
--color-emerald-50: #f4faef;
|
|
||||||
--color-emerald-100: #e5f3d9;
|
|
||||||
--color-emerald-200: #cce7b7;
|
|
||||||
--color-emerald-300: #a8d58d;
|
|
||||||
--color-emerald-400: #7fbc63;
|
|
||||||
--color-emerald-500: #609d48;
|
|
||||||
--color-emerald-600: #4d7f3b;
|
|
||||||
--color-emerald-700: #3f6633;
|
|
||||||
--color-emerald-800: #35522c;
|
|
||||||
--color-emerald-900: #2d4427;
|
|
||||||
|
|
||||||
--oc-font-sans: 'IBM Plex Sans', 'Noto Sans SC', 'PingFang SC', sans-serif;
|
--oc-font-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;
|
||||||
|
|
||||||
@@ -43,34 +16,21 @@
|
|||||||
--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: #0f766e;
|
||||||
--oc-color-primary-hover: #2d5f4e;
|
--oc-color-primary-hover: #115e59;
|
||||||
--oc-color-primary-pressed: #274c40;
|
--oc-color-primary-pressed: #134e4a;
|
||||||
--oc-color-info: #0369a1;
|
--oc-color-info: #0369a1;
|
||||||
--oc-color-success: #4d7f3b;
|
--oc-color-success: #0f766e;
|
||||||
--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(15 118 110 / 18%);
|
||||||
|
|
||||||
/* Contribution heatmap: no activity + GitHub-style contribution quartiles. */
|
|
||||||
--oc-heatmap-empty: #f1f5f9;
|
|
||||||
--oc-heatmap-empty-border: #d9e3ee;
|
|
||||||
--oc-heatmap-level-1: #ddf2e6;
|
|
||||||
--oc-heatmap-level-1-border: #bce4cf;
|
|
||||||
--oc-heatmap-level-2: #8fceb0;
|
|
||||||
--oc-heatmap-level-2-border: #60b18d;
|
|
||||||
--oc-heatmap-level-3: #438f71;
|
|
||||||
--oc-heatmap-level-3-border: #34745d;
|
|
||||||
--oc-heatmap-level-4: #274c40;
|
|
||||||
--oc-heatmap-level-4-border: #213e35;
|
|
||||||
|
|
||||||
--oc-gradient-canvas:
|
--oc-gradient-canvas:
|
||||||
radial-gradient(circle at top left, rgb(52 116 93 / 11%), transparent 28%),
|
radial-gradient(circle at top left, rgb(12 148 136 / 12%), transparent 28%),
|
||||||
linear-gradient(180deg, #f8fbfd 0%, #eef4f8 100%);
|
linear-gradient(180deg, #f8fbfd 0%, #eef4f8 100%);
|
||||||
--oc-gradient-page: linear-gradient(180deg, #e0f2fe 0%, #f8fafc 100%);
|
--oc-gradient-page: linear-gradient(180deg, #e0f2fe 0%, #f8fafc 100%);
|
||||||
--oc-gradient-page-soft: linear-gradient(180deg, #f2faf6 0%, #f8fafc 100%);
|
--oc-gradient-page-soft: linear-gradient(180deg, #f0fdfa 0%, #f8fafc 100%);
|
||||||
--oc-gradient-grid:
|
--oc-gradient-grid:
|
||||||
linear-gradient(rgb(15 23 42 / 3%) 1px, transparent 1px),
|
linear-gradient(rgb(15 23 42 / 3%) 1px, transparent 1px),
|
||||||
linear-gradient(90deg, rgb(15 23 42 / 3%) 1px, transparent 1px);
|
linear-gradient(90deg, rgb(15 23 42 / 3%) 1px, transparent 1px);
|
||||||
@@ -81,10 +41,7 @@
|
|||||||
--oc-shadow-lg: 8px 8px 0 0 rgb(15 23 42 / 8%);
|
--oc-shadow-lg: 8px 8px 0 0 rgb(15 23 42 / 8%);
|
||||||
--oc-shadow-xl: 12px 12px 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-sky: 4px 4px 0 0 rgb(14 165 233 / 14%);
|
||||||
--oc-shadow-primary: 4px 4px 0 0 rgb(52 116 93 / 14%);
|
--oc-shadow-teal: 4px 4px 0 0 rgb(20 184 166 / 10%);
|
||||||
--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-duration-fast: 160ms;
|
||||||
--oc-ease-standard: ease;
|
--oc-ease-standard: ease;
|
||||||
|
|||||||
Vendored
+2
-2
@@ -6,7 +6,7 @@ declare namespace AMap {
|
|||||||
remove(layer: unknown): void
|
remove(layer: unknown): void
|
||||||
destroy(): void
|
destroy(): void
|
||||||
setCenter(center: [number, number]): void
|
setCenter(center: [number, number]): void
|
||||||
setZoom(zoom: number, immediately?: boolean, duration?: number): void
|
setZoom(zoom: number): void
|
||||||
setPitch(pitch: number): void
|
setPitch(pitch: number): void
|
||||||
getPitch(): number
|
getPitch(): number
|
||||||
setRotation(rotation: number): void
|
setRotation(rotation: number): void
|
||||||
@@ -14,7 +14,7 @@ declare namespace AMap {
|
|||||||
on(event: string, callback: (...args: unknown[]) => void): void
|
on(event: string, callback: (...args: unknown[]) => void): void
|
||||||
off(event: string, callback: (...args: unknown[]) => void): void
|
off(event: string, callback: (...args: unknown[]) => void): void
|
||||||
getCenter(): { lng: number; lat: number }
|
getCenter(): { lng: number; lat: number }
|
||||||
getZoom(digits?: number): number
|
getZoom(): number
|
||||||
getAllOverlays(type?: string): unknown[]
|
getAllOverlays(type?: string): unknown[]
|
||||||
addOverlay(overlay: unknown): void
|
addOverlay(overlay: unknown): void
|
||||||
removeOverlay(overlay: unknown): void
|
removeOverlay(overlay: unknown): void
|
||||||
|
|||||||
+67
-72
@@ -1,84 +1,45 @@
|
|||||||
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
|
|
||||||
avatar_id: 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 RegisterResponse extends ActionResponse {
|
|
||||||
email_sent: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LoginResponse {
|
|
||||||
access_token: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CloudCreateResponse extends ActionResponse {
|
|
||||||
cloud_id: string
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
||||||
avatar_id: string | null
|
|
||||||
role: Profile['role']
|
|
||||||
is_disabled: boolean
|
|
||||||
cloud_count: number
|
|
||||||
received_like_count: number
|
|
||||||
created_at: string
|
created_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
access_token: string
|
||||||
|
token_type: 'bearer'
|
||||||
|
expires_in: number
|
||||||
|
user: AuthUser
|
||||||
|
profile: Profile
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeResponse {
|
||||||
|
user: AuthUser
|
||||||
|
profile: Profile
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicProfile {
|
||||||
|
id: string
|
||||||
|
username: string
|
||||||
|
avatar_url: string | null
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiPage<T> {
|
||||||
|
items: T[]
|
||||||
|
page: number
|
||||||
|
page_size: number
|
||||||
|
total: number
|
||||||
|
total_pages: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiCloudTypeSummary {
|
export interface ApiCloudTypeSummary {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
name_en: string
|
||||||
rarity: CloudType['rarity']
|
rarity: CloudType['rarity']
|
||||||
rarity_value: number
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiCloudOwner {
|
export interface ApiCloudOwner {
|
||||||
@@ -92,7 +53,41 @@ export interface ApiCloud extends Cloud {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
+21
-3
@@ -1,11 +1,11 @@
|
|||||||
export interface CloudType {
|
export interface CloudType {
|
||||||
id: number
|
id: number
|
||||||
name: string
|
name: string
|
||||||
|
name_en: string
|
||||||
genus: string
|
genus: string
|
||||||
icon_id: string | null
|
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,10 +13,12 @@ export interface Cloud {
|
|||||||
id: string
|
id: string
|
||||||
user_id: string
|
user_id: string
|
||||||
cloud_type_id: number | null
|
cloud_type_id: number | null
|
||||||
|
custom_cloud_type: string | null
|
||||||
image_url: string
|
image_url: string
|
||||||
thumbnail_url: string | null
|
thumbnail_url: string | null
|
||||||
latitude: number | null
|
latitude: number | null
|
||||||
longitude: number | null
|
longitude: number | null
|
||||||
|
location_name: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
captured_at: string | null
|
captured_at: string | null
|
||||||
status: 'pending' | 'approved' | 'rejected'
|
status: 'pending' | 'approved' | 'rejected'
|
||||||
@@ -27,8 +29,24 @@ export interface Cloud {
|
|||||||
export interface Profile {
|
export interface Profile {
|
||||||
id: string
|
id: string
|
||||||
username: string
|
username: string
|
||||||
avatar_id: 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
|
||||||
|
}
|
||||||
|
|||||||
+207
-292
@@ -1,13 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch, type Component } from 'vue'
|
import { computed, onMounted, ref, watch, type Component } from 'vue'
|
||||||
import { NAlert, NButton, NEmpty, NIcon, NInput, NPopover, NSkeleton, NTag, useMessage } from 'naive-ui'
|
import { NAlert, NButton, NEmpty, NIcon, NPopover, NSkeleton, NTag, useMessage } from 'naive-ui'
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Dashboard,
|
Dashboard,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
Home,
|
Home,
|
||||||
Key,
|
|
||||||
LayoutSidebarLeftCollapse,
|
LayoutSidebarLeftCollapse,
|
||||||
LayoutSidebarLeftExpand,
|
LayoutSidebarLeftExpand,
|
||||||
Logout,
|
Logout,
|
||||||
@@ -23,9 +24,10 @@ import {
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||||
import { apiRequest, toApiCloud, toAuthUser } from '@/lib/api'
|
import { apiRequest } from '@/lib/api'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import type { ActionResponse, ApiCloud, AuthUser, BackendCloudInfo, BackendUserProfile } from '@/types/api'
|
import type { AdminStats, AdminUserItem, ApiCloud, ApiPage } from '@/types/api'
|
||||||
|
import type { CloudType, Profile } from '@/types/database'
|
||||||
|
|
||||||
type AdminTab = 'dashboard' | 'review' | 'users' | 'images'
|
type AdminTab = 'dashboard' | 'review' | 'users' | 'images'
|
||||||
type CloudStatus = 'pending' | 'approved' | 'rejected'
|
type CloudStatus = 'pending' | 'approved' | 'rejected'
|
||||||
@@ -35,17 +37,19 @@ interface AdminCloud {
|
|||||||
id: string
|
id: string
|
||||||
user_id: string
|
user_id: string
|
||||||
cloud_type_id: number | null
|
cloud_type_id: number | null
|
||||||
|
custom_cloud_type: string | null
|
||||||
image_url: string
|
image_url: string
|
||||||
thumbnail_url: string | null
|
thumbnail_url: string | null
|
||||||
latitude: number | null
|
latitude: number | null
|
||||||
longitude: number | null
|
longitude: number | null
|
||||||
|
location_name: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
captured_at: string | null
|
captured_at: string | null
|
||||||
created_at: string
|
created_at: string
|
||||||
status: CloudStatus
|
status: CloudStatus
|
||||||
is_hidden: boolean
|
is_hidden: boolean
|
||||||
cloudTypeName: string
|
cloudTypeName: string
|
||||||
cloudTypeRarity: number
|
cloudTypeRarity: CloudType['rarity']
|
||||||
username: string
|
username: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,17 +82,19 @@ const dashboardStats = ref<DashboardStats>({
|
|||||||
rejected: 0,
|
rejected: 0,
|
||||||
hidden: 0,
|
hidden: 0,
|
||||||
})
|
})
|
||||||
const users = ref<AuthUser[]>([])
|
const users = ref<Profile[]>([])
|
||||||
const images = ref<AdminCloud[]>([])
|
const images = ref<AdminCloud[]>([])
|
||||||
const selectedReviewIds = ref<Set<string>>(new Set())
|
const selectedReviewIds = ref<Set<string>>(new Set())
|
||||||
const activeReviewId = ref<string | null>(null)
|
const activeReviewId = ref<string | null>(null)
|
||||||
const selectedImageIds = ref<Set<string>>(new Set())
|
const selectedImageIds = ref<Set<string>>(new Set())
|
||||||
const imageFilter = ref<ImageFilter>('all')
|
const imageFilter = ref<ImageFilter>('all')
|
||||||
const selectedImage = ref<AdminCloud | null>(null)
|
const selectedImage = ref<AdminCloud | null>(null)
|
||||||
const passwordTarget = ref<AuthUser | null>(null)
|
|
||||||
const newPassword = ref('')
|
const rarityMeta = {
|
||||||
const confirmPassword = ref('')
|
common: { label: '常见', chip: 'border-sky-200 bg-sky-100 text-sky-700' },
|
||||||
const passwordError = ref('')
|
uncommon: { label: '少见', chip: 'border-amber-200 bg-amber-100 text-amber-700' },
|
||||||
|
rare: { label: '罕见', chip: 'border-rose-200 bg-rose-100 text-rose-700' },
|
||||||
|
} satisfies Record<CloudType['rarity'], { label: string; chip: string }>
|
||||||
|
|
||||||
const statusMeta = {
|
const statusMeta = {
|
||||||
pending: { label: '待审核', chip: 'border-amber-200 bg-amber-100 text-amber-700' },
|
pending: { label: '待审核', chip: 'border-amber-200 bg-amber-100 text-amber-700' },
|
||||||
@@ -99,8 +105,8 @@ const statusMeta = {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'dashboard', label: '数据看板', description: '运行概览与内容分布', icon: Dashboard },
|
{ key: 'dashboard', label: '数据看板', description: '运行概览与内容分布', icon: Dashboard },
|
||||||
{ key: 'review', label: '内容审核', description: '处理待审核上传', icon: ClipboardCheck },
|
{ key: 'review', label: '内容审核', description: '处理待审核上传', icon: ClipboardCheck },
|
||||||
{ key: 'users', label: '用户管理', description: '角色与登录密码', icon: Users },
|
{ key: 'users', label: '用户管理', description: '角色与账号状态', icon: Users },
|
||||||
{ key: 'images', label: '图片管理', description: '内容审核与删除', icon: Photo },
|
{ key: 'images', label: '图片管理', description: '内容状态与可见性', icon: Photo },
|
||||||
] satisfies Array<{ key: AdminTab; label: string; description: string; icon: Component }>
|
] satisfies Array<{ key: AdminTab; label: string; description: string; icon: Component }>
|
||||||
|
|
||||||
const pendingImages = computed(() => images.value.filter(item => item.status === 'pending'))
|
const pendingImages = computed(() => images.value.filter(item => item.status === 'pending'))
|
||||||
@@ -138,9 +144,7 @@ const cloudTypeStats = computed(() => {
|
|||||||
|
|
||||||
const reviewedCount = computed(() => dashboardStats.value.approved + dashboardStats.value.rejected)
|
const reviewedCount = computed(() => dashboardStats.value.approved + dashboardStats.value.rejected)
|
||||||
const approvalRate = computed(() => getPercentage(dashboardStats.value.approved, reviewedCount.value))
|
const approvalRate = computed(() => getPercentage(dashboardStats.value.approved, reviewedCount.value))
|
||||||
const publicImageCount = computed(() =>
|
const publicImageCount = computed(() => Math.max(0, dashboardStats.value.images - dashboardStats.value.hidden))
|
||||||
images.value.filter(item => item.status === 'approved' && !item.is_hidden).length,
|
|
||||||
)
|
|
||||||
const recentImages = computed(() => images.value.slice(0, 5))
|
const recentImages = computed(() => images.value.slice(0, 5))
|
||||||
const cloudTypeMaxCount = computed(() => Math.max(1, ...cloudTypeStats.value.map(item => item.count)))
|
const cloudTypeMaxCount = computed(() => Math.max(1, ...cloudTypeStats.value.map(item => item.count)))
|
||||||
const reviewStatusStats = computed(() => [
|
const reviewStatusStats = computed(() => [
|
||||||
@@ -174,17 +178,19 @@ function toAdminCloud(row: ApiCloud): AdminCloud {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
user_id: row.user_id,
|
user_id: row.user_id,
|
||||||
cloud_type_id: row.cloud_type_id,
|
cloud_type_id: row.cloud_type_id,
|
||||||
|
custom_cloud_type: row.custom_cloud_type,
|
||||||
image_url: row.image_url,
|
image_url: row.image_url,
|
||||||
thumbnail_url: row.thumbnail_url,
|
thumbnail_url: row.thumbnail_url,
|
||||||
latitude: row.latitude,
|
latitude: row.latitude,
|
||||||
longitude: row.longitude,
|
longitude: row.longitude,
|
||||||
|
location_name: row.location_name,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
captured_at: row.captured_at,
|
captured_at: row.captured_at,
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
is_hidden: row.is_hidden,
|
is_hidden: row.is_hidden,
|
||||||
cloudTypeName: row.cloud_type_name || '未知云型',
|
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知云型',
|
||||||
cloudTypeRarity: row.cloud_type_rarity_value ?? 0,
|
cloudTypeRarity: row.cloud_type_rarity || 'common',
|
||||||
username: row.username || '匿名用户',
|
username: row.username || '匿名用户',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,42 +219,27 @@ function getErrorMessage(error: unknown, fallback: string) {
|
|||||||
return error instanceof Error ? error.message : fallback
|
return error instanceof Error ? error.message : fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncDashboardStats() {
|
async function fetchStats() {
|
||||||
const now = new Date()
|
const stats = await apiRequest<AdminStats>('/admin/stats')
|
||||||
const isToday = (value: string) => {
|
|
||||||
const date = new Date(value)
|
|
||||||
return date.getFullYear() === now.getFullYear()
|
|
||||||
&& date.getMonth() === now.getMonth()
|
|
||||||
&& date.getDate() === now.getDate()
|
|
||||||
}
|
|
||||||
|
|
||||||
dashboardStats.value = {
|
dashboardStats.value = {
|
||||||
users: users.value.length,
|
users: stats.users,
|
||||||
images: images.value.length,
|
images: stats.images,
|
||||||
todayUploads: images.value.filter(item => isToday(item.created_at)).length,
|
todayUploads: stats.today_uploads,
|
||||||
pending: images.value.filter(item => item.status === 'pending').length,
|
pending: stats.pending,
|
||||||
approved: images.value.filter(item => item.status === 'approved').length,
|
approved: stats.approved,
|
||||||
rejected: images.value.filter(item => item.status === 'rejected').length,
|
rejected: stats.rejected,
|
||||||
hidden: images.value.filter(item => item.is_hidden).length,
|
hidden: stats.hidden,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchUsers() {
|
async function fetchUsers() {
|
||||||
const rows = await apiRequest<BackendUserProfile[]>('/admin/users')
|
const result = await apiRequest<ApiPage<AdminUserItem>>('/admin/users?page=1&page_size=100')
|
||||||
users.value = rows.map(toAuthUser)
|
users.value = result.items.map(item => item.profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchImages() {
|
async function fetchImages() {
|
||||||
const rows: BackendCloudInfo[] = []
|
const result = await apiRequest<ApiPage<ApiCloud>>('/admin/clouds?page=1&page_size=120')
|
||||||
const pageSize = 100
|
images.value = result.items.map(toAdminCloud)
|
||||||
|
|
||||||
for (let page = 1; ; page++) {
|
|
||||||
const current = await apiRequest<BackendCloudInfo[]>(`/admin/clouds?page=${page}&page_size=${pageSize}`)
|
|
||||||
rows.push(...current)
|
|
||||||
if (current.length < pageSize) break
|
|
||||||
}
|
|
||||||
|
|
||||||
images.value = rows.map(toApiCloud).map(toAdminCloud)
|
|
||||||
selectedReviewIds.value = new Set([...selectedReviewIds.value].filter(id => images.value.some(item => item.id === id)))
|
selectedReviewIds.value = new Set([...selectedReviewIds.value].filter(id => images.value.some(item => item.id === id)))
|
||||||
selectedImageIds.value = new Set([...selectedImageIds.value].filter(id => images.value.some(item => item.id === id)))
|
selectedImageIds.value = new Set([...selectedImageIds.value].filter(id => images.value.some(item => item.id === id)))
|
||||||
}
|
}
|
||||||
@@ -258,8 +249,11 @@ async function loadAdminData() {
|
|||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all([fetchUsers(), fetchImages()])
|
await Promise.all([
|
||||||
syncDashboardStats()
|
fetchStats(),
|
||||||
|
fetchUsers(),
|
||||||
|
fetchImages(),
|
||||||
|
])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
loadError.value = error instanceof Error ? error.message : '管理后台加载失败'
|
loadError.value = error instanceof Error ? error.message : '管理后台加载失败'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -311,13 +305,21 @@ function toggleAllFilteredImageSelection() {
|
|||||||
setSelectedImageIds(filteredImages.value.map(item => item.id))
|
setSelectedImageIds(filteredImages.value.map(item => item.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function patchImages(ids: string[], patch: Partial<AdminCloud>) {
|
||||||
|
const idSet = new Set(ids)
|
||||||
|
images.value = images.value.map(item => (idSet.has(item.id) ? { ...item, ...patch } : item))
|
||||||
|
if (selectedImage.value && idSet.has(selectedImage.value.id)) {
|
||||||
|
selectedImage.value = { ...selectedImage.value, ...patch }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function chunkIds(ids: string[]) {
|
function chunkIds(ids: string[]) {
|
||||||
return Array.from({ length: Math.ceil(ids.length / 100) }, (_, index) =>
|
return Array.from({ length: Math.ceil(ids.length / 100) }, (_, index) =>
|
||||||
ids.slice(index * 100, (index + 1) * 100),
|
ids.slice(index * 100, (index + 1) * 100),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateCloudStatus(ids: string[], status: Exclude<CloudStatus, 'pending'>) {
|
async function updateCloudStatus(ids: string[], status: CloudStatus) {
|
||||||
if (!ids.length) return
|
if (!ids.length) return
|
||||||
|
|
||||||
actionLoading.value = true
|
actionLoading.value = true
|
||||||
@@ -325,20 +327,17 @@ async function updateCloudStatus(ids: string[], status: Exclude<CloudStatus, 'pe
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
for (const chunk of chunkIds(ids)) {
|
for (const chunk of chunkIds(ids)) {
|
||||||
await apiRequest<ActionResponse>('/admin/clouds/review', {
|
await apiRequest('/admin/clouds/status', {
|
||||||
method: 'POST',
|
method: 'PATCH',
|
||||||
body: {
|
body: { ids: chunk, status },
|
||||||
cloud_ids: chunk.map(cloudId => ({ cloud_id: cloudId })),
|
|
||||||
status,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await fetchImages()
|
patchImages(ids, { status })
|
||||||
syncDashboardStats()
|
|
||||||
setSelectedReviewIds([...selectedReviewIds.value].filter(id => !ids.includes(id)))
|
setSelectedReviewIds([...selectedReviewIds.value].filter(id => !ids.includes(id)))
|
||||||
setSelectedImageIds([...selectedImageIds.value].filter(id => !ids.includes(id)))
|
setSelectedImageIds([...selectedImageIds.value].filter(id => !ids.includes(id)))
|
||||||
message.success('审核操作已完成,列表已刷新')
|
await fetchStats()
|
||||||
|
message.success(`已${status === 'approved' ? '通过' : '拒绝'} ${ids.length} 张图片`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const text = getErrorMessage(error, '审核操作失败')
|
const text = getErrorMessage(error, '审核操作失败')
|
||||||
loadError.value = text
|
loadError.value = text
|
||||||
@@ -348,6 +347,37 @@ async function updateCloudStatus(ids: string[], status: Exclude<CloudStatus, 'pe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function updateImageVisibility(ids: string[], isHidden: boolean) {
|
||||||
|
if (!ids.length) return
|
||||||
|
|
||||||
|
actionLoading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const chunk of chunkIds(ids)) {
|
||||||
|
await apiRequest('/admin/clouds/visibility', {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: { ids: chunk, is_hidden: isHidden },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
patchImages(ids, { is_hidden: isHidden })
|
||||||
|
setSelectedImageIds([...selectedImageIds.value].filter(id => !ids.includes(id)))
|
||||||
|
await fetchStats()
|
||||||
|
message.success(isHidden ? `已隐藏 ${ids.length} 张图片` : `已公开 ${ids.length} 张图片`)
|
||||||
|
} catch (error) {
|
||||||
|
const text = getErrorMessage(error, '可见性更新失败')
|
||||||
|
loadError.value = text
|
||||||
|
message.error(text)
|
||||||
|
} finally {
|
||||||
|
actionLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleImageVisibility(cloud: AdminCloud) {
|
||||||
|
await updateImageVisibility([cloud.id], !cloud.is_hidden)
|
||||||
|
}
|
||||||
|
|
||||||
async function deleteImages(clouds: AdminCloud[]) {
|
async function deleteImages(clouds: AdminCloud[]) {
|
||||||
if (!clouds.length) return
|
if (!clouds.length) return
|
||||||
|
|
||||||
@@ -362,33 +392,22 @@ async function deleteImages(clouds: AdminCloud[]) {
|
|||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const deletedIds: string[] = []
|
const idsToDelete = clouds.map(cloud => cloud.id)
|
||||||
let firstError = ''
|
for (const chunk of chunkIds(idsToDelete)) {
|
||||||
|
await apiRequest('/admin/clouds/batch-delete', {
|
||||||
for (const cloud of clouds) {
|
method: 'POST',
|
||||||
try {
|
body: { ids: chunk },
|
||||||
await apiRequest<ActionResponse>(`/admin/clouds/${cloud.id}`, { method: 'DELETE' })
|
})
|
||||||
deletedIds.push(cloud.id)
|
|
||||||
} catch (error) {
|
|
||||||
firstError ||= getErrorMessage(error, '图片删除失败')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await fetchImages()
|
images.value = images.value.filter(item => !idsToDelete.includes(item.id))
|
||||||
syncDashboardStats()
|
if (selectedImage.value && idsToDelete.includes(selectedImage.value.id)) {
|
||||||
if (selectedImage.value && deletedIds.includes(selectedImage.value.id)) {
|
|
||||||
selectedImage.value = null
|
selectedImage.value = null
|
||||||
}
|
}
|
||||||
setSelectedReviewIds([...selectedReviewIds.value].filter(id => !deletedIds.includes(id)))
|
setSelectedReviewIds([...selectedReviewIds.value].filter(id => !idsToDelete.includes(id)))
|
||||||
setSelectedImageIds([...selectedImageIds.value].filter(id => !deletedIds.includes(id)))
|
setSelectedImageIds([...selectedImageIds.value].filter(id => !idsToDelete.includes(id)))
|
||||||
|
await fetchStats()
|
||||||
if (deletedIds.length === clouds.length) {
|
message.success(clouds.length === 1 ? '图片已删除' : `已删除 ${clouds.length} 张图片`)
|
||||||
message.success(clouds.length === 1 ? '图片已删除' : `已删除 ${clouds.length} 张图片`)
|
|
||||||
} else {
|
|
||||||
const text = `已删除 ${deletedIds.length} 张,${clouds.length - deletedIds.length} 张失败${firstError ? `:${firstError}` : ''}`
|
|
||||||
loadError.value = text
|
|
||||||
message.error(text)
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const text = getErrorMessage(error, '图片删除失败')
|
const text = getErrorMessage(error, '图片删除失败')
|
||||||
loadError.value = text
|
loadError.value = text
|
||||||
@@ -402,7 +421,7 @@ async function deleteImage(cloud: AdminCloud) {
|
|||||||
await deleteImages([cloud])
|
await deleteImages([cloud])
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateUserRole(user: AuthUser, role: AuthUser['role']) {
|
async function updateUserRole(user: Profile, role: Profile['role']) {
|
||||||
if (user.id === authStore.user?.id && role !== 'admin') {
|
if (user.id === authStore.user?.id && role !== 'admin') {
|
||||||
message.warning('不能移除自己的管理员权限')
|
message.warning('不能移除自己的管理员权限')
|
||||||
return
|
return
|
||||||
@@ -412,12 +431,12 @@ async function updateUserRole(user: AuthUser, role: AuthUser['role']) {
|
|||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiRequest<ActionResponse>(`/admin/users/${user.id}`, {
|
const result = await apiRequest<AdminUserItem>(`/admin/users/${user.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: { user_role: role },
|
body: { role },
|
||||||
})
|
})
|
||||||
users.value = users.value.map(item => item.id === user.id ? { ...item, role } : item)
|
users.value = users.value.map(item => item.id === user.id ? result.profile : item)
|
||||||
message.success('用户角色已更新;该用户需要重新登录')
|
message.success('用户角色已更新')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const text = getErrorMessage(error, '用户角色更新失败')
|
const text = getErrorMessage(error, '用户角色更新失败')
|
||||||
loadError.value = text
|
loadError.value = text
|
||||||
@@ -427,42 +446,27 @@ async function updateUserRole(user: AuthUser, role: AuthUser['role']) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openPasswordReset(user: AuthUser) {
|
async function toggleUserDisabled(user: Profile) {
|
||||||
passwordTarget.value = user
|
if (user.id === authStore.user?.id) {
|
||||||
newPassword.value = ''
|
message.warning('不能禁用当前登录的管理员账号')
|
||||||
confirmPassword.value = ''
|
|
||||||
passwordError.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePasswordReset() {
|
|
||||||
if (actionLoading.value) return
|
|
||||||
passwordTarget.value = null
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resetUserPassword() {
|
|
||||||
const user = passwordTarget.value
|
|
||||||
if (!user) return
|
|
||||||
|
|
||||||
passwordError.value = ''
|
|
||||||
if (newPassword.value.length < 8) {
|
|
||||||
passwordError.value = '新密码至少需要 8 位。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (newPassword.value !== confirmPassword.value) {
|
|
||||||
passwordError.value = '两次输入的密码不一致。'
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
actionLoading.value = true
|
actionLoading.value = true
|
||||||
|
loadError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiRequest<ActionResponse>(`/admin/users/${user.id}/password`, {
|
const nextDisabled = !user.is_disabled
|
||||||
|
const result = await apiRequest<AdminUserItem>(`/admin/users/${user.id}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
body: { new_password: newPassword.value },
|
body: { is_disabled: nextDisabled },
|
||||||
})
|
})
|
||||||
passwordTarget.value = null
|
users.value = users.value.map(item => item.id === user.id ? result.profile : item)
|
||||||
message.success('密码已重置;该用户需要重新登录')
|
message.success(nextDisabled ? '用户已禁用' : '用户已恢复')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
passwordError.value = getErrorMessage(error, '密码重置失败')
|
const text = getErrorMessage(error, '用户状态更新失败')
|
||||||
|
loadError.value = text
|
||||||
|
message.error(text)
|
||||||
} finally {
|
} finally {
|
||||||
actionLoading.value = false
|
actionLoading.value = false
|
||||||
}
|
}
|
||||||
@@ -498,7 +502,7 @@ onMounted(loadAdminData)
|
|||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="flex min-w-0 items-center gap-3">
|
<div class="flex min-w-0 items-center gap-3">
|
||||||
<span class="admin-brand-icon flex h-10 w-10 shrink-0 items-center justify-center border border-sky-200 bg-sky-50 text-sky-800">
|
<span class="flex h-10 w-10 shrink-0 items-center justify-center border border-sky-200 bg-sky-50 text-sky-800 shadow-[3px_3px_0_0_rgba(14,165,233,0.12)]">
|
||||||
<NIcon size="22"><Shield /></NIcon>
|
<NIcon size="22"><Shield /></NIcon>
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
@@ -515,7 +519,7 @@ onMounted(loadAdminData)
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="oc-icon-button oc-icon-button--small hidden lg:absolute lg:-right-4 lg:top-6 lg:z-20 lg:flex"
|
class="hidden h-8 w-8 items-center justify-center border border-slate-200 bg-white text-slate-500 shadow-[3px_3px_0_0_rgba(15,23,42,0.06)] transition-colors hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 lg:absolute lg:-right-4 lg:top-6 lg:z-20 lg:flex"
|
||||||
:aria-label="sidebarCollapsed ? '展开侧边栏' : '折叠侧边栏'"
|
:aria-label="sidebarCollapsed ? '展开侧边栏' : '折叠侧边栏'"
|
||||||
:aria-expanded="!sidebarCollapsed"
|
:aria-expanded="!sidebarCollapsed"
|
||||||
:title="sidebarCollapsed ? '展开侧边栏' : '折叠侧边栏'"
|
:title="sidebarCollapsed ? '展开侧边栏' : '折叠侧边栏'"
|
||||||
@@ -534,7 +538,7 @@ onMounted(loadAdminData)
|
|||||||
type="button"
|
type="button"
|
||||||
class="group relative flex min-w-[150px] items-center gap-3 overflow-hidden border px-3 py-3 text-left transition-[background-color,border-color,color,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-2 lg:w-full lg:min-w-0"
|
class="group relative flex min-w-[150px] items-center gap-3 overflow-hidden border px-3 py-3 text-left transition-[background-color,border-color,color,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 focus-visible:ring-offset-2 lg:w-full lg:min-w-0"
|
||||||
:class="activeTab === tab.key
|
:class="activeTab === tab.key
|
||||||
? 'admin-control-active border-teal-200 bg-teal-50 text-teal-900'
|
? 'border-teal-200 bg-teal-50 text-teal-900 shadow-[3px_3px_0_0_rgba(20,184,166,0.14)]'
|
||||||
: 'border-transparent bg-white text-slate-600 hover:border-slate-200 hover:bg-slate-50 hover:text-slate-950'"
|
: 'border-transparent bg-white text-slate-600 hover:border-slate-200 hover:bg-slate-50 hover:text-slate-950'"
|
||||||
:aria-current="activeTab === tab.key ? 'page' : undefined"
|
:aria-current="activeTab === tab.key ? 'page' : undefined"
|
||||||
:aria-label="tab.label"
|
:aria-label="tab.label"
|
||||||
@@ -576,7 +580,7 @@ onMounted(loadAdminData)
|
|||||||
type="button"
|
type="button"
|
||||||
class="flex h-14 min-w-0 flex-1 items-center gap-3 overflow-hidden border px-3 py-2 text-left transition-[background-color,border-color,color,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 lg:w-full"
|
class="flex h-14 min-w-0 flex-1 items-center gap-3 overflow-hidden border px-3 py-2 text-left transition-[background-color,border-color,color,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600 lg:w-full"
|
||||||
:class="accountMenuOpen
|
:class="accountMenuOpen
|
||||||
? 'admin-control-active border-teal-200 bg-teal-50 text-teal-800'
|
? 'border-teal-200 bg-teal-50 text-teal-800 shadow-[3px_3px_0_0_rgba(20,184,166,0.12)]'
|
||||||
: 'border-slate-200 bg-slate-50 text-slate-700 hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800'"
|
: 'border-slate-200 bg-slate-50 text-slate-700 hover:border-teal-200 hover:bg-teal-50 hover:text-teal-800'"
|
||||||
aria-label="打开管理员账户菜单"
|
aria-label="打开管理员账户菜单"
|
||||||
aria-haspopup="menu"
|
aria-haspopup="menu"
|
||||||
@@ -607,13 +611,13 @@ onMounted(loadAdminData)
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="oc-floating-panel w-64 border border-slate-200 bg-white"
|
class="w-64 border border-slate-200 bg-white shadow-[8px_8px_0_0_rgba(15,23,42,0.08)]"
|
||||||
role="menu"
|
role="menu"
|
||||||
aria-label="管理员账户"
|
aria-label="管理员账户"
|
||||||
>
|
>
|
||||||
<div class="border-b border-slate-200 bg-[linear-gradient(180deg,#f2faf6_0%,#ffffff_100%)] px-4 py-4">
|
<div class="border-b border-slate-200 bg-[linear-gradient(180deg,#f0fdfa_0%,#ffffff_100%)] px-4 py-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="flex h-11 w-11 shrink-0 items-center justify-center border border-teal-200 bg-[linear-gradient(135deg,#eef9f3_0%,#d9efe3_100%)] text-lg font-bold text-teal-800">
|
<div class="flex h-11 w-11 shrink-0 items-center justify-center border border-teal-200 bg-[linear-gradient(135deg,#ecfeff_0%,#ccfbf1_100%)] text-lg font-bold text-teal-800">
|
||||||
{{ displayInitial }}
|
{{ displayInitial }}
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
@@ -647,7 +651,7 @@ onMounted(loadAdminData)
|
|||||||
<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="flex w-full items-center gap-3 px-2 py-2.5 text-left text-sm font-medium text-rose-600 transition-colors hover:bg-rose-50 hover:text-rose-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-rose-500"
|
||||||
role="menuitem"
|
role="menuitem"
|
||||||
@click="handleLogout"
|
@click="handleLogout"
|
||||||
>
|
>
|
||||||
@@ -659,7 +663,7 @@ onMounted(loadAdminData)
|
|||||||
</NPopover>
|
</NPopover>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/"
|
to="/"
|
||||||
class="oc-panel-button oc-panel-button--neutral flex h-[46px] shrink-0 items-center justify-start gap-3 overflow-hidden px-3 font-semibold focus-visible:ring-2 focus-visible:ring-sky-600 lg:w-full"
|
class="flex h-[46px] shrink-0 items-center justify-start gap-3 overflow-hidden border border-slate-200 bg-white px-3 text-sm font-semibold text-slate-700 shadow-[3px_3px_0_0_rgba(15,23,42,0.06)] transition-colors hover:border-sky-200 hover:bg-sky-50 hover:text-sky-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-600 lg:w-full"
|
||||||
title="返回主页"
|
title="返回主页"
|
||||||
>
|
>
|
||||||
<NIcon size="20" class="shrink-0"><Home /></NIcon>
|
<NIcon size="20" class="shrink-0"><Home /></NIcon>
|
||||||
@@ -711,7 +715,7 @@ onMounted(loadAdminData)
|
|||||||
</NAlert>
|
</NAlert>
|
||||||
|
|
||||||
<section v-if="loading" class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<section v-if="loading" class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<div v-for="n in 8" :key="n" class="oc-panel-card border border-slate-200 bg-white p-5">
|
<div v-for="n in 8" :key="n" class="border border-slate-200 bg-white p-5">
|
||||||
<NSkeleton class="h-4 w-1/2" />
|
<NSkeleton class="h-4 w-1/2" />
|
||||||
<NSkeleton class="mt-4 h-8 w-2/3" />
|
<NSkeleton class="mt-4 h-8 w-2/3" />
|
||||||
<NSkeleton class="mt-3 h-3 w-full" />
|
<NSkeleton class="mt-3 h-3 w-full" />
|
||||||
@@ -724,8 +728,8 @@ onMounted(loadAdminData)
|
|||||||
<div
|
<div
|
||||||
class="flex flex-col justify-between gap-5 border p-6 sm:flex-row sm:items-center"
|
class="flex flex-col justify-between gap-5 border p-6 sm:flex-row sm:items-center"
|
||||||
:class="dashboardStats.pending
|
:class="dashboardStats.pending
|
||||||
? 'admin-priority-card--pending border-amber-200 bg-[linear-gradient(135deg,#fffbeb_0%,#fff_52%,#f2faf6_100%)]'
|
? 'border-amber-200 bg-[linear-gradient(135deg,#fffbeb_0%,#fff_52%,#f0fdfa_100%)] shadow-[5px_5px_0_0_rgba(245,158,11,0.12)]'
|
||||||
: 'admin-priority-card--clear border-teal-200 bg-[linear-gradient(135deg,#f2faf6_0%,#fff_55%,#eff6ff_100%)]'"
|
: 'border-teal-200 bg-[linear-gradient(135deg,#f0fdfa_0%,#fff_55%,#eff6ff_100%)] shadow-[5px_5px_0_0_rgba(20,184,166,0.10)]'"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-5">
|
<div class="flex items-center gap-5">
|
||||||
<div
|
<div
|
||||||
@@ -749,18 +753,18 @@ onMounted(loadAdminData)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NButton
|
<NButton
|
||||||
v-if="dashboardStats.pending > 0"
|
|
||||||
type="default"
|
type="default"
|
||||||
secondary
|
secondary
|
||||||
strong
|
strong
|
||||||
class="oc-panel-button oc-panel-button--amber shrink-0"
|
class="oc-panel-button oc-panel-button--amber shrink-0"
|
||||||
|
:disabled="!dashboardStats.pending"
|
||||||
@click="activeTab = 'review'"
|
@click="activeTab = 'review'"
|
||||||
>
|
>
|
||||||
进入审核队列
|
进入审核队列
|
||||||
</NButton>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="oc-panel-card grid grid-cols-2 border border-slate-200 bg-white">
|
<div class="grid grid-cols-2 border border-slate-200 bg-white">
|
||||||
<div class="border-b border-r border-slate-100 p-4">
|
<div class="border-b border-r border-slate-100 p-4">
|
||||||
<p class="text-xs font-medium text-slate-500">今日上传</p>
|
<p class="text-xs font-medium text-slate-500">今日上传</p>
|
||||||
<p class="mt-2 text-2xl font-bold text-sky-800">{{ dashboardStats.todayUploads }}</p>
|
<p class="mt-2 text-2xl font-bold text-sky-800">{{ dashboardStats.todayUploads }}</p>
|
||||||
@@ -781,7 +785,7 @@ onMounted(loadAdminData)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-5 lg:grid-cols-[1.05fr_0.95fr]">
|
<div class="grid gap-5 lg:grid-cols-[1.05fr_0.95fr]">
|
||||||
<div class="oc-panel-card border border-slate-200 bg-white p-5 sm:p-6">
|
<div class="border border-slate-200 bg-white p-5 sm:p-6">
|
||||||
<div class="flex items-start justify-between gap-4">
|
<div class="flex items-start justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="oc-section-label">Review Health</p>
|
<p class="oc-section-label">Review Health</p>
|
||||||
@@ -819,7 +823,7 @@ onMounted(loadAdminData)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="oc-panel-card border border-slate-200 bg-white p-5 sm:p-6">
|
<div class="border border-slate-200 bg-white p-5 sm:p-6">
|
||||||
<div>
|
<div>
|
||||||
<p class="oc-section-label">Cloud Type Mix</p>
|
<p class="oc-section-label">Cloud Type Mix</p>
|
||||||
<h3 class="mt-2 text-xl font-bold text-slate-950">云型分布</h3>
|
<h3 class="mt-2 text-xl font-bold text-slate-950">云型分布</h3>
|
||||||
@@ -845,37 +849,28 @@ onMounted(loadAdminData)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="oc-panel-card border border-slate-200 bg-white">
|
<div class="border border-slate-200 bg-white">
|
||||||
<div class="flex items-end justify-between gap-4 border-b border-slate-200 px-5 py-4">
|
<div class="flex items-end justify-between gap-4 border-b border-slate-200 px-5 py-4">
|
||||||
<div>
|
<div>
|
||||||
<p class="oc-section-label">Latest Activity</p>
|
<p class="oc-section-label">Latest Activity</p>
|
||||||
<h3 class="mt-2 text-xl font-bold text-slate-950">最近上传</h3>
|
<h3 class="mt-2 text-xl font-bold text-slate-950">最近上传</h3>
|
||||||
</div>
|
</div>
|
||||||
<NButton
|
<button type="button" class="text-sm font-medium text-sky-700 hover:text-sky-900" @click="activeTab = 'images'">
|
||||||
size="small"
|
|
||||||
type="default"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
@click="activeTab = 'images'"
|
|
||||||
>
|
|
||||||
查看全部图片
|
查看全部图片
|
||||||
</NButton>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="recentImages.length" class="divide-y divide-slate-100">
|
<div v-if="recentImages.length" class="divide-y divide-slate-100">
|
||||||
<button
|
<button
|
||||||
v-for="item in recentImages"
|
v-for="item in recentImages"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
type="button"
|
type="button"
|
||||||
class="oc-interactive-row grid w-full grid-cols-[52px_minmax(0,1fr)_auto] items-center gap-3 px-5 py-3 text-left sm:grid-cols-[52px_minmax(0,1fr)_minmax(120px,0.5fr)_auto]"
|
class="grid w-full grid-cols-[52px_minmax(0,1fr)_auto] items-center gap-3 px-5 py-3 text-left transition-colors hover:bg-sky-50/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-sky-600 sm:grid-cols-[52px_minmax(0,1fr)_minmax(120px,0.5fr)_auto]"
|
||||||
@click="selectedImage = item"
|
@click="selectedImage = item"
|
||||||
>
|
>
|
||||||
<img :src="item.thumbnail_url || item.image_url" :alt="item.cloudTypeName" class="h-11 w-13 object-cover" />
|
<img :src="item.thumbnail_url || item.image_url" :alt="item.cloudTypeName" class="h-11 w-13 object-cover" />
|
||||||
<span class="min-w-0">
|
<span class="min-w-0">
|
||||||
<span class="block truncate text-sm font-semibold text-slate-900">{{ item.cloudTypeName }}</span>
|
<span class="block truncate text-sm font-semibold text-slate-900">{{ item.cloudTypeName }}</span>
|
||||||
<span class="mt-0.5 block truncate text-xs text-slate-500">
|
<span class="mt-0.5 block truncate text-xs text-slate-500">{{ item.username }} · {{ item.location_name || '未填写位置' }}</span>
|
||||||
{{ item.username }} · {{ formatCoordinate(item.latitude) }}, {{ formatCoordinate(item.longitude) }}
|
|
||||||
</span>
|
|
||||||
</span>
|
</span>
|
||||||
<span class="hidden truncate text-xs text-slate-500 sm:block">{{ formatDateTime(item.created_at) }}</span>
|
<span class="hidden truncate text-xs text-slate-500 sm:block">{{ formatDateTime(item.created_at) }}</span>
|
||||||
<span class="border px-2 py-1 text-xs font-medium" :class="statusMeta[item.status].chip">
|
<span class="border px-2 py-1 text-xs font-medium" :class="statusMeta[item.status].chip">
|
||||||
@@ -888,20 +883,13 @@ onMounted(loadAdminData)
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-else-if="activeTab === 'review'">
|
<section v-else-if="activeTab === 'review'">
|
||||||
<div class="oc-panel-card mb-4 flex flex-col gap-3 border border-slate-200 bg-white p-4 md:flex-row md:items-center md:justify-between">
|
<div class="mb-4 flex flex-col gap-3 border border-slate-200 bg-white p-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-bold text-slate-950">待审核队列</h2>
|
<h2 class="text-xl font-bold text-slate-950">待审核队列</h2>
|
||||||
<p class="oc-field-help">共 {{ pendingImages.length }} 张待处理图片。</p>
|
<p class="oc-field-help">共 {{ pendingImages.length }} 张待处理图片。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<NButton
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="toggleAllPendingSelection">
|
||||||
v-if="pendingImages.length > 0"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
@click="toggleAllPendingSelection"
|
|
||||||
>
|
|
||||||
{{ selectedReviewCount === pendingImages.length && pendingImages.length ? '取消全选' : '全选待审核' }}
|
{{ selectedReviewCount === pendingImages.length && pendingImages.length ? '取消全选' : '全选待审核' }}
|
||||||
</NButton>
|
</NButton>
|
||||||
<template v-if="selectedReviewCount">
|
<template v-if="selectedReviewCount">
|
||||||
@@ -930,7 +918,7 @@ onMounted(loadAdminData)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="activeReviewImage" class="grid min-w-0 gap-4 xl:grid-cols-[220px_minmax(0,1fr)]">
|
<div v-if="activeReviewImage" class="grid min-w-0 gap-4 xl:grid-cols-[220px_minmax(0,1fr)]">
|
||||||
<aside class="oc-panel-card min-w-0 self-start border border-slate-200 bg-white xl:sticky xl:top-20 xl:max-h-[calc(100vh-6rem)]">
|
<aside class="min-w-0 self-start border border-slate-200 bg-white xl:sticky xl:top-20 xl:max-h-[calc(100vh-6rem)]">
|
||||||
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
<div class="flex items-center justify-between border-b border-slate-200 px-4 py-3">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm font-semibold text-slate-900">审核队列</p>
|
<p class="text-sm font-semibold text-slate-900">审核队列</p>
|
||||||
@@ -951,7 +939,7 @@ onMounted(loadAdminData)
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="oc-interactive-row flex min-w-0 flex-1 items-center gap-3 text-left"
|
class="flex min-w-0 flex-1 items-center gap-3 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-teal-600"
|
||||||
:aria-pressed="activeReviewImage.id === item.id"
|
:aria-pressed="activeReviewImage.id === item.id"
|
||||||
@click="activeReviewId = item.id"
|
@click="activeReviewId = item.id"
|
||||||
>
|
>
|
||||||
@@ -977,14 +965,14 @@ onMounted(loadAdminData)
|
|||||||
|
|
||||||
<div class="grid min-w-0 gap-4 lg:grid-cols-[minmax(0,1fr)_300px]">
|
<div class="grid min-w-0 gap-4 lg:grid-cols-[minmax(0,1fr)_300px]">
|
||||||
<div class="min-w-0 space-y-4">
|
<div class="min-w-0 space-y-4">
|
||||||
<div class="oc-panel-card border border-slate-200 bg-white">
|
<div class="border border-slate-200 bg-white">
|
||||||
<div class="flex items-center justify-between gap-4 border-b border-slate-200 px-4 py-3">
|
<div class="flex items-center justify-between gap-4 border-b border-slate-200 px-4 py-3">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="truncate text-sm font-semibold text-slate-900">{{ activeReviewImage.cloudTypeName }}</p>
|
<p class="truncate text-sm font-semibold text-slate-900">{{ activeReviewImage.cloudTypeName }}</p>
|
||||||
<p class="mt-0.5 truncate text-xs text-slate-500">内容编号 {{ activeReviewImage.id }}</p>
|
<p class="mt-0.5 truncate text-xs text-slate-500">内容编号 {{ activeReviewImage.id }}</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="shrink-0 whitespace-nowrap border border-sky-200 bg-sky-100 px-2 py-1 text-xs font-medium text-sky-700">
|
<span class="shrink-0 whitespace-nowrap border px-2 py-1 text-xs font-medium" :class="rarityMeta[activeReviewImage.cloudTypeRarity].chip">
|
||||||
稀有度 {{ activeReviewImage.cloudTypeRarity }}
|
{{ rarityMeta[activeReviewImage.cloudTypeRarity].label }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -1007,7 +995,7 @@ onMounted(loadAdminData)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="oc-panel-card border border-slate-200 bg-white p-5">
|
<div class="border border-slate-200 bg-white p-5">
|
||||||
<p class="oc-section-label">Uploader Notes</p>
|
<p class="oc-section-label">Uploader Notes</p>
|
||||||
<h3 class="mt-2 text-lg font-bold text-slate-950">上传说明</h3>
|
<h3 class="mt-2 text-lg font-bold text-slate-950">上传说明</h3>
|
||||||
<p class="mt-3 text-sm leading-7 text-slate-700">
|
<p class="mt-3 text-sm leading-7 text-slate-700">
|
||||||
@@ -1016,7 +1004,7 @@ onMounted(loadAdminData)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="oc-panel-card self-start border border-slate-200 bg-white lg:sticky lg:top-20">
|
<aside class="self-start border border-slate-200 bg-white lg:sticky lg:top-20">
|
||||||
<div class="border-b border-slate-200 bg-[linear-gradient(180deg,#fffbeb_0%,#fff_100%)] p-5">
|
<div class="border-b border-slate-200 bg-[linear-gradient(180deg,#fffbeb_0%,#fff_100%)] p-5">
|
||||||
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">Review Decision</p>
|
<p class="text-xs font-semibold uppercase tracking-[0.2em] text-amber-700">Review Decision</p>
|
||||||
<h3 class="mt-2 text-xl font-bold text-slate-950">审核资料</h3>
|
<h3 class="mt-2 text-xl font-bold text-slate-950">审核资料</h3>
|
||||||
@@ -1046,6 +1034,10 @@ onMounted(loadAdminData)
|
|||||||
<dt class="text-xs font-medium text-slate-400">上传时间</dt>
|
<dt class="text-xs font-medium text-slate-400">上传时间</dt>
|
||||||
<dd class="mt-1 text-sm font-medium text-slate-800">{{ formatDateTime(activeReviewImage.created_at) }}</dd>
|
<dd class="mt-1 text-sm font-medium text-slate-800">{{ formatDateTime(activeReviewImage.created_at) }}</dd>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="py-3">
|
||||||
|
<dt class="text-xs font-medium text-slate-400">位置名称</dt>
|
||||||
|
<dd class="mt-1 text-sm font-medium text-slate-800">{{ activeReviewImage.location_name || '未填写位置名称' }}</dd>
|
||||||
|
</div>
|
||||||
<div class="py-3">
|
<div class="py-3">
|
||||||
<dt class="text-xs font-medium text-slate-400">模糊化坐标</dt>
|
<dt class="text-xs font-medium text-slate-400">模糊化坐标</dt>
|
||||||
<dd class="mt-1 font-mono text-xs leading-5 text-slate-700">
|
<dd class="mt-1 font-mono text-xs leading-5 text-slate-700">
|
||||||
@@ -1096,46 +1088,33 @@ onMounted(loadAdminData)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="oc-panel-card border border-dashed border-slate-300 bg-white p-10">
|
<div v-else class="border border-dashed border-slate-300 bg-white p-10">
|
||||||
<NEmpty description="当前没有待审核图片" />
|
<NEmpty description="当前没有待审核图片" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-else-if="activeTab === 'users'">
|
<section v-else-if="activeTab === 'users'">
|
||||||
<div class="oc-panel-card overflow-hidden border border-slate-200 bg-white">
|
<div class="overflow-hidden border border-slate-200 bg-white">
|
||||||
<div class="hidden grid-cols-[minmax(0,1.1fr)_minmax(0,0.7fr)_minmax(0,0.7fr)_minmax(0,0.9fr)_minmax(0,1.5fr)] gap-3 border-b border-slate-200 bg-slate-50 px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-slate-500 lg:grid">
|
<div class="grid grid-cols-[1.1fr_0.8fr_0.7fr_0.9fr_1.2fr] border-b border-slate-200 bg-slate-50 px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-slate-500">
|
||||||
<span>用户</span>
|
<span>用户</span>
|
||||||
<span>角色</span>
|
<span>角色</span>
|
||||||
<span>状态</span>
|
<span>状态</span>
|
||||||
<span>注册时间</span>
|
<span>注册时间</span>
|
||||||
<span>操作</span>
|
<span>操作</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div v-for="user in users" :key="user.id" class="grid grid-cols-[1.1fr_0.8fr_0.7fr_0.9fr_1.2fr] items-center gap-3 border-b border-slate-100 px-4 py-4 last:border-b-0">
|
||||||
v-for="user in users"
|
<div class="min-w-0">
|
||||||
:key="user.id"
|
|
||||||
class="grid gap-4 border-b border-slate-100 px-4 py-4 last:border-b-0 sm:grid-cols-2 lg:grid-cols-[minmax(0,1.1fr)_minmax(0,0.7fr)_minmax(0,0.7fr)_minmax(0,0.9fr)_minmax(0,1.5fr)] lg:items-center lg:gap-3"
|
|
||||||
>
|
|
||||||
<div class="min-w-0 sm:col-span-2 lg:col-span-1">
|
|
||||||
<p class="truncate font-medium text-slate-950">{{ user.username }}</p>
|
<p class="truncate font-medium text-slate-950">{{ user.username }}</p>
|
||||||
<p class="mt-1 truncate text-xs text-slate-400">{{ user.email }}</p>
|
<p class="mt-1 truncate text-xs text-slate-400">{{ user.id }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between gap-3 lg:block">
|
<NTag size="small" :bordered="false" :type="user.role === 'admin' ? 'success' : 'default'">
|
||||||
<span class="text-xs font-medium text-slate-400 lg:hidden">角色</span>
|
{{ user.role === 'admin' ? '管理员' : '用户' }}
|
||||||
<NTag size="small" :bordered="false" :type="user.role === 'admin' ? 'success' : 'default'">
|
</NTag>
|
||||||
{{ user.role === 'admin' ? '管理员' : '用户' }}
|
<NTag size="small" :bordered="false" :type="user.is_disabled ? 'error' : 'success'">
|
||||||
</NTag>
|
{{ user.is_disabled ? '已禁用' : '正常' }}
|
||||||
</div>
|
</NTag>
|
||||||
<div class="flex items-center justify-between gap-3 lg:block">
|
<span class="text-sm text-slate-500">{{ formatDateTime(user.created_at) }}</span>
|
||||||
<span class="text-xs font-medium text-slate-400 lg:hidden">状态</span>
|
<div class="flex flex-wrap gap-2">
|
||||||
<NTag size="small" :bordered="false" :type="user.is_disabled ? 'error' : 'success'">
|
|
||||||
{{ user.is_disabled ? '已禁用' : '正常' }}
|
|
||||||
</NTag>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between gap-3 text-sm text-slate-500 lg:block">
|
|
||||||
<span class="text-xs font-medium text-slate-400 lg:hidden">注册时间</span>
|
|
||||||
<span>{{ formatDateTime(user.created_at) }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-wrap gap-2 sm:col-span-2 lg:col-span-1">
|
|
||||||
<NButton
|
<NButton
|
||||||
size="small"
|
size="small"
|
||||||
secondary
|
secondary
|
||||||
@@ -1153,14 +1132,12 @@ onMounted(loadAdminData)
|
|||||||
type="default"
|
type="default"
|
||||||
secondary
|
secondary
|
||||||
strong
|
strong
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
:class="user.is_disabled ? 'oc-panel-button oc-panel-button--teal' : 'oc-panel-button oc-panel-button--danger'"
|
||||||
:disabled="user.id === authStore.user?.id"
|
:disabled="user.id === authStore.user?.id"
|
||||||
:loading="actionLoading"
|
:loading="actionLoading"
|
||||||
:title="user.id === authStore.user?.id ? '请在账号设置中修改自己的密码' : '设置新密码'"
|
@click="toggleUserDisabled(user)"
|
||||||
@click="openPasswordReset(user)"
|
|
||||||
>
|
>
|
||||||
<template #icon><NIcon><Key /></NIcon></template>
|
{{ user.is_disabled ? '恢复' : '禁用' }}
|
||||||
重置密码
|
|
||||||
</NButton>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1168,10 +1145,10 @@ onMounted(loadAdminData)
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-else>
|
<section v-else>
|
||||||
<div class="oc-panel-card mb-4 flex flex-col gap-3 border border-slate-200 bg-white p-4 md:flex-row md:items-center md:justify-between">
|
<div class="mb-4 flex flex-col gap-3 border border-slate-200 bg-white p-4 md:flex-row md:items-center md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-xl font-bold text-slate-950">图片管理</h2>
|
<h2 class="text-xl font-bold text-slate-950">图片管理</h2>
|
||||||
<p class="oc-field-help">共 {{ images.length }} 张图片,可调整审核状态或删除。</p>
|
<p class="oc-field-help">最近 {{ images.length }} 张图片,可调整审核状态、可见性或删除。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<select
|
<select
|
||||||
@@ -1183,14 +1160,7 @@ onMounted(loadAdminData)
|
|||||||
<option value="approved">已通过</option>
|
<option value="approved">已通过</option>
|
||||||
<option value="rejected">已拒绝</option>
|
<option value="rejected">已拒绝</option>
|
||||||
</select>
|
</select>
|
||||||
<NButton
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="toggleAllFilteredImageSelection">
|
||||||
v-if="filteredImages.length > 0"
|
|
||||||
secondary
|
|
||||||
strong
|
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
|
||||||
@click="toggleAllFilteredImageSelection"
|
|
||||||
>
|
|
||||||
{{ selectedManagedCount === filteredImages.length && filteredImages.length ? '取消全选' : '全选当前列表' }}
|
{{ selectedManagedCount === filteredImages.length && filteredImages.length ? '取消全选' : '全选当前列表' }}
|
||||||
</NButton>
|
</NButton>
|
||||||
<template v-if="selectedManagedCount">
|
<template v-if="selectedManagedCount">
|
||||||
@@ -1214,6 +1184,26 @@ onMounted(loadAdminData)
|
|||||||
>
|
>
|
||||||
批量拒绝 {{ selectedManagedCount }}
|
批量拒绝 {{ selectedManagedCount }}
|
||||||
</NButton>
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--amber"
|
||||||
|
:loading="actionLoading"
|
||||||
|
@click="updateImageVisibility(Array.from(selectedImageIds), true)"
|
||||||
|
>
|
||||||
|
批量隐藏 {{ selectedManagedCount }}
|
||||||
|
</NButton>
|
||||||
|
<NButton
|
||||||
|
secondary
|
||||||
|
strong
|
||||||
|
type="default"
|
||||||
|
class="oc-panel-button oc-panel-button--teal"
|
||||||
|
:loading="actionLoading"
|
||||||
|
@click="updateImageVisibility(Array.from(selectedImageIds), false)"
|
||||||
|
>
|
||||||
|
批量公开 {{ selectedManagedCount }}
|
||||||
|
</NButton>
|
||||||
<NButton
|
<NButton
|
||||||
secondary
|
secondary
|
||||||
strong
|
strong
|
||||||
@@ -1229,17 +1219,15 @@ onMounted(loadAdminData)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="filteredImages.length" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
<div v-if="filteredImages.length" class="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
||||||
<article v-for="item in filteredImages" :key="item.id" class="oc-panel-card overflow-hidden border border-slate-200 bg-white">
|
<article v-for="item in filteredImages" :key="item.id" class="overflow-hidden border border-slate-200 bg-white">
|
||||||
<button type="button" class="oc-interactive-card block w-full overflow-hidden bg-slate-100" @click="selectedImage = item">
|
<button type="button" class="block w-full overflow-hidden bg-slate-100" @click="selectedImage = item">
|
||||||
<img :src="item.thumbnail_url || item.image_url" :alt="item.cloudTypeName" class="h-52 w-full object-cover transition-transform hover:scale-105" />
|
<img :src="item.thumbnail_url || item.image_url" :alt="item.cloudTypeName" class="h-52 w-full object-cover transition-transform hover:scale-105" />
|
||||||
</button>
|
</button>
|
||||||
<div class="p-4">
|
<div class="p-4">
|
||||||
<div class="flex items-start justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="truncate font-semibold text-slate-950">{{ item.cloudTypeName }}</p>
|
<p class="truncate font-semibold text-slate-950">{{ item.cloudTypeName }}</p>
|
||||||
<p class="mt-1 truncate text-sm text-slate-500">
|
<p class="mt-1 truncate text-sm text-slate-500">{{ item.username }} · {{ item.location_name || '未填写位置' }}</p>
|
||||||
{{ item.username }} · {{ formatCoordinate(item.latitude) }}, {{ formatCoordinate(item.longitude) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-start gap-2">
|
<div class="flex items-start gap-2">
|
||||||
<NTag size="small" :bordered="false" :class="statusMeta[item.status].chip">
|
<NTag size="small" :bordered="false" :class="statusMeta[item.status].chip">
|
||||||
@@ -1257,6 +1245,15 @@ onMounted(loadAdminData)
|
|||||||
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--neutral" @click="selectedImage = item">查看</NButton>
|
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--neutral" @click="selectedImage = item">查看</NButton>
|
||||||
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--teal" :disabled="item.status === 'approved'" :loading="actionLoading" @click="updateCloudStatus([item.id], 'approved')">通过</NButton>
|
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--teal" :disabled="item.status === 'approved'" :loading="actionLoading" @click="updateCloudStatus([item.id], 'approved')">通过</NButton>
|
||||||
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--danger" :disabled="item.status === 'rejected'" :loading="actionLoading" @click="updateCloudStatus([item.id], 'rejected')">拒绝</NButton>
|
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--danger" :disabled="item.status === 'rejected'" :loading="actionLoading" @click="updateCloudStatus([item.id], 'rejected')">拒绝</NButton>
|
||||||
|
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--amber" :loading="actionLoading" @click="toggleImageVisibility(item)">
|
||||||
|
<template #icon>
|
||||||
|
<NIcon>
|
||||||
|
<Eye v-if="item.is_hidden" />
|
||||||
|
<EyeOff v-else />
|
||||||
|
</NIcon>
|
||||||
|
</template>
|
||||||
|
{{ item.is_hidden ? '公开' : '隐藏' }}
|
||||||
|
</NButton>
|
||||||
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--danger" :loading="actionLoading" @click="deleteImage(item)">
|
<NButton size="small" type="default" secondary strong class="oc-panel-button oc-panel-button--danger" :loading="actionLoading" @click="deleteImage(item)">
|
||||||
<template #icon><NIcon><Trash /></NIcon></template>
|
<template #icon><NIcon><Trash /></NIcon></template>
|
||||||
删除
|
删除
|
||||||
@@ -1266,7 +1263,7 @@ onMounted(loadAdminData)
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="oc-panel-card border border-dashed border-slate-300 bg-white p-10">
|
<div v-else class="border border-dashed border-slate-300 bg-white p-10">
|
||||||
<NEmpty description="没有符合筛选条件的图片" />
|
<NEmpty description="没有符合筛选条件的图片" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1274,71 +1271,6 @@ onMounted(loadAdminData)
|
|||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Teleport to="body">
|
|
||||||
<div
|
|
||||||
v-if="passwordTarget"
|
|
||||||
class="fixed inset-0 z-[140] flex items-center justify-center bg-slate-950/60 px-4 py-6"
|
|
||||||
@click="closePasswordReset"
|
|
||||||
>
|
|
||||||
<form class="oc-modal-shell w-full max-w-lg" @click.stop @submit.prevent="resetUserPassword">
|
|
||||||
<div class="flex items-start justify-between gap-4 border-b border-slate-200 px-5 py-4 sm:px-6 sm:py-5">
|
|
||||||
<div class="min-w-0">
|
|
||||||
<p class="oc-section-label">Admin Credential</p>
|
|
||||||
<h3 class="mt-2 text-xl font-semibold text-slate-900">重置用户密码</h3>
|
|
||||||
<p class="oc-field-help truncate">@{{ passwordTarget.username }} · {{ passwordTarget.email }}</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="oc-icon-button"
|
|
||||||
aria-label="关闭密码重置窗口"
|
|
||||||
title="关闭密码重置窗口"
|
|
||||||
@click="closePasswordReset"
|
|
||||||
>
|
|
||||||
<NIcon size="20"><X /></NIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="grid gap-4 px-5 py-5 sm:px-6">
|
|
||||||
<NAlert v-if="passwordError" type="error" :show-icon="false" :bordered="false">
|
|
||||||
{{ passwordError }}
|
|
||||||
</NAlert>
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label">新密码 <span class="oc-field-required">*</span></label>
|
|
||||||
<NInput
|
|
||||||
v-model:value="newPassword"
|
|
||||||
type="password"
|
|
||||||
show-password-on="click"
|
|
||||||
placeholder="至少 8 位"
|
|
||||||
:input-props="{ autocomplete: 'new-password', name: 'admin-new-password', required: true }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label class="oc-field-label">确认新密码 <span class="oc-field-required">*</span></label>
|
|
||||||
<NInput
|
|
||||||
v-model:value="confirmPassword"
|
|
||||||
type="password"
|
|
||||||
show-password-on="click"
|
|
||||||
placeholder="再次输入新密码"
|
|
||||||
:input-props="{ autocomplete: 'new-password', name: 'admin-confirm-new-password', required: true }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p class="border border-amber-200 bg-amber-50 px-3 py-2.5 text-xs leading-5 text-amber-800">
|
|
||||||
保存后,该用户所有设备上的现有登录状态都会立即失效。
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-end gap-3 border-t border-slate-200 px-5 py-4 sm:px-6">
|
|
||||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral" :disabled="actionLoading" @click="closePasswordReset">
|
|
||||||
取消
|
|
||||||
</NButton>
|
|
||||||
<NButton attr-type="submit" type="default" secondary strong class="oc-panel-button oc-panel-button--amber" :loading="actionLoading">
|
|
||||||
确认重置
|
|
||||||
</NButton>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</Teleport>
|
|
||||||
|
|
||||||
<ImageDetailModal
|
<ImageDetailModal
|
||||||
v-if="selectedImage"
|
v-if="selectedImage"
|
||||||
:open="!!selectedImage"
|
:open="!!selectedImage"
|
||||||
@@ -1347,8 +1279,8 @@ onMounted(loadAdminData)
|
|||||||
:image-alt="selectedImage.cloudTypeName"
|
:image-alt="selectedImage.cloudTypeName"
|
||||||
:title="selectedImage.cloudTypeName"
|
:title="selectedImage.cloudTypeName"
|
||||||
:subtitle="`上传者:${selectedImage.username}`"
|
:subtitle="`上传者:${selectedImage.username}`"
|
||||||
:badge-label="`稀有度 ${selectedImage.cloudTypeRarity}`"
|
:badge-label="rarityMeta[selectedImage.cloudTypeRarity].label"
|
||||||
badge-class="border-sky-200 bg-sky-100 text-sky-700"
|
:badge-class="rarityMeta[selectedImage.cloudTypeRarity].chip"
|
||||||
@close="selectedImage = null"
|
@close="selectedImage = null"
|
||||||
>
|
>
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
@@ -1383,25 +1315,8 @@ onMounted(loadAdminData)
|
|||||||
<MiniLocationMap
|
<MiniLocationMap
|
||||||
:latitude="selectedImage.latitude"
|
:latitude="selectedImage.latitude"
|
||||||
:longitude="selectedImage.longitude"
|
:longitude="selectedImage.longitude"
|
||||||
|
:location-name="selectedImage.location_name"
|
||||||
/>
|
/>
|
||||||
</ImageDetailModal>
|
</ImageDetailModal>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.admin-brand-icon {
|
|
||||||
box-shadow: var(--oc-shadow-sky);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-control-active {
|
|
||||||
box-shadow: var(--oc-shadow-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-priority-card--pending {
|
|
||||||
box-shadow: var(--oc-shadow-amber);
|
|
||||||
}
|
|
||||||
|
|
||||||
.admin-priority-card--clear {
|
|
||||||
box-shadow: var(--oc-shadow-primary-hover);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,73 +1,89 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref } from 'vue'
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { NButton, NCard, NResult, NSpin } from 'naive-ui'
|
import { NButton, NCard, NResult, NSpin } from 'naive-ui'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { apiRequest } from '@/lib/api'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const state = ref<'loading' | 'success' | 'failed'>('loading')
|
const state = ref<'loading' | 'success' | 'failed'>('loading')
|
||||||
const error = ref('')
|
|
||||||
const countdown = ref(5)
|
const countdown = ref(5)
|
||||||
const token = typeof route.query.token === 'string' ? route.query.token : ''
|
let countdownTimer: ReturnType<typeof setInterval> | null = null
|
||||||
let timer: ReturnType<typeof setInterval> | null = null
|
const confirmationToken = typeof route.query.token === 'string' ? route.query.token : ''
|
||||||
|
|
||||||
if (route.query.token !== undefined) {
|
if (route.query.token !== undefined) {
|
||||||
window.history.replaceState(window.history.state, '', route.path)
|
window.history.replaceState(window.history.state, '', window.location.pathname)
|
||||||
}
|
}
|
||||||
|
|
||||||
function startCountdown() {
|
function startCountdown() {
|
||||||
timer = setInterval(() => {
|
countdownTimer = setInterval(() => {
|
||||||
countdown.value--
|
countdown.value--
|
||||||
if (countdown.value <= 0) {
|
if (countdown.value <= 0) {
|
||||||
if (timer) clearInterval(timer)
|
if (countdownTimer) clearInterval(countdownTimer)
|
||||||
void router.push('/login')
|
router.push('/login')
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!token) {
|
|
||||||
error.value = '确认链接缺少验证信息'
|
|
||||||
state.value = 'failed'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await authStore.confirmEmail(token)
|
if (!confirmationToken) {
|
||||||
|
state.value = 'failed'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await apiRequest('/auth/confirm-email', {
|
||||||
|
method: 'POST',
|
||||||
|
auth: false,
|
||||||
|
body: { token: confirmationToken },
|
||||||
|
})
|
||||||
state.value = 'success'
|
state.value = 'success'
|
||||||
startCountdown()
|
startCountdown()
|
||||||
} catch (e: unknown) {
|
} catch {
|
||||||
error.value = e instanceof Error ? e.message : '确认链接无效或已过期'
|
|
||||||
state.value = 'failed'
|
state.value = 'failed'
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => { if (timer) clearInterval(timer) })
|
onUnmounted(() => {
|
||||||
|
if (countdownTimer) clearInterval(countdownTimer)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||||
<div class="mx-auto max-w-3xl">
|
<div class="mx-auto max-w-3xl">
|
||||||
<NCard class="oc-panel-card-xl">
|
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||||
<NResult v-if="state === 'success'" status="success" title="邮箱确认成功" description="现在可以使用邮箱和密码登录。">
|
<template v-if="state === 'success'">
|
||||||
|
<NResult status="success" title="邮箱认证成功" description="你的邮箱已确认,现在可以登录了。">
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动前往登录页面</p>
|
<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>
|
<RouterLink to="/login" class="no-underline">
|
||||||
|
<NButton type="primary" class="oc-primary-button oc-primary-button--teal">立即登录</NButton>
|
||||||
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</NResult>
|
</NResult>
|
||||||
<NResult v-else-if="state === 'failed'" status="error" title="邮箱确认失败" :description="error">
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="state === 'failed'">
|
||||||
|
<NResult status="error" title="认证失败" description="邮箱确认链接无效或已过期,请重新注册。">
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<RouterLink to="/resend-confirmation" class="no-underline"><NButton type="default" class="oc-panel-button oc-panel-button--sky">重新发送验证邮件</NButton></RouterLink>
|
<RouterLink to="/register" class="no-underline">
|
||||||
|
<NButton type="primary" class="oc-primary-button oc-primary-button--sky">重新注册</NButton>
|
||||||
|
</RouterLink>
|
||||||
</template>
|
</template>
|
||||||
</NResult>
|
</NResult>
|
||||||
<div v-else class="flex flex-col items-center justify-center py-12 text-center">
|
</template>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="flex flex-col items-center justify-center py-12 text-center">
|
||||||
<NSpin size="large" />
|
<NSpin size="large" />
|
||||||
<h1 class="mt-6 text-2xl font-bold text-slate-900">正在确认邮箱...</h1>
|
<h1 class="mt-6 text-2xl font-bold text-slate-900">正在验证...</h1>
|
||||||
<p class="mt-2 text-slate-500">请稍候</p>
|
<p class="mt-2 text-slate-500">请稍候</p>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
</NCard>
|
</NCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,61 +6,123 @@ import { useAuthStore } from '@/stores/auth'
|
|||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
|
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const submitted = ref(false)
|
const submitted = ref(false)
|
||||||
const cooldown = ref(0)
|
const cooldown = ref(0)
|
||||||
let timer: ReturnType<typeof setInterval> | null = null
|
let cooldownTimer: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
const buttonLabel = computed(() => loading.value ? '发送中...' : cooldown.value > 0 ? `重新发送(${cooldown.value}s)` : '重新发送')
|
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() {
|
function startCooldown() {
|
||||||
if (timer) clearInterval(timer)
|
clearCooldownTimer()
|
||||||
cooldown.value = 60
|
cooldown.value = 60
|
||||||
timer = setInterval(() => {
|
cooldownTimer = setInterval(() => {
|
||||||
cooldown.value--
|
cooldown.value--
|
||||||
if (cooldown.value <= 0 && timer) {
|
if (cooldown.value <= 0) {
|
||||||
clearInterval(timer)
|
cooldown.value = 0
|
||||||
timer = null
|
clearCooldownTimer()
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function handleSendResetEmail() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
const value = email.value.trim()
|
|
||||||
if (!value || value.length > 64) {
|
if (!email.value.trim()) {
|
||||||
error.value = '请输入不超过 64 个字符的有效邮箱'
|
error.value = '请输入需要找回密码的邮箱。'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await authStore.sendPasswordReset(value)
|
await authStore.sendPasswordReset(email.value.trim())
|
||||||
email.value = value
|
|
||||||
submitted.value = true
|
submitted.value = true
|
||||||
startCooldown()
|
startCooldown()
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
error.value = e instanceof Error ? e.message : '重置邮件发送失败,请稍后重试'
|
error.value = e instanceof Error ? e.message : '重置邮件发送失败,请稍后重试。'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnmounted(() => { if (timer) clearInterval(timer) })
|
async function resendResetEmail() {
|
||||||
|
if (cooldown.value > 0 || loading.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleSendResetEmail()
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
clearCooldownTimer()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||||
<div class="mx-auto max-w-2xl">
|
<div class="mx-auto grid max-w-6xl gap-8 lg:grid-cols-[1.05fr_0.95fr] lg:items-center">
|
||||||
<NCard class="oc-panel-card-xl">
|
<section class="border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_52%,#f0fdfa_100%)] p-8 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)]">
|
||||||
<NResult v-if="submitted" status="success" title="请求已受理" description="如果该邮箱符合条件,我们会发送密码重置邮件。">
|
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Password Recovery</p>
|
||||||
|
<h1 class="mt-4 max-w-xl text-5xl font-black leading-[1.05] text-slate-900">
|
||||||
|
重新拿回你的
|
||||||
|
<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="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||||
|
<NResult
|
||||||
|
v-if="submitted"
|
||||||
|
status="success"
|
||||||
|
title="重置邮件已发送"
|
||||||
|
description="如果该邮箱已注册,请查收邮件中的重置链接。"
|
||||||
|
>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<p class="text-sm text-slate-500">目标邮箱:<span class="font-semibold text-slate-700">{{ email }}</span></p>
|
<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">
|
<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>
|
<NButton
|
||||||
<RouterLink :to="{ path: '/login', query: { email } }" class="no-underline"><NButton type="default" class="oc-panel-button oc-panel-button--neutral">返回登录</NButton></RouterLink>
|
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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -68,18 +130,43 @@ onUnmounted(() => { if (timer) clearInterval(timer) })
|
|||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Password Recovery</div>
|
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Reset Request</div>
|
||||||
<h1 class="mt-3 text-3xl font-bold text-slate-900">忘记密码</h1>
|
<h2 class="mt-3 text-3xl font-bold text-slate-900">忘记密码</h2>
|
||||||
<p class="mt-2 text-sm text-slate-500">输入注册邮箱,我们会发送有效期为 30 分钟的重置链接。</p>
|
<p class="mt-2 text-sm text-slate-500">输入你的注册邮箱,我们会发送一封密码重置邮件。</p>
|
||||||
</div>
|
</div>
|
||||||
<NForm @submit.prevent="submit">
|
|
||||||
|
<NForm @submit.prevent="handleSendResetEmail">
|
||||||
<NFormItem label="邮箱">
|
<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 }" />
|
<NInput
|
||||||
|
v-model:value="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
/>
|
||||||
</NFormItem>
|
</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>
|
<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>
|
</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>
|
|
||||||
|
<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>
|
</template>
|
||||||
</NCard>
|
</NCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput } from 'naive-ui'
|
import { NAlert, NButton, NCard, NForm, NFormItem, NInput } from 'naive-ui'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
@@ -12,7 +12,6 @@ const email = ref(typeof route.query.email === 'string' ? route.query.email : ''
|
|||||||
const password = ref('')
|
const password = ref('')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const needsEmailConfirmation = computed(() => error.value.includes('邮箱尚未验证'))
|
|
||||||
|
|
||||||
function getSafeRedirect(value: unknown) {
|
function getSafeRedirect(value: unknown) {
|
||||||
if (typeof value !== 'string' || !value.startsWith('/')) return '/'
|
if (typeof value !== 'string' || !value.startsWith('/')) return '/'
|
||||||
@@ -43,33 +42,33 @@ async function handleLogin() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
<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">
|
<div class="mx-auto grid 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">
|
<section class="flex h-full flex-col justify-between border border-slate-200 bg-[linear-gradient(135deg,#f0fdfa_0%,#ffffff_48%,#eff6ff_100%)] p-8 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)]">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm uppercase tracking-[0.26em] text-teal-700">Sky Log In</p>
|
<p class="text-sm uppercase tracking-[0.26em] text-teal-700">Sky Log In</p>
|
||||||
<h1 class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl">
|
<h1 class="mt-4 max-w-xl text-5xl font-black leading-[1.05] text-slate-900">
|
||||||
登录后继续记录
|
登录后继续记录
|
||||||
<span class="block text-teal-700">你眼中的每一朵云</span>
|
<span class="block text-teal-700">你眼中的每一朵云</span>
|
||||||
</h1>
|
</h1>
|
||||||
<!-- <p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
|
<!-- <p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
|
||||||
在地图、百科和画廊之间浏览观测记录。上传新的云图后,审核通过的作品会进入公开画廊与地图。
|
在地图、图鉴和画廊之间同步你的观测记录。上传新的云图后,图鉴会自动点亮,画廊也会按时间收纳你的作品。
|
||||||
</p> -->
|
</p> -->
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-8 grid min-w-0 grid-cols-2 gap-4">
|
<div class="mt-8 flex gap-4">
|
||||||
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
<div class="border border-slate-200 bg-white px-4 py-3">
|
||||||
<div class="oc-stat-label">Collection</div>
|
<div class="oc-stat-label">Collection</div>
|
||||||
<div class="oc-stat-value">10</div>
|
<div class="oc-stat-value">10</div>
|
||||||
<div class="oc-field-help">基础云属资料</div>
|
<div class="oc-field-help">基础云属待收集</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
<div class="border border-slate-200 bg-white px-4 py-3">
|
||||||
<div class="oc-stat-label">Mode</div>
|
<div class="oc-stat-label">Mode</div>
|
||||||
<div class="oc-stat-value">Atlas</div>
|
<div class="oc-stat-value">Atlas</div>
|
||||||
<div class="oc-field-help">地图、百科、画廊一体</div>
|
<div class="oc-field-help">地图、图鉴、画廊一体</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<NCard class="oc-panel-card-xl min-w-0 h-full">
|
<NCard class="h-full shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Member Access</div>
|
<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>
|
<h2 class="mt-3 text-3xl font-bold text-slate-900">登录 OpenCloud</h2>
|
||||||
@@ -80,13 +79,8 @@ async function handleLogin() {
|
|||||||
<NFormItem label="邮箱">
|
<NFormItem label="邮箱">
|
||||||
<NInput
|
<NInput
|
||||||
v-model:value="email"
|
v-model:value="email"
|
||||||
type="text"
|
required
|
||||||
:input-props="{
|
autocomplete="email"
|
||||||
autocomplete: 'username',
|
|
||||||
inputmode: 'email',
|
|
||||||
name: 'username',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
placeholder="your@email.com"
|
placeholder="your@email.com"
|
||||||
/>
|
/>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
@@ -95,26 +89,15 @@ async function handleLogin() {
|
|||||||
<NInput
|
<NInput
|
||||||
v-model:value="password"
|
v-model:value="password"
|
||||||
type="password"
|
type="password"
|
||||||
|
required
|
||||||
show-password-on="click"
|
show-password-on="click"
|
||||||
:input-props="{
|
autocomplete="current-password"
|
||||||
autocomplete: 'current-password',
|
|
||||||
name: 'current-password',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
placeholder="输入密码"
|
placeholder="输入密码"
|
||||||
/>
|
/>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
|
|
||||||
<NAlert v-if="error" type="error" class="mb-4">
|
<NAlert v-if="error" type="error" class="mb-4">
|
||||||
{{ error }}
|
{{ 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>
|
</NAlert>
|
||||||
|
|
||||||
<NButton
|
<NButton
|
||||||
@@ -130,15 +113,15 @@ async function handleLogin() {
|
|||||||
</NForm>
|
</NForm>
|
||||||
|
|
||||||
<div class="mt-6 flex flex-wrap items-center justify-between gap-3 text-sm text-slate-500">
|
<div class="mt-6 flex flex-wrap items-center justify-between gap-3 text-sm text-slate-500">
|
||||||
<span>
|
<p>
|
||||||
还没有账号?
|
没有账号?
|
||||||
<RouterLink to="/register" class="font-semibold text-sky-700 hover:text-sky-800">立即注册</RouterLink>
|
<RouterLink to="/register" class="font-semibold text-teal-700 hover:text-teal-800">去注册</RouterLink>
|
||||||
</span>
|
</p>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
:to="{ path: '/forgot-password', query: { email } }"
|
:to="email ? { path: '/forgot-password', query: { email } } : { path: '/forgot-password' }"
|
||||||
class="font-semibold text-teal-700 hover:text-teal-800"
|
class="font-semibold text-teal-700 transition-colors hover:text-teal-800"
|
||||||
>
|
>
|
||||||
忘记密码
|
忘记密码?
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</NCard>
|
</NCard>
|
||||||
|
|||||||
@@ -1,64 +1,44 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
|
||||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
||||||
import { ApiError } from '@/lib/api'
|
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const route = useRoute()
|
|
||||||
|
|
||||||
const name = ref('')
|
const email = ref('')
|
||||||
const email = ref(typeof route.query.email === 'string' ? route.query.email : '')
|
|
||||||
const password = ref('')
|
const password = ref('')
|
||||||
const confirmPassword = ref('')
|
const confirmPassword = ref('')
|
||||||
|
const username = ref('')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const registered = ref(false)
|
const emailSent = ref(false)
|
||||||
const emailDelivered = ref(true)
|
|
||||||
const registerConflict = ref(false)
|
|
||||||
const canResend = computed(() => registerConflict.value)
|
|
||||||
|
|
||||||
async function handleRegister() {
|
async function handleRegister() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
registerConflict.value = false
|
|
||||||
const trimmedName = name.value.trim()
|
|
||||||
const trimmedEmail = email.value.trim()
|
|
||||||
|
|
||||||
if (!trimmedName) {
|
if (!username.value.trim()) {
|
||||||
error.value = '请输入用户名'
|
error.value = '请输入用户名'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (trimmedName.length > 16) {
|
if (username.value.length > 20) {
|
||||||
error.value = '用户名不能超过 16 个字符'
|
error.value = '用户名不能超过20个字符'
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!trimmedEmail || trimmedEmail.length > 64) {
|
|
||||||
error.value = '请输入不超过 64 个字符的有效邮箱'
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (password.value.length < 8) {
|
if (password.value.length < 8) {
|
||||||
error.value = '密码至少需要 8 位'
|
error.value = '密码至少8位'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (password.value !== confirmPassword.value) {
|
if (password.value !== confirmPassword.value) {
|
||||||
error.value = '两次输入的密码不一致'
|
error.value = '两次密码不一致'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const response = await authStore.register(trimmedName, trimmedEmail, password.value)
|
await authStore.register(email.value, password.value, username.value.trim())
|
||||||
email.value = trimmedEmail
|
emailSent.value = true
|
||||||
emailDelivered.value = response.email_sent
|
|
||||||
registered.value = true
|
|
||||||
} catch (e: unknown) {
|
} catch (e: unknown) {
|
||||||
if (e instanceof ApiError && e.status === 409) {
|
error.value = e instanceof Error ? e.message : '注册失败,请稍后重试'
|
||||||
registerConflict.value = true
|
|
||||||
error.value = '邮箱或用户名已被占用;若邮箱尚未验证,可以尝试重发验证邮件。'
|
|
||||||
} else {
|
|
||||||
error.value = e instanceof Error ? e.message : '注册失败,请稍后重试'
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@@ -67,49 +47,48 @@ async function handleRegister() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
<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">
|
<div class="mx-auto grid 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">
|
<section class="flex h-full flex-col justify-between border border-slate-200 bg-[linear-gradient(135deg,#eff6ff_0%,#ffffff_42%,#f0fdfa_100%)] p-8 shadow-[10px_10px_0_0_rgba(15,23,42,0.08)]">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Observer Join</p>
|
<p class="text-sm uppercase tracking-[0.26em] text-sky-700">Observer Join</p>
|
||||||
<h1 class="mt-4 max-w-xl break-words text-4xl font-black leading-[1.05] text-slate-900 sm:text-5xl">
|
<h1 class="mt-4 max-w-xl text-5xl font-black leading-[1.05] text-slate-900">
|
||||||
加入天空探索者
|
加入天空探索者
|
||||||
<span class="block text-sky-700">建立你的云图档案</span>
|
<span class="block text-sky-700">建立你的云图档案</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
<!-- <p class="mt-6 max-w-lg text-base leading-8 text-slate-600">
|
||||||
|
注册后即可上传云图、点亮图鉴、在社区画廊里按时间展示你的观测记录。所有页面都会围绕你的个人云层档案同步更新。
|
||||||
|
</p> -->
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-8 grid min-w-0 grid-cols-2 gap-4">
|
<div class="mt-8 flex gap-4">
|
||||||
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
<div class="border border-slate-200 bg-white px-4 py-3">
|
||||||
<div class="oc-stat-label">Step 1</div>
|
<div class="oc-stat-label">Review</div>
|
||||||
<div class="oc-stat-value">Join</div>
|
<div class="oc-stat-value">Queue</div>
|
||||||
<div class="oc-field-help">填写账号资料</div>
|
<div class="oc-field-help">上传后进入审核队列</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0 border border-slate-200 bg-white px-4 py-3">
|
<div class="border border-slate-200 bg-white px-4 py-3">
|
||||||
<div class="oc-stat-label">Step 2</div>
|
<div class="oc-stat-label">Profile</div>
|
||||||
<div class="oc-stat-value">Verify</div>
|
<div class="oc-stat-value">Journal</div>
|
||||||
<div class="oc-field-help">通过邮件确认邮箱</div>
|
<div class="oc-field-help">自动生成个人天空日志</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<NCard class="oc-panel-card-xl min-w-0 h-full">
|
<NCard class="h-full shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||||
<NResult
|
<NResult
|
||||||
v-if="registered"
|
v-if="emailSent"
|
||||||
:status="emailDelivered ? 'success' : 'warning'"
|
status="success"
|
||||||
title="确认你的邮箱"
|
title="确认你的邮箱"
|
||||||
:description="emailDelivered
|
description="确认邮件已经发送,请查收并点击链接完成注册。"
|
||||||
? '确认邮件已经发送,请查收并点击链接完成注册。'
|
|
||||||
: '账号已创建,但验证邮件暂未送达。请使用重发验证功能,不要重复注册。'"
|
|
||||||
>
|
>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<p class="text-sm text-slate-500">目标邮箱:<span class="font-semibold text-slate-700">{{ email }}</span></p>
|
<p class="text-sm text-slate-500">
|
||||||
<div class="flex flex-wrap justify-center gap-3">
|
目标邮箱:
|
||||||
<RouterLink :to="{ path: '/resend-confirmation', query: { email } }" class="no-underline">
|
<span class="font-semibold text-slate-700">{{ email }}</span>
|
||||||
<NButton type="default" class="oc-panel-button oc-panel-button--sky">重发验证邮件</NButton>
|
</p>
|
||||||
</RouterLink>
|
<RouterLink to="/login" class="no-underline">
|
||||||
<RouterLink :to="{ path: '/login', query: { email } }" class="no-underline">
|
<NButton type="primary" class="oc-primary-button oc-primary-button--teal">去登录</NButton>
|
||||||
<NButton type="default" class="oc-panel-button oc-panel-button--teal">去登录</NButton>
|
</RouterLink>
|
||||||
</RouterLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</NResult>
|
</NResult>
|
||||||
@@ -118,36 +97,72 @@ async function handleRegister() {
|
|||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Create Account</div>
|
<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>
|
<h2 class="mt-3 text-3xl font-bold text-slate-900">注册 OpenCloud</h2>
|
||||||
<p class="mt-2 text-sm text-slate-500">注册后,请先通过邮件确认邮箱再登录。</p>
|
<p class="mt-2 text-sm text-slate-500">加入社区后即可开始记录、收集与展示云图。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<NForm @submit.prevent="handleRegister">
|
<NForm @submit.prevent="handleRegister">
|
||||||
<NFormItem label="用户名">
|
<NFormItem label="用户名">
|
||||||
<NInput v-model:value="name" maxlength="16" show-count placeholder="最多 16 个字符" :input-props="{ autocomplete: 'username', name: 'username', required: true }" />
|
<NInput
|
||||||
|
v-model:value="username"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
autocomplete="username"
|
||||||
|
placeholder="你的昵称"
|
||||||
|
maxlength="20"
|
||||||
|
/>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
|
|
||||||
<NFormItem label="邮箱">
|
<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 }" />
|
<NInput
|
||||||
|
v-model:value="email"
|
||||||
|
required
|
||||||
|
autocomplete="email"
|
||||||
|
placeholder="your@email.com"
|
||||||
|
/>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
|
|
||||||
<NFormItem label="密码">
|
<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 }" />
|
<NInput
|
||||||
|
v-model:value="password"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
show-password-on="click"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="至少8位"
|
||||||
|
/>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
|
|
||||||
<NFormItem label="确认密码">
|
<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 }" />
|
<NInput
|
||||||
|
v-model:value="confirmPassword"
|
||||||
|
type="password"
|
||||||
|
required
|
||||||
|
show-password-on="click"
|
||||||
|
autocomplete="new-password"
|
||||||
|
placeholder="再次输入密码"
|
||||||
|
/>
|
||||||
</NFormItem>
|
</NFormItem>
|
||||||
|
|
||||||
<NAlert v-if="error" type="error" class="mb-4">
|
<NAlert v-if="error" type="error" class="mb-4">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
<div v-if="canResend" class="mt-2">
|
|
||||||
<RouterLink :to="{ path: '/resend-confirmation', query: { email } }" class="font-semibold text-rose-700 underline underline-offset-2">重发验证邮件</RouterLink>
|
|
||||||
</div>
|
|
||||||
</NAlert>
|
</NAlert>
|
||||||
|
|
||||||
<NButton attr-type="submit" type="primary" block size="large" class="oc-primary-button oc-primary-button--sky" :loading="loading">
|
<NButton
|
||||||
|
attr-type="submit"
|
||||||
|
type="primary"
|
||||||
|
block
|
||||||
|
size="large"
|
||||||
|
class="oc-primary-button oc-primary-button--sky"
|
||||||
|
:loading="loading"
|
||||||
|
>
|
||||||
{{ loading ? '注册中...' : '注册' }}
|
{{ loading ? '注册中...' : '注册' }}
|
||||||
</NButton>
|
</NButton>
|
||||||
</NForm>
|
</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>
|
<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>
|
</template>
|
||||||
</NCard>
|
</NCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,83 +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 '@/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,90 +1,192 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult } from 'naive-ui'
|
import { NAlert, NButton, NCard, NForm, NFormItem, NInput, NResult, NSpin } from 'naive-ui'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { apiRequest } from '@/lib/api'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
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 password = ref('')
|
||||||
const confirmPassword = ref('')
|
const confirmPassword = ref('')
|
||||||
const error = ref(!email || !token ? '密码重置链接缺少必要信息,请重新发送邮件。' : '')
|
const error = ref('')
|
||||||
const invalid = ref(!email || !token)
|
const state = ref<'checking' | 'ready' | 'invalid' | 'success'>('checking')
|
||||||
const success = ref(false)
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const countdown = ref(5)
|
const countdown = ref(5)
|
||||||
let timer: ReturnType<typeof setInterval> | null = null
|
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 || route.query.email !== undefined) {
|
if (route.query.token !== undefined) {
|
||||||
window.history.replaceState(window.history.state, '', route.path)
|
window.history.replaceState(window.history.state, '', window.location.pathname)
|
||||||
}
|
}
|
||||||
|
|
||||||
const canSubmit = computed(() => !invalid.value && password.value.length >= 8 && password.value === confirmPassword.value)
|
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() {
|
function startCountdown() {
|
||||||
timer = setInterval(() => {
|
countdownTimer = setInterval(() => {
|
||||||
countdown.value--
|
countdown.value--
|
||||||
if (countdown.value <= 0) {
|
if (countdown.value <= 0) {
|
||||||
if (timer) clearInterval(timer)
|
if (countdownTimer) clearInterval(countdownTimer)
|
||||||
void router.push({ path: '/login', query: { email } })
|
router.push('/login')
|
||||||
}
|
}
|
||||||
}, 1000)
|
}, 1000)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submit() {
|
async function initializeRecoverySession() {
|
||||||
|
if (!resetToken.value) {
|
||||||
|
showInvalidRecoveryLink()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state.value = 'ready'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleResetPassword() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
|
|
||||||
if (password.value.length < 8) {
|
if (password.value.length < 8) {
|
||||||
error.value = '新密码至少需要 8 位'
|
error.value = '新密码至少需要 8 位。'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (password.value !== confirmPassword.value) {
|
if (password.value !== confirmPassword.value) {
|
||||||
error.value = '两次输入的密码不一致'
|
error.value = '两次输入的密码不一致。'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
await authStore.resetPassword(email, token, password.value)
|
await apiRequest('/auth/reset-password', {
|
||||||
success.value = true
|
method: 'POST',
|
||||||
|
auth: false,
|
||||||
|
body: { token: resetToken.value, password: password.value },
|
||||||
|
})
|
||||||
|
countdown.value = 5
|
||||||
|
state.value = 'success'
|
||||||
startCountdown()
|
startCountdown()
|
||||||
} catch (e: unknown) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : '密码重置失败,请稍后重试'
|
error.value = getResetErrorMessage(e)
|
||||||
if (error.value.includes('无效') || error.value.includes('过期')) invalid.value = true
|
if (error.value === '密码重置链接无效或已过期,请重新发送邮件。') {
|
||||||
|
state.value = 'invalid'
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnmounted(() => { if (timer) clearInterval(timer) })
|
onMounted(() => {
|
||||||
|
void initializeRecoverySession()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (countdownTimer) clearInterval(countdownTimer)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
<div class="min-h-[calc(100vh-4rem)] px-4 py-10">
|
||||||
<div class="mx-auto max-w-2xl">
|
<div class="mx-auto max-w-2xl">
|
||||||
<NCard class="oc-panel-card-xl">
|
<NCard class="shadow-[12px_12px_0_0_rgba(15,23,42,0.08)]">
|
||||||
<NResult v-if="success" status="success" title="密码已重置" description="请使用新密码重新登录。">
|
<NResult
|
||||||
|
v-if="state === 'success'"
|
||||||
|
status="success"
|
||||||
|
title="密码已重置"
|
||||||
|
description="你已使用新密码完成更新,现在可以重新登录。"
|
||||||
|
>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<p class="text-sm text-slate-500">{{ countdown }} 秒后自动前往登录页面</p>
|
<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>
|
<NButton type="primary" class="oc-primary-button oc-primary-button--teal" @click="router.push('/login')">立即登录</NButton>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</NResult>
|
</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>
|
<template v-else>
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<div class="text-sm uppercase tracking-[0.22em] text-slate-500">Password Recovery</div>
|
<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>
|
<h1 class="mt-3 text-3xl font-bold text-slate-900">设置新密码</h1>
|
||||||
<p class="mt-2 text-sm text-slate-500">提交成功后,此重置链接和现有登录会话都会失效。</p>
|
<p class="mt-2 text-sm text-slate-500">
|
||||||
|
{{
|
||||||
|
state === 'invalid'
|
||||||
|
? '当前重置链接不可用,请重新发送密码重置邮件。'
|
||||||
|
: '请输入新的登录密码,提交后当前重置链接会失效。'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
</div>
|
</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>
|
<NForm @submit.prevent="handleResetPassword">
|
||||||
<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>
|
<NFormItem label="新密码">
|
||||||
<NAlert v-if="error" type="error" class="mb-4">{{ error }}</NAlert>
|
<NInput
|
||||||
<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>
|
v-model:value="password"
|
||||||
<NButton attr-type="submit" type="primary" block size="large" class="oc-primary-button oc-primary-button--teal" :disabled="!canSubmit" :loading="loading">保存新密码</NButton>
|
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>
|
</NForm>
|
||||||
</template>
|
</template>
|
||||||
</NCard>
|
</NCard>
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#f2faf6_0%,#f8fafc_100%)] px-4 py-16">
|
<div class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#f0fdfa_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">
|
<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>
|
<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>
|
<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 class="mt-5 max-w-2xl text-sm leading-7 text-slate-600">
|
||||||
|
|||||||
@@ -1,60 +1,126 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { NAlert, NButton, NEmpty, NSkeleton } from 'naive-ui'
|
import { NAlert, NButton, NCard, NEmpty, NSkeleton, NTag } from 'naive-ui'
|
||||||
import { RouterLink, useRoute } from 'vue-router'
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||||
import { apiRequest, imageUrl, toApiCloud } from '@/lib/api'
|
import { apiRequest } from '@/lib/api'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useEncyclopediaStore } from '@/stores/encyclopedia'
|
import { useEncyclopediaStore } from '@/stores/encyclopedia'
|
||||||
import type { ApiCloud, BackendCloudInfo } from '@/types/api'
|
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 PAGE_SIZE = 24
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
const encyclopediaStore = useEncyclopediaStore()
|
const encyclopediaStore = useEncyclopediaStore()
|
||||||
|
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const loadError = ref('')
|
const loadError = ref('')
|
||||||
const gallery = ref<ApiCloud[]>([])
|
const gallery = ref<CloudGalleryItem[]>([])
|
||||||
const selectedItem = ref<ApiCloud | null>(null)
|
const selectedGalleryItem = ref<CloudGalleryItem | null>(null)
|
||||||
const currentPage = ref(1)
|
const publicCount = ref(0)
|
||||||
const hasNextPage = ref(false)
|
|
||||||
|
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 cloudTypeId = computed(() => Number(route.params.id))
|
||||||
const cloudType = computed(() => encyclopediaStore.cloudTypes.find(item => item.id === cloudTypeId.value) ?? null)
|
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)
|
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 formatDateTime(iso: string | null) {
|
function formatDate(iso: string | null) {
|
||||||
if (!iso) return '未记录拍摄时间'
|
if (!iso) return '未知时间'
|
||||||
return new Date(iso).toLocaleString('zh-CN', {
|
return new Date(iso).toLocaleDateString('zh-CN', {
|
||||||
year: 'numeric', month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit',
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadGallery(page: number) {
|
function formatGalleryTime(item: CloudGalleryItem) {
|
||||||
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
|
return formatDate(item.captured_at || item.created_at)
|
||||||
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) {
|
function formatDateTime(iso: string | null) {
|
||||||
if (!Number.isInteger(cloudTypeId.value) || cloudTypeId.value <= 0) {
|
if (!iso) return '未知时间'
|
||||||
loadError.value = '云类型不存在'
|
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
|
loading.value = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
selectedItem.value = null
|
|
||||||
try {
|
try {
|
||||||
await encyclopediaStore.fetchCloudTypes()
|
await encyclopediaStore.fetchCloudTypes()
|
||||||
|
await encyclopediaStore.fetchMyCollection()
|
||||||
|
|
||||||
if (!cloudType.value) {
|
if (!cloudType.value) {
|
||||||
loadError.value = '云类型不存在'
|
loadError.value = '云型不存在'
|
||||||
gallery.value = []
|
gallery.value = []
|
||||||
|
publicCount.value = 0
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
await loadGallery(page)
|
|
||||||
|
await loadGallery(cloudType.value.id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
loadError.value = error instanceof Error ? error.message : '详情加载失败'
|
loadError.value = error instanceof Error ? error.message : '详情加载失败'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -62,81 +128,191 @@ async function loadPage(page = 1) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => loadPage())
|
onMounted(loadPage)
|
||||||
watch(() => route.params.id, () => loadPage())
|
watch(() => route.params.id, loadPage)
|
||||||
|
|
||||||
|
watch(() => route.params.id, () => {
|
||||||
|
selectedGalleryItem.value = null
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="oc-container oc-container--content py-8">
|
<div class="max-w-6xl mx-auto px-4 py-8">
|
||||||
<RouterLink to="/encyclopedia">
|
<div class="mb-6">
|
||||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">← 返回百科总览</NButton>
|
<RouterLink to="/encyclopedia">
|
||||||
</RouterLink>
|
<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>
|
</div>
|
||||||
|
|
||||||
<NAlert v-else-if="loadError" class="mt-6" type="error" :show-icon="false" :bordered="false" title="详情加载失败">
|
<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 }}
|
{{ loadError }}
|
||||||
</NAlert>
|
</NAlert>
|
||||||
|
|
||||||
<template v-else-if="cloudType">
|
<template v-else-if="cloudType">
|
||||||
<section class="oc-panel-card mt-6 overflow-hidden border border-slate-200 bg-white">
|
<section class="overflow-hidden border border-slate-200 bg-white shadow-sm">
|
||||||
<div class="grid lg:grid-cols-[1.05fr_0.95fr]">
|
<div class="grid lg:grid-cols-[1.15fr_0.85fr]">
|
||||||
<div class="relative min-h-80 bg-sky-50">
|
<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" />
|
<img
|
||||||
<div v-else class="absolute inset-0 flex items-center justify-center pb-20 text-sm font-medium text-slate-500">无图片</div>
|
v-if="heroImage"
|
||||||
<div v-if="heroImage" class="absolute inset-0 bg-gradient-to-t from-slate-950/75 via-slate-950/10 to-transparent"></div>
|
:src="heroImage"
|
||||||
<div class="absolute bottom-7 left-7 right-7" :class="heroImage ? 'text-white' : 'text-slate-900'">
|
:alt="cloudType.name"
|
||||||
<span
|
class="absolute inset-0 h-full w-full object-cover"
|
||||||
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'"
|
<div
|
||||||
>稀有度 {{ cloudType.rarity_value }}</span>
|
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>
|
<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>
|
<p class="mt-2 text-lg text-white/82">{{ cloudType.name_en }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-8">
|
|
||||||
<p class="oc-section-label">Meteorology Note</p>
|
<div class="flex flex-col justify-between bg-slate-50 p-8">
|
||||||
<p class="mt-5 text-base leading-8 text-slate-700">{{ cloudType.description || '暂未提供识别说明。' }}</p>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="mt-8">
|
||||||
<div class="mb-5 flex items-end justify-between gap-4">
|
<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">第 {{ currentPage }} 页,每页最多 {{ PAGE_SIZE }} 张。</p></div>
|
<div>
|
||||||
<div class="flex gap-3">
|
<h2 class="text-2xl font-bold text-slate-900">该类型云图画廊</h2>
|
||||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral" :disabled="currentPage === 1" @click="loadPage(currentPage - 1)">上一页</NButton>
|
<p class="oc-field-help">来自社区公开可见的已通过云图。</p>
|
||||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky" :disabled="!hasNextPage" @click="loadPage(currentPage + 1)">下一页</NButton>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="gallery.length" class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<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" @click="selectedItem = item">
|
<button
|
||||||
<img :src="item.thumbnail_url || item.image_url" :alt="cloudType.name" class="h-48 w-full object-cover" />
|
v-for="item in gallery"
|
||||||
<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>
|
:key="item.id"
|
||||||
|
type="button"
|
||||||
|
class="overflow-hidden border border-slate-200 bg-white text-left shadow-sm transition-transform duration-300 hover:-translate-y-1 hover:shadow-lg"
|
||||||
|
@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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="oc-empty-card border border-dashed border-slate-300 bg-white p-10"><NEmpty description="这一页暂无公开云图" /></div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<ImageDetailModal
|
<div v-else class="border border-dashed border-slate-300 bg-white p-8">
|
||||||
v-if="selectedItem && cloudType"
|
<NEmpty description="还没有公开作品">
|
||||||
:open="true"
|
<template #extra>
|
||||||
:image-url="selectedItem.image_url"
|
<p class="text-sm text-slate-500">等第一位观测者上传这类云图后,这里就会展示出来。</p>
|
||||||
:thumbnail-url="selectedItem.thumbnail_url"
|
</template>
|
||||||
:image-alt="cloudType.name"
|
</NEmpty>
|
||||||
:title="cloudType.name"
|
</div>
|
||||||
:subtitle="selectedItem.captured_at ? `拍摄于 ${formatDateTime(selectedItem.captured_at)}` : `上传于 ${formatDateTime(selectedItem.created_at)}`"
|
</section>
|
||||||
:badge-label="`稀有度 ${cloudType.rarity_value}`"
|
|
||||||
badge-class="border-slate-200 bg-slate-50 text-slate-600"
|
<ImageDetailModal
|
||||||
@close="selectedItem = null"
|
v-if="selectedGalleryItem"
|
||||||
>
|
:open="!!selectedGalleryItem"
|
||||||
<p class="text-sm leading-7 text-slate-700">{{ selectedItem.description || '上传者没有留下额外说明。' }}</p>
|
:image-url="selectedGalleryItem.image_url"
|
||||||
<MiniLocationMap :latitude="selectedItem.latitude" :longitude="selectedItem.longitude" />
|
:thumbnail-url="selectedGalleryItem.thumbnail_url"
|
||||||
</ImageDetailModal>
|
: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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,92 +1,191 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { NEmpty, NSkeleton } from 'naive-ui'
|
import { NAlert, NCard, NEmpty, NIcon, NSkeleton } from 'naive-ui'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { imageUrl } from '@/lib/api'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useEncyclopediaStore } from '@/stores/encyclopedia'
|
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 router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
const encyclopediaStore = useEncyclopediaStore()
|
const encyclopediaStore = useEncyclopediaStore()
|
||||||
|
|
||||||
function typeImageUrl(iconId: string | null) {
|
const lockHint = ref('')
|
||||||
return iconId ? imageUrl(iconId, 'preview') : ''
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => encyclopediaStore.fetchCloudTypes())
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="oc-page-hero">
|
<div 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 Encyclopedia</p>
|
<div class="grid gap-6 lg:grid-cols-[1.4fr_0.9fr] lg:items-start">
|
||||||
<h1 class="oc-page-title">云朵百科</h1>
|
<div>
|
||||||
<p class="oc-page-description">
|
<p class="oc-page-eyebrow">Cloud Encyclopedia</p>
|
||||||
浏览后端提供的云类型资料,了解云属名称、识别说明和社区公开观测记录。
|
<h1 class="oc-page-title">云朵图鉴</h1>
|
||||||
</p>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="oc-container oc-container--content oc-page-content">
|
<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">
|
<NAlert
|
||||||
<div v-for="n in 6" :key="n" class="oc-panel-card overflow-hidden border border-slate-200 bg-white">
|
v-if="encyclopediaStore.collectionError && authStore.isLoggedIn"
|
||||||
<NSkeleton class="h-80 w-full" />
|
class="mb-6"
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
<div v-else-if="encyclopediaStore.cloudTypes.length" class="grid gap-5 md:grid-cols-2 lg:grid-cols-3">
|
<div v-else-if="encyclopediaStore.cloudTypes.length" class="grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||||
<button
|
<button
|
||||||
v-for="cloudType in encyclopediaStore.cloudTypes"
|
v-for="cloudType in encyclopediaStore.cloudTypes"
|
||||||
:key="cloudType.id"
|
:key="cloudType.id"
|
||||||
type="button"
|
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="openCard(cloudType)"
|
||||||
@click="router.push(`/encyclopedia/${cloudType.id}`)"
|
class="group overflow-hidden border bg-white text-left shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg"
|
||||||
|
:class="isUnlocked(cloudType.id) ? 'border-amber-300 shadow-amber-100/60' : 'border-slate-200 hover:border-slate-300'"
|
||||||
>
|
>
|
||||||
<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
|
<div
|
||||||
v-if="cloudType.icon_id"
|
class="relative h-44 overflow-hidden border-b"
|
||||||
class="absolute inset-0 -z-10 bg-gradient-to-t from-slate-950/90 via-slate-950/55 to-slate-950/25"
|
:class="isUnlocked(cloudType.id) ? 'border-white/40' : 'border-slate-200'"
|
||||||
></div>
|
>
|
||||||
<div v-else class="absolute inset-0 -z-10 flex items-center justify-center text-sm font-medium text-slate-500">无图片</div>
|
<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="flex min-h-80 flex-col p-5">
|
<div class="absolute inset-0 bg-gradient-to-t from-slate-950/70 via-slate-950/10 to-transparent"></div>
|
||||||
<div class="flex items-start justify-between gap-4">
|
|
||||||
<div>
|
<div v-if="!isUnlocked(cloudType.id)" class="absolute inset-0 flex items-center justify-center bg-slate-950/22 backdrop-blur-[2px]">
|
||||||
<p class="oc-section-label" :class="cloudType.icon_id ? 'text-white/70' : 'text-slate-500'">Cloud Genus</p>
|
<div class="border border-white/45 bg-white/15 px-4 py-2 text-sm font-medium text-white">
|
||||||
<h2 class="mt-2 text-xl font-bold" :class="cloudType.icon_id ? 'text-white' : 'text-slate-900'">{{ cloudType.name }}</h2>
|
<span style="display: inline-flex; align-items: center; gap: 4px;">
|
||||||
<p
|
<NIcon size="16" style="display: inline-flex;">
|
||||||
class="mt-1 font-mono text-xs uppercase tracking-[0.12em]"
|
<Lock />
|
||||||
:class="cloudType.icon_id ? 'text-white/75' : 'text-slate-500'"
|
</NIcon>
|
||||||
>
|
<span>尚未解锁</span>
|
||||||
{{ cloudType.genus || '未提供云属名称' }}
|
</span>
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<span
|
</div>
|
||||||
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'"
|
<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">
|
||||||
稀有度 {{ cloudType.rarity_value }}
|
{{ rarityMeta[cloudType.rarity].label }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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 || '暂未提供识别说明。' }}
|
<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>
|
</p>
|
||||||
<p
|
|
||||||
class="mt-4 border-t pt-4 text-xs font-semibold"
|
<div class="mt-5 flex items-center justify-between text-sm">
|
||||||
:class="cloudType.icon_id ? 'border-white/25 text-white' : 'border-slate-200 text-sky-700'"
|
<template v-if="isUnlocked(cloudType.id) && getCollectionEntry(cloudType.id)">
|
||||||
>查看类型资料与公开云图 →</p>
|
<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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="oc-empty-card border border-dashed border-slate-300 bg-white px-6 py-12">
|
<div v-else class="oc-empty-card border border-dashed border-slate-300 bg-white px-6 py-12">
|
||||||
<NEmpty description="暂时还没有云类型资料" />
|
<NEmpty description="暂时还没有图鉴数据" />
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,24 +1,26 @@
|
|||||||
<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 { RouterLink } from 'vue-router'
|
|
||||||
import { NAlert, NButton, NDropdown, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
|
import { NAlert, NButton, NDropdown, NEmpty, NIcon, NSkeleton, NTag, useMessage } from 'naive-ui'
|
||||||
import { Clock, Location, Search, Settings, User, X } from '@vicons/tabler'
|
import { Clock, Location, Search, Settings, User, X } from '@vicons/tabler'
|
||||||
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
|
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
|
||||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||||
import { ApiError, apiRequest, toApiCloud } from '@/lib/api'
|
import { apiRequest } from '@/lib/api'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useCloudsStore } from '@/stores/clouds'
|
import { useCloudsStore } from '@/stores/clouds'
|
||||||
import { useProfileStore } from '@/stores/profile'
|
import { useProfileStore } from '@/stores/profile'
|
||||||
|
|
||||||
import type { ApiCloud, BackendCloudInfo } from '@/types/api'
|
import type { ApiCloud, ApiPage } from '@/types/api'
|
||||||
|
import type { CloudType } from '@/types/database'
|
||||||
|
|
||||||
interface GalleryCloud {
|
interface GalleryCloud {
|
||||||
id: string
|
id: string
|
||||||
user_id: string
|
user_id: string
|
||||||
cloud_type_id: number | null
|
cloud_type_id: number | null
|
||||||
|
custom_cloud_type: string | null
|
||||||
image_url: string
|
image_url: string
|
||||||
thumbnail_url: string | null
|
thumbnail_url: string | null
|
||||||
|
location_name: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
latitude: number | null
|
latitude: number | null
|
||||||
longitude: number | null
|
longitude: number | null
|
||||||
@@ -27,7 +29,7 @@ interface GalleryCloud {
|
|||||||
status: 'pending' | 'approved' | 'rejected'
|
status: 'pending' | 'approved' | 'rejected'
|
||||||
is_hidden: boolean
|
is_hidden: boolean
|
||||||
cloudTypeName: string
|
cloudTypeName: string
|
||||||
cloudTypeRarity: number
|
cloudTypeRarity: CloudType['rarity']
|
||||||
username: string
|
username: string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +47,8 @@ const selectedTypeId = ref<number | 'all'>('all')
|
|||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
const selectedCloud = ref<GalleryCloud | null>(null)
|
const selectedCloud = ref<GalleryCloud | null>(null)
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const hasNextPage = ref(false)
|
const totalCount = ref(0)
|
||||||
|
const totalPages = computed(() => Math.max(1, Math.ceil(totalCount.value / PAGE_SIZE)))
|
||||||
const manageError = ref('')
|
const manageError = ref('')
|
||||||
const editModalOpen = ref(false)
|
const editModalOpen = ref(false)
|
||||||
const editSaving = ref(false)
|
const editSaving = ref(false)
|
||||||
@@ -53,7 +56,11 @@ const editInitialValue = ref<CloudEditFormValue | null>(null)
|
|||||||
|
|
||||||
let searchTimer: number | null = null
|
let searchTimer: number | null = null
|
||||||
|
|
||||||
const rarityChip = 'border-slate-200 bg-slate-50 text-slate-600'
|
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) {
|
function formatDateTime(iso: string | null) {
|
||||||
if (!iso) return '未知时间'
|
if (!iso) return '未知时间'
|
||||||
@@ -86,8 +93,10 @@ function toGalleryCloud(row: ApiCloud) {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
user_id: row.user_id,
|
user_id: row.user_id,
|
||||||
cloud_type_id: row.cloud_type_id,
|
cloud_type_id: row.cloud_type_id,
|
||||||
|
custom_cloud_type: row.custom_cloud_type,
|
||||||
image_url: row.image_url,
|
image_url: row.image_url,
|
||||||
thumbnail_url: row.thumbnail_url,
|
thumbnail_url: row.thumbnail_url,
|
||||||
|
location_name: row.location_name,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
latitude: row.latitude,
|
latitude: row.latitude,
|
||||||
longitude: row.longitude,
|
longitude: row.longitude,
|
||||||
@@ -95,8 +104,8 @@ function toGalleryCloud(row: ApiCloud) {
|
|||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
is_hidden: row.is_hidden,
|
is_hidden: row.is_hidden,
|
||||||
cloudTypeName: row.cloud_type_name || '未知',
|
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
|
||||||
cloudTypeRarity: row.cloud_type_rarity_value ?? 0,
|
cloudTypeRarity: row.cloud_type_rarity || 'common',
|
||||||
username: row.username || '匿名',
|
username: row.username || '匿名',
|
||||||
} satisfies GalleryCloud
|
} satisfies GalleryCloud
|
||||||
}
|
}
|
||||||
@@ -107,28 +116,14 @@ async function loadPage(page: number) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
|
const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) })
|
||||||
if (normalizedSearch.value) {
|
if (selectedTypeId.value !== 'all') params.set('type_id', String(selectedTypeId.value))
|
||||||
params.set('filter', isUserSearch.value ? 'owner' : 'type')
|
if (normalizedSearch.value) params.set('search', normalizedSearch.value)
|
||||||
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 })
|
const result = await apiRequest<ApiPage<ApiCloud>>(`/clouds?${params}`, { auth: false })
|
||||||
galleryItems.value = rows.map(toApiCloud).map(toGalleryCloud)
|
totalCount.value = result.total
|
||||||
hasNextPage.value = rows.length === PAGE_SIZE
|
galleryItems.value = result.items.map(toGalleryCloud)
|
||||||
currentPage.value = page
|
currentPage.value = page
|
||||||
} catch (error) {
|
} 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 : '画廊加载失败'
|
loadError.value = error instanceof Error ? error.message : '画廊加载失败'
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
@@ -136,7 +131,7 @@ async function loadPage(page: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function goToPage(page: number) {
|
async function goToPage(page: number) {
|
||||||
if (page < 1 || page === currentPage.value || (page > currentPage.value && !hasNextPage.value)) return
|
if (page < 1 || page > totalPages.value || page === currentPage.value) return
|
||||||
await loadPage(page)
|
await loadPage(page)
|
||||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
}
|
}
|
||||||
@@ -177,10 +172,12 @@ function openEditModal() {
|
|||||||
|
|
||||||
manageError.value = ''
|
manageError.value = ''
|
||||||
editInitialValue.value = {
|
editInitialValue.value = {
|
||||||
cloudCategoryId: selectedCloud.value.cloud_type_id,
|
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),
|
capturedAt: formatDatetimeLocal(selectedCloud.value.captured_at),
|
||||||
latitude: selectedCloud.value.latitude,
|
latitude: selectedCloud.value.latitude,
|
||||||
longitude: selectedCloud.value.longitude,
|
longitude: selectedCloud.value.longitude,
|
||||||
|
locationName: selectedCloud.value.location_name || '',
|
||||||
description: selectedCloud.value.description || '',
|
description: selectedCloud.value.description || '',
|
||||||
isPublic: !selectedCloud.value.is_hidden,
|
isPublic: !selectedCloud.value.is_hidden,
|
||||||
}
|
}
|
||||||
@@ -207,14 +204,18 @@ async function submitEditModal(value: CloudEditFormValue) {
|
|||||||
manageError.value = '请选择云型类别。'
|
manageError.value = '请选择云型类别。'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const hasLat = typeof value.latitude === 'number' && !Number.isNaN(value.latitude)
|
if (value.cloudCategoryId === 'other' && !value.customCloudType.trim()) {
|
||||||
const hasLng = typeof value.longitude === 'number' && !Number.isNaN(value.longitude)
|
manageError.value = '请输入自定义云型名称。'
|
||||||
if (!hasLat || !hasLng) {
|
|
||||||
manageError.value = '经纬度为必填项。'
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (value.description.trim().length > 128) {
|
if (!value.capturedAt) {
|
||||||
manageError.value = '图片描述不能超过 128 个字符。'
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,11 +225,13 @@ async function submitEditModal(value: CloudEditFormValue) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await profileStore.updateCloud(authStore.user.id, current.id, {
|
const updated = await profileStore.updateCloud(authStore.user.id, current.id, {
|
||||||
type_id: value.cloudCategoryId,
|
cloud_type_id: value.cloudCategoryId === 'other' ? null : value.cloudCategoryId,
|
||||||
latitude: blurCoordinate(value.latitude as number),
|
custom_cloud_type: value.cloudCategoryId === 'other' ? value.customCloudType.trim() : null,
|
||||||
longitude: blurCoordinate(value.longitude as number),
|
latitude: hasLat ? blurCoordinate(value.latitude as number) : null,
|
||||||
description: value.description.trim(),
|
longitude: hasLng ? blurCoordinate(value.longitude as number) : null,
|
||||||
...(value.capturedAt ? { captured_at: new Date(value.capturedAt).toISOString() } : {}),
|
location_name: value.locationName.trim() || null,
|
||||||
|
description: value.description.trim() || null,
|
||||||
|
captured_at: new Date(value.capturedAt).toISOString(),
|
||||||
is_hidden: !value.isPublic,
|
is_hidden: !value.isPublic,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -339,7 +342,7 @@ onUnmounted(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<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--wide oc-page-hero__inner">
|
||||||
<p class="oc-page-eyebrow">Community Gallery</p>
|
<p class="oc-page-eyebrow">Community Gallery</p>
|
||||||
<h1 class="oc-page-title">云图画廊</h1>
|
<h1 class="oc-page-title">云图画廊</h1>
|
||||||
<p class="oc-page-description">
|
<p class="oc-page-description">
|
||||||
@@ -348,9 +351,9 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="oc-container oc-container--content oc-page-content">
|
<div class="oc-container oc-container--wide oc-page-content">
|
||||||
<section>
|
<section>
|
||||||
<div class="oc-panel-card mb-4 border border-slate-200 bg-white p-3">
|
<div class="mb-4 border border-slate-200 bg-white p-3 shadow-[6px_6px_0_0_rgba(15,23,42,0.05)]">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<NIcon size="20" class="shrink-0 text-slate-400">
|
<NIcon size="20" class="shrink-0 text-slate-400">
|
||||||
<Search />
|
<Search />
|
||||||
@@ -359,12 +362,12 @@ onUnmounted(() => {
|
|||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
class="min-w-0 flex-1 bg-transparent text-sm text-slate-800 outline-none placeholder:text-slate-400"
|
class="min-w-0 flex-1 bg-transparent text-sm text-slate-800 outline-none placeholder:text-slate-400"
|
||||||
placeholder="输入完整云类型名称,或 @用户名进行精确搜索"
|
placeholder="搜索云型名称,或输入 @用户名 查看某个用户的图片"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="searchQuery"
|
v-if="searchQuery"
|
||||||
type="button"
|
type="button"
|
||||||
class="oc-icon-button oc-icon-button--small"
|
class="flex h-8 w-8 shrink-0 items-center justify-center text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-700"
|
||||||
title="清空搜索"
|
title="清空搜索"
|
||||||
aria-label="清空搜索"
|
aria-label="清空搜索"
|
||||||
@click="clearSearch"
|
@click="clearSearch"
|
||||||
@@ -379,7 +382,7 @@ onUnmounted(() => {
|
|||||||
正在按上传者搜索:{{ normalizedSearch.slice(1) || '请输入用户名' }}
|
正在按上传者搜索:{{ normalizedSearch.slice(1) || '请输入用户名' }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
正在精确匹配云型名称:{{ normalizedSearch }}
|
正在匹配云型名称和自定义云型:{{ normalizedSearch }}
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -427,7 +430,7 @@ onUnmounted(() => {
|
|||||||
:key="cloud.id"
|
:key="cloud.id"
|
||||||
type="button"
|
type="button"
|
||||||
@click="openDetail(cloud)"
|
@click="openDetail(cloud)"
|
||||||
class="oc-interactive-card group relative mb-3 block w-full break-inside-avoid overflow-hidden border border-slate-200 bg-slate-200 text-left transition-transform duration-300 hover:-translate-y-1"
|
class="group relative mb-3 block w-full break-inside-avoid overflow-hidden border border-slate-200 bg-slate-200 text-left shadow-sm transition-transform duration-300 hover:-translate-y-1 hover:shadow-lg"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
:src="cloud.thumbnail_url || cloud.image_url"
|
:src="cloud.thumbnail_url || cloud.image_url"
|
||||||
@@ -441,7 +444,7 @@ onUnmounted(() => {
|
|||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<p class="truncate text-sm font-semibold">{{ cloud.cloudTypeName }}</p>
|
<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">
|
<NTag size="small" :bordered="false" class="shrink-0 bg-white/12 text-white backdrop-blur">
|
||||||
稀有度 {{ cloud.cloudTypeRarity }}
|
{{ rarityMeta[cloud.cloudTypeRarity].label }}
|
||||||
</NTag>
|
</NTag>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-2 flex items-center gap-1.5 truncate text-xs text-white/82">
|
<p class="mt-2 flex items-center gap-1.5 truncate text-xs text-white/82">
|
||||||
@@ -460,7 +463,7 @@ onUnmounted(() => {
|
|||||||
<NIcon size="14">
|
<NIcon size="14">
|
||||||
<Location />
|
<Location />
|
||||||
</NIcon>
|
</NIcon>
|
||||||
<span class="truncate">{{ formatCoordinate(cloud.latitude) }}, {{ formatCoordinate(cloud.longitude) }}</span>
|
<span class="truncate">{{ cloud.location_name || '未填写位置' }}</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -475,11 +478,10 @@ onUnmounted(() => {
|
|||||||
</NEmpty>
|
</NEmpty>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div v-if="currentPage > 1 || hasNextPage" class="mt-8 flex items-center justify-center gap-2">
|
<div v-if="totalPages > 1" class="mt-8 flex items-center justify-center gap-2">
|
||||||
<NButton
|
<NButton
|
||||||
secondary
|
secondary
|
||||||
strong
|
strong
|
||||||
type="default"
|
|
||||||
:disabled="currentPage <= 1"
|
:disabled="currentPage <= 1"
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
@click="goToPage(currentPage - 1)"
|
@click="goToPage(currentPage - 1)"
|
||||||
@@ -487,13 +489,27 @@ onUnmounted(() => {
|
|||||||
上一页
|
上一页
|
||||||
</NButton>
|
</NButton>
|
||||||
|
|
||||||
<span class="border border-slate-200 bg-white px-4 py-2 text-sm text-slate-600">第 {{ currentPage }} 页</span>
|
<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
|
<NButton
|
||||||
secondary
|
secondary
|
||||||
strong
|
strong
|
||||||
type="default"
|
:disabled="currentPage >= totalPages"
|
||||||
:disabled="!hasNextPage"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
@click="goToPage(currentPage + 1)"
|
@click="goToPage(currentPage + 1)"
|
||||||
>
|
>
|
||||||
@@ -501,6 +517,10 @@ onUnmounted(() => {
|
|||||||
</NButton>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="totalCount > 0" class="mt-2 text-center text-xs text-slate-400">
|
||||||
|
共 {{ totalCount }} 张照片,第 {{ currentPage }} / {{ totalPages }} 页
|
||||||
|
</div>
|
||||||
|
|
||||||
<ImageDetailModal
|
<ImageDetailModal
|
||||||
v-if="selectedCloud"
|
v-if="selectedCloud"
|
||||||
:open="!!selectedCloud"
|
:open="!!selectedCloud"
|
||||||
@@ -509,8 +529,8 @@ onUnmounted(() => {
|
|||||||
:image-alt="selectedCloud.cloudTypeName"
|
:image-alt="selectedCloud.cloudTypeName"
|
||||||
:title="selectedCloud.cloudTypeName"
|
:title="selectedCloud.cloudTypeName"
|
||||||
:subtitle="`上传者:${selectedCloud.username}`"
|
:subtitle="`上传者:${selectedCloud.username}`"
|
||||||
:badge-label="`稀有度 ${selectedCloud.cloudTypeRarity}`"
|
:badge-label="rarityMeta[selectedCloud.cloudTypeRarity].label"
|
||||||
:badge-class="rarityChip"
|
:badge-class="rarityMeta[selectedCloud.cloudTypeRarity].chip"
|
||||||
@close="closeDetail"
|
@close="closeDetail"
|
||||||
>
|
>
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
@@ -524,7 +544,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="oc-inset-panel">
|
<div class="oc-inset-panel">
|
||||||
<p class="oc-section-label">位置</p>
|
<p class="oc-section-label">位置</p>
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">已记录经纬度</p>
|
<p class="mt-2 text-sm font-medium text-slate-900">{{ selectedCloud.location_name || '未填写位置名称' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="oc-inset-panel">
|
<div class="oc-inset-panel">
|
||||||
<p class="oc-section-label">模糊化经纬度</p>
|
<p class="oc-section-label">模糊化经纬度</p>
|
||||||
@@ -534,22 +554,16 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-5 border border-slate-200 bg-white p-5">
|
<div class="mt-5 border border-slate-200 bg-white p-5">
|
||||||
<p class="oc-section-label">图片说明</p>
|
<p class="oc-section-label">图片说明</p>
|
||||||
<p class="mt-3 text-sm leading-7 text-slate-700">
|
<p class="mt-3 text-sm leading-7 text-slate-700">
|
||||||
{{ selectedCloud.description || '上传者没有留下额外说明。' }}
|
{{ selectedCloud.description || '上传者没有留下额外说明。' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<MiniLocationMap
|
||||||
<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"
|
:latitude="selectedCloud.latitude"
|
||||||
:longitude="selectedCloud.longitude"
|
:longitude="selectedCloud.longitude"
|
||||||
|
:location-name="selectedCloud.location_name"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<template v-if="selectedCloudIsMine" #actions>
|
<template v-if="selectedCloudIsMine" #actions>
|
||||||
@@ -562,7 +576,7 @@ onUnmounted(() => {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="oc-icon-button"
|
class="flex h-10 w-10 items-center justify-center border border-slate-200 bg-slate-50 text-slate-600 transition-colors hover:border-slate-900 hover:text-slate-900"
|
||||||
title="图片管理"
|
title="图片管理"
|
||||||
aria-label="图片管理"
|
aria-label="图片管理"
|
||||||
>
|
>
|
||||||
|
|||||||
+52
-255
@@ -3,12 +3,12 @@ import { computed, ref, onMounted, onUnmounted } from 'vue'
|
|||||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||||
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
||||||
import QuickUploadModal from '@/components/cloud/QuickUploadModal.vue'
|
import QuickUploadModal from '@/components/cloud/QuickUploadModal.vue'
|
||||||
import { apiRequest, toApiCloud } from '@/lib/api'
|
import { apiRequest } from '@/lib/api'
|
||||||
import { loadAMap } from '@/lib/amap'
|
import { loadAMap } from '@/lib/amap'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import type { ApiCloud, BackendCloudInfo } from '@/types/api'
|
import type { ApiCloud } from '@/types/api'
|
||||||
import { NIcon } from 'naive-ui'
|
import { NIcon } from 'naive-ui'
|
||||||
import { Adjustments, Calendar, CloudUpload, Minus, Plus, Refresh, Map, Satellite, X } from '@vicons/tabler'
|
import { Adjustments, Calendar, CloudUpload, Refresh, Map, Satellite, X } from '@vicons/tabler'
|
||||||
|
|
||||||
interface CloudMarkerData {
|
interface CloudMarkerData {
|
||||||
id: string
|
id: string
|
||||||
@@ -16,9 +16,10 @@ interface CloudMarkerData {
|
|||||||
longitude: number
|
longitude: number
|
||||||
imageUrl: string
|
imageUrl: string
|
||||||
thumbnailUrl: string | null
|
thumbnailUrl: string | null
|
||||||
|
locationName: string | null
|
||||||
description: string | null
|
description: string | null
|
||||||
cloudTypeName: string
|
cloudTypeName: string
|
||||||
rarity: number
|
rarity: 'common' | 'uncommon' | 'rare'
|
||||||
username: string
|
username: string
|
||||||
capturedAt: string
|
capturedAt: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
@@ -39,15 +40,10 @@ const archiveMonth = ref(formatMonthInput(new Date()))
|
|||||||
const currentMinuteOfDay = ref(getMinuteOfDay(new Date()))
|
const currentMinuteOfDay = ref(getMinuteOfDay(new Date()))
|
||||||
const selectedMinuteOfDay = ref(currentMinuteOfDay.value)
|
const selectedMinuteOfDay = ref(currentMinuteOfDay.value)
|
||||||
const sliderDragging = ref(false)
|
const sliderDragging = ref(false)
|
||||||
const currentZoom = ref(5)
|
|
||||||
|
|
||||||
const VISIBLE_WINDOW_MS = 2 * 60 * 60 * 1000
|
const VISIBLE_WINDOW_MS = 2 * 60 * 60 * 1000
|
||||||
const MIN_MARKER_OPACITY = 0.3
|
const MIN_MARKER_OPACITY = 0.3
|
||||||
const HIDE_HEADER_EVENT = 'opencloud:hide-header'
|
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 AMapLib: typeof AMap | null = null
|
||||||
let mapInst: AMap.Map | null = null
|
let mapInst: AMap.Map | null = null
|
||||||
@@ -57,16 +53,18 @@ let mks: AMap.Marker[] = []
|
|||||||
let hoverIW: AMap.InfoWindow | null = null
|
let hoverIW: AMap.InfoWindow | null = null
|
||||||
let allClouds: CloudMarkerData[] = []
|
let allClouds: CloudMarkerData[] = []
|
||||||
let redrawTimer: number | null = null
|
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 markerBorder = '#64748b'
|
const rarityColors: Record<string, string> = {
|
||||||
const rarityChip = 'border-slate-200 bg-slate-50 text-slate-600'
|
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 selectedTimeLabel = computed(() => formatMinuteOfDay(selectedMinuteOfDay.value))
|
||||||
const currentMinutePercent = computed(() => Math.min(100, Math.max(0, currentMinuteOfDay.value / 1439 * 100)))
|
const currentMinutePercent = computed(() => Math.min(100, Math.max(0, currentMinuteOfDay.value / 1439 * 100)))
|
||||||
@@ -168,7 +166,7 @@ function getMarkerOpacity(cloud: CloudMarkerData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function bubbleContent(cloud: CloudMarkerData): HTMLElement {
|
function bubbleContent(cloud: CloudMarkerData): HTMLElement {
|
||||||
const border = markerBorder
|
const border = rarityColors[cloud.rarity] || rarityColors.common
|
||||||
const bubbleImage = cloud.thumbnailUrl || cloud.imageUrl
|
const bubbleImage = cloud.thumbnailUrl || cloud.imageUrl
|
||||||
const opacity = getMarkerOpacity(cloud) ?? MIN_MARKER_OPACITY
|
const opacity = getMarkerOpacity(cloud) ?? MIN_MARKER_OPACITY
|
||||||
|
|
||||||
@@ -189,9 +187,9 @@ function bubbleContent(cloud: CloudMarkerData): HTMLElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hoverCardContent(cloud: CloudMarkerData): HTMLElement {
|
function hoverCardContent(cloud: CloudMarkerData): HTMLElement {
|
||||||
const border = markerBorder
|
const border = rarityColors[cloud.rarity] || rarityColors.common
|
||||||
const cardImage = cloud.thumbnailUrl || cloud.imageUrl
|
const cardImage = cloud.thumbnailUrl || cloud.imageUrl
|
||||||
const label = `稀有度 ${cloud.rarity}`
|
const label = cloud.rarity === 'common' ? '常见' : cloud.rarity === 'uncommon' ? '少见' : '罕见'
|
||||||
|
|
||||||
const card = document.createElement('div')
|
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'
|
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'
|
||||||
@@ -208,15 +206,15 @@ function hoverCardContent(cloud: CloudMarkerData): HTMLElement {
|
|||||||
|
|
||||||
const name = document.createElement('span')
|
const name = document.createElement('span')
|
||||||
name.textContent = cloud.cloudTypeName
|
name.textContent = cloud.cloudTypeName
|
||||||
name.style.cssText = 'font-size:13px;font-weight:600;color:#1e293b'
|
name.style.cssText = 'font-size:13px;font-weight:600;color:#1f2937'
|
||||||
|
|
||||||
const rarity = document.createElement('span')
|
const rarity = document.createElement('span')
|
||||||
rarity.textContent = label
|
rarity.textContent = label
|
||||||
rarity.style.cssText = `font-size:10px;padding:1px 5px;border-radius:3px;color:${border};background:${border}22`
|
rarity.style.cssText = `font-size:10px;padding:1px 5px;border-radius:3px;color:${border};background:${border}22`
|
||||||
|
|
||||||
const meta = document.createElement('div')
|
const meta = document.createElement('div')
|
||||||
meta.textContent = `${cloud.username} · ${timeAgo(cloud.capturedAt)}`
|
meta.textContent = `📷 ${cloud.username} · ${timeAgo(cloud.capturedAt)}`
|
||||||
meta.style.cssText = 'font-size:11px;color:#94a3b8;margin-top:3px'
|
meta.style.cssText = 'font-size:11px;color:#9ca3af;margin-top:3px'
|
||||||
|
|
||||||
title.append(name, rarity)
|
title.append(name, rarity)
|
||||||
body.append(title, meta)
|
body.append(title, meta)
|
||||||
@@ -245,165 +243,6 @@ function hideHeader() {
|
|||||||
window.dispatchEvent(new CustomEvent(HIDE_HEADER_EVENT))
|
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 {
|
function toCloudMarker(row: ApiCloud): CloudMarkerData {
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
@@ -411,24 +250,25 @@ function toCloudMarker(row: ApiCloud): CloudMarkerData {
|
|||||||
longitude: row.longitude as number,
|
longitude: row.longitude as number,
|
||||||
imageUrl: row.image_url,
|
imageUrl: row.image_url,
|
||||||
thumbnailUrl: row.thumbnail_url,
|
thumbnailUrl: row.thumbnail_url,
|
||||||
|
locationName: row.location_name,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
cloudTypeName: row.cloud_type_name || '未知',
|
cloudTypeName: row.cloud_type_name || row.custom_cloud_type || '未知',
|
||||||
rarity: row.cloud_type_rarity_value ?? 0,
|
rarity: row.cloud_type_rarity || 'common',
|
||||||
username: row.username || '匿名',
|
username: row.username || '匿名',
|
||||||
capturedAt: row.captured_at || row.created_at,
|
capturedAt: row.captured_at || row.created_at,
|
||||||
createdAt: row.created_at,
|
createdAt: row.created_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCloudsByRange(field: 'captured_at' | 'uploaded_at', start: Date, end: Date): Promise<CloudMarkerData[]> {
|
async function fetchCloudsByRange(field: 'captured_at' | 'created_at', start: Date, end: Date): Promise<CloudMarkerData[]> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
start: start.toISOString(),
|
start: start.toISOString(),
|
||||||
end: end.toISOString(),
|
end: end.toISOString(),
|
||||||
time_field: field,
|
time_field: field,
|
||||||
})
|
})
|
||||||
try {
|
try {
|
||||||
const rows = await apiRequest<BackendCloudInfo[]>(`/cloud/map?${params}`, { auth: false })
|
const rows = await apiRequest<ApiCloud[]>(`/clouds/map?${params}`, { auth: false })
|
||||||
return rows.map(toApiCloud).map(toCloudMarker)
|
return rows.map(toCloudMarker)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
statusText.value = `查询失败: ${error instanceof Error ? error.message : '未知错误'}`
|
statusText.value = `查询失败: ${error instanceof Error ? error.message : '未知错误'}`
|
||||||
return []
|
return []
|
||||||
@@ -583,16 +423,14 @@ async function returnRealtime() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
window.addEventListener('blur', clearZoomHold)
|
|
||||||
try {
|
try {
|
||||||
AMapLib = await loadAMap()
|
AMapLib = await loadAMap()
|
||||||
AMapLib.getConfig().appname = 'amap-jsapi-skill'
|
|
||||||
mapInst = new AMapLib.Map(mapEl.value!, {
|
mapInst = new AMapLib.Map(mapEl.value!, {
|
||||||
viewMode: '3D',
|
viewMode: '3D',
|
||||||
pitch: 0,
|
pitch: 0,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
zoom: currentZoom.value,
|
zoom: 5,
|
||||||
zooms: [MIN_MAP_ZOOM, MAX_MAP_ZOOM],
|
zooms: [4, 18],
|
||||||
center: [104.07, 30.67],
|
center: [104.07, 30.67],
|
||||||
mapStyle: 'amap://styles/normal',
|
mapStyle: 'amap://styles/normal',
|
||||||
features: ['bg', 'road', 'building', 'point'],
|
features: ['bg', 'road', 'building', 'point'],
|
||||||
@@ -602,13 +440,12 @@ onMounted(async () => {
|
|||||||
} as AMap.MapOptions)
|
} as AMap.MapOptions)
|
||||||
|
|
||||||
mapInst.addControl(new AMapLib.Scale())
|
mapInst.addControl(new AMapLib.Scale())
|
||||||
|
mapInst.addControl(new AMapLib.ToolBar({ position: 'LT' } as Record<string, unknown>))
|
||||||
mapInst.addControl(new AMapLib.ControlBar({ position: { right: '50px', bottom: '224px' } } as Record<string, unknown>))
|
mapInst.addControl(new AMapLib.ControlBar({ position: { right: '50px', bottom: '224px' } } as Record<string, unknown>))
|
||||||
mapInst.on('click', () => { previewCloud.value = null; hideHoverCard() })
|
mapInst.on('click', () => { previewCloud.value = null; hideHoverCard() })
|
||||||
mapInst.on('zoomstart', hideHeader)
|
mapInst.on('zoomstart', hideHeader)
|
||||||
mapInst.on('zoomchange', syncCurrentZoom)
|
|
||||||
mapInst.on('movestart', hideHeader)
|
mapInst.on('movestart', hideHeader)
|
||||||
mapInst.on('dragstart', hideHeader)
|
mapInst.on('dragstart', hideHeader)
|
||||||
syncCurrentZoom()
|
|
||||||
await refresh()
|
await refresh()
|
||||||
startMarkerDecayTimer()
|
startMarkerDecayTimer()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -617,13 +454,10 @@ onMounted(async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('blur', clearZoomHold)
|
|
||||||
clearZoomHold()
|
|
||||||
if (redrawTimer !== null) {
|
if (redrawTimer !== null) {
|
||||||
window.clearInterval(redrawTimer)
|
window.clearInterval(redrawTimer)
|
||||||
redrawTimer = null
|
redrawTimer = null
|
||||||
}
|
}
|
||||||
mapInst?.off('zoomchange', syncCurrentZoom)
|
|
||||||
mapInst?.destroy()
|
mapInst?.destroy()
|
||||||
mapInst = null
|
mapInst = null
|
||||||
AMapLib = null
|
AMapLib = null
|
||||||
@@ -634,45 +468,11 @@ onUnmounted(() => {
|
|||||||
<div class="relative h-[100dvh] min-h-screen">
|
<div class="relative h-[100dvh] min-h-screen">
|
||||||
<div ref="mapEl" class="w-full h-full"></div>
|
<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">
|
<div class="absolute bottom-6 right-7 z-20 flex w-10 flex-col items-center gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="oc-map-button"
|
class="w-10 h-10 rounded-lg shadow-md flex items-center justify-center"
|
||||||
|
:class="authStore.loading || !authStore.isLoggedIn ? 'cursor-not-allowed bg-slate-100 text-slate-300 shadow-none' : 'bg-white hover:bg-slate-50'"
|
||||||
:title="authStore.isLoggedIn ? '快速上传图片' : '请先登录后上传图片'"
|
:title="authStore.isLoggedIn ? '快速上传图片' : '请先登录后上传图片'"
|
||||||
aria-label="快速上传图片"
|
aria-label="快速上传图片"
|
||||||
:disabled="authStore.loading || !authStore.isLoggedIn"
|
:disabled="authStore.loading || !authStore.isLoggedIn"
|
||||||
@@ -682,14 +482,14 @@ onUnmounted(() => {
|
|||||||
<CloudUpload />
|
<CloudUpload />
|
||||||
</NIcon>
|
</NIcon>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" @click="refresh" class="oc-map-button" title="刷新" aria-label="刷新地图">
|
<button @click="refresh" class="oc-map-button" title="刷新">
|
||||||
<span class="text-lg">
|
<span class="text-lg">
|
||||||
<NIcon>
|
<NIcon>
|
||||||
<Refresh/>
|
<Refresh/>
|
||||||
</NIcon>
|
</NIcon>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" @click="toggleSat" class="oc-map-button" :title="satelliteOn ? '切换普通视图' : '切换卫星视图'" :aria-label="satelliteOn ? '切换普通视图' : '切换卫星视图'">
|
<button @click="toggleSat" class="oc-map-button" :title="satelliteOn ? '切换普通视图' : '切换卫星视图'">
|
||||||
<NIcon size="20" style="display: inline-flex; vertical-align: middle;">
|
<NIcon size="20" style="display: inline-flex; vertical-align: middle;">
|
||||||
<Map v-if="satelliteOn" />
|
<Map v-if="satelliteOn" />
|
||||||
<Satellite v-else />
|
<Satellite v-else />
|
||||||
@@ -716,7 +516,7 @@ onUnmounted(() => {
|
|||||||
<div class="relative w-[min(42vw,420px)] min-w-[220px]">
|
<div class="relative w-[min(42vw,420px)] min-w-[220px]">
|
||||||
<div
|
<div
|
||||||
v-if="sliderDragging"
|
v-if="sliderDragging"
|
||||||
class="oc-panel-card absolute bottom-8 border border-slate-200 bg-white/95 px-3 py-1.5 text-xs font-medium text-slate-800 backdrop-blur"
|
class="absolute bottom-8 border border-slate-200 bg-white/95 px-3 py-1.5 text-xs font-medium text-slate-800 shadow-md backdrop-blur"
|
||||||
:style="{ left: `${selectedMinutePercent}%`, transform: 'translateX(-50%)' }"
|
:style="{ left: `${selectedMinutePercent}%`, transform: 'translateX(-50%)' }"
|
||||||
>
|
>
|
||||||
{{ selectedTimeLabel }}
|
{{ selectedTimeLabel }}
|
||||||
@@ -761,29 +561,27 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="archivePanelOpen"
|
v-if="archivePanelOpen"
|
||||||
class="oc-panel-card absolute bottom-16 right-0 w-72 border border-slate-200 bg-white p-4"
|
class="absolute bottom-16 right-0 w-72 border border-slate-200 bg-white p-4 shadow-[8px_8px_0_0_rgba(15,23,42,0.08)]"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between gap-2">
|
<div class="flex items-center justify-between gap-2">
|
||||||
<p class="text-sm font-semibold text-slate-900">历史位置</p>
|
<p class="text-sm font-semibold text-slate-900">历史位置</p>
|
||||||
<button type="button" class="oc-text-button text-xs" @click="returnRealtime">
|
<button type="button" class="text-xs font-medium text-sky-700 hover:text-sky-900" @click="returnRealtime">
|
||||||
返回实时
|
返回实时
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 grid grid-cols-2 gap-2">
|
<div class="mt-4 grid grid-cols-2 gap-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="oc-choice-button"
|
class="border px-3 py-2 text-sm font-medium transition-colors"
|
||||||
:class="archiveKind === 'day' ? 'oc-choice-button--active' : 'oc-choice-button--inactive'"
|
:class="archiveKind === 'day' ? 'border-teal-200 bg-teal-50 text-teal-800' : 'border-slate-200 bg-white text-slate-600 hover:bg-slate-50'"
|
||||||
:aria-pressed="archiveKind === 'day'"
|
|
||||||
@click="archiveKind = 'day'"
|
@click="archiveKind = 'day'"
|
||||||
>
|
>
|
||||||
某天
|
某天
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="oc-choice-button"
|
class="border px-3 py-2 text-sm font-medium transition-colors"
|
||||||
:class="archiveKind === 'month' ? 'oc-choice-button--active' : 'oc-choice-button--inactive'"
|
:class="archiveKind === 'month' ? 'border-teal-200 bg-teal-50 text-teal-800' : 'border-slate-200 bg-white text-slate-600 hover:bg-slate-50'"
|
||||||
:aria-pressed="archiveKind === 'month'"
|
|
||||||
@click="archiveKind = 'month'"
|
@click="archiveKind = 'month'"
|
||||||
>
|
>
|
||||||
某月
|
某月
|
||||||
@@ -805,7 +603,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="oc-panel-button oc-panel-button--sky mt-3 w-full"
|
class="mt-3 w-full border border-sky-200 bg-sky-100 px-3 py-2 text-sm font-medium text-sky-800 shadow-[4px_4px_0_0_rgba(14,165,233,0.14)] transition-colors hover:bg-sky-50"
|
||||||
@click="applyArchiveFilter"
|
@click="applyArchiveFilter"
|
||||||
>
|
>
|
||||||
查看拍摄位置
|
查看拍摄位置
|
||||||
@@ -825,26 +623,24 @@ onUnmounted(() => {
|
|||||||
:image-alt="previewCloud.cloudTypeName"
|
:image-alt="previewCloud.cloudTypeName"
|
||||||
:title="previewCloud.cloudTypeName"
|
:title="previewCloud.cloudTypeName"
|
||||||
:subtitle="`上传者:${previewCloud.username}`"
|
:subtitle="`上传者:${previewCloud.username}`"
|
||||||
:badge-label="`稀有度 ${previewCloud.rarity}`"
|
:badge-label="rarityMeta[previewCloud.rarity].label"
|
||||||
:badge-class="rarityChip"
|
:badge-class="rarityMeta[previewCloud.rarity].chip"
|
||||||
@close="previewCloud = null"
|
@close="previewCloud = null"
|
||||||
>
|
>
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<div class="oc-inset-panel">
|
<div class="rounded-2xl bg-slate-50 p-4">
|
||||||
<p class="oc-section-label">上传时间</p>
|
<p class="oc-section-label">上传时间</p>
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(previewCloud.createdAt) }}</p>
|
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(previewCloud.createdAt) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="oc-inset-panel">
|
<div class="rounded-2xl bg-slate-50 p-4">
|
||||||
<p class="oc-section-label">拍摄时间</p>
|
<p class="oc-section-label">拍摄时间</p>
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(previewCloud.capturedAt) }}</p>
|
<p class="mt-2 text-sm font-medium text-slate-900">{{ formatDateTime(previewCloud.capturedAt) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="oc-inset-panel">
|
<div class="rounded-2xl bg-slate-50 p-4">
|
||||||
<p class="oc-section-label">位置</p>
|
<p class="oc-section-label">位置</p>
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">
|
<p class="mt-2 text-sm font-medium text-slate-900">{{ previewCloud.locationName || '未填写位置名称' }}</p>
|
||||||
纬度 {{ formatCoordinate(previewCloud.latitude) }} / 经度 {{ formatCoordinate(previewCloud.longitude) }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="oc-inset-panel">
|
<div class="rounded-2xl bg-slate-50 p-4">
|
||||||
<p class="oc-section-label">模糊化经纬度</p>
|
<p class="oc-section-label">模糊化经纬度</p>
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">
|
<p class="mt-2 text-sm font-medium text-slate-900">
|
||||||
纬度 {{ formatCoordinate(previewCloud.latitude) }} / 经度 {{ formatCoordinate(previewCloud.longitude) }}
|
纬度 {{ formatCoordinate(previewCloud.latitude) }} / 经度 {{ formatCoordinate(previewCloud.longitude) }}
|
||||||
@@ -852,7 +648,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-5 border border-slate-200 bg-white p-5">
|
<div class="mt-5 rounded-[28px] border border-slate-200 bg-white p-5">
|
||||||
<p class="oc-section-label">图片说明</p>
|
<p class="oc-section-label">图片说明</p>
|
||||||
<p class="mt-3 text-sm leading-7 text-slate-700">
|
<p class="mt-3 text-sm leading-7 text-slate-700">
|
||||||
{{ previewCloud.description || '上传者没有留下额外说明。' }}
|
{{ previewCloud.description || '上传者没有留下额外说明。' }}
|
||||||
@@ -861,6 +657,7 @@ onUnmounted(() => {
|
|||||||
<MiniLocationMap
|
<MiniLocationMap
|
||||||
:latitude="previewCloud.latitude"
|
:latitude="previewCloud.latitude"
|
||||||
:longitude="previewCloud.longitude"
|
:longitude="previewCloud.longitude"
|
||||||
|
:location-name="previewCloud.locationName"
|
||||||
/>
|
/>
|
||||||
</ImageDetailModal>
|
</ImageDetailModal>
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { RouterLink, useRouter } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import { NAlert, NButton, NCard, NForm, NInput, useMessage } from 'naive-ui'
|
import { NAlert, NButton, NCard, NInput, useMessage } from 'naive-ui'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const router = useRouter()
|
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
const usernameDraft = ref('')
|
const usernameDraft = ref('')
|
||||||
const currentPassword = ref('')
|
|
||||||
const newPassword = ref('')
|
const newPassword = ref('')
|
||||||
const confirmNewPassword = ref('')
|
const confirmNewPassword = ref('')
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
@@ -31,8 +29,8 @@ async function saveUsername() {
|
|||||||
error.value = '昵称不能为空。'
|
error.value = '昵称不能为空。'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (nextUsername.length > 16) {
|
if (nextUsername.length < 2) {
|
||||||
error.value = '昵称不能超过 16 个字符。'
|
error.value = '昵称至少需要 2 个字符。'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (nextUsername === authStore.profile?.username) {
|
if (nextUsername === authStore.profile?.username) {
|
||||||
@@ -64,19 +62,14 @@ async function savePassword() {
|
|||||||
error.value = '两次输入的新密码不一致。'
|
error.value = '两次输入的新密码不一致。'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!currentPassword.value) {
|
|
||||||
error.value = '请输入当前密码。'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
passwordSaving.value = true
|
passwordSaving.value = true
|
||||||
try {
|
try {
|
||||||
await authStore.updatePassword(currentPassword.value, newPassword.value)
|
await authStore.updatePassword(newPassword.value)
|
||||||
currentPassword.value = ''
|
|
||||||
newPassword.value = ''
|
newPassword.value = ''
|
||||||
confirmNewPassword.value = ''
|
confirmNewPassword.value = ''
|
||||||
message.success('密码已更新,请重新登录')
|
success.value = '密码已更新。'
|
||||||
await router.push('/login')
|
message.success('密码已更新')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : '密码更新失败'
|
error.value = e instanceof Error ? e.message : '密码更新失败'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -86,8 +79,8 @@ async function savePassword() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)] py-10">
|
<div class="min-h-[calc(100vh-4rem)] bg-[linear-gradient(180deg,#e0f2fe_0%,#f8fafc_100%)] px-4 py-10">
|
||||||
<div class="oc-container oc-container--content">
|
<div class="mx-auto max-w-4xl">
|
||||||
<RouterLink to="/profile">
|
<RouterLink to="/profile">
|
||||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">← 返回个人主页</NButton>
|
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--neutral">← 返回个人主页</NButton>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
@@ -96,7 +89,7 @@ async function savePassword() {
|
|||||||
<p class="oc-page-eyebrow">Account Settings</p>
|
<p class="oc-page-eyebrow">Account Settings</p>
|
||||||
<h1 class="oc-page-title">个人资料设置</h1>
|
<h1 class="oc-page-title">个人资料设置</h1>
|
||||||
<p class="oc-page-description">
|
<p class="oc-page-description">
|
||||||
修改公开昵称和登录密码。昵称会显示在画廊、百科和地图中,且不能与其他用户重复。
|
修改公开昵称和登录密码。昵称会显示在画廊、图鉴和地图中,且不能与其他用户重复。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -118,7 +111,7 @@ async function savePassword() {
|
|||||||
{{ success }}
|
{{ success }}
|
||||||
</NAlert>
|
</NAlert>
|
||||||
|
|
||||||
<NCard class="oc-panel-card border border-slate-200 bg-white" :bordered="false">
|
<NCard class="oc-panel-card border border-slate-200 bg-white shadow-sm" :bordered="false">
|
||||||
<div class="grid gap-5 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
|
<div class="grid gap-5 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold text-slate-900">公开昵称</h2>
|
<h2 class="text-2xl font-bold text-slate-900">公开昵称</h2>
|
||||||
@@ -130,7 +123,7 @@ async function savePassword() {
|
|||||||
<NInput
|
<NInput
|
||||||
v-model:value="usernameDraft"
|
v-model:value="usernameDraft"
|
||||||
placeholder="输入新的昵称"
|
placeholder="输入新的昵称"
|
||||||
maxlength="16"
|
maxlength="32"
|
||||||
show-count
|
show-count
|
||||||
/>
|
/>
|
||||||
<NButton
|
<NButton
|
||||||
@@ -147,7 +140,7 @@ async function savePassword() {
|
|||||||
</div>
|
</div>
|
||||||
</NCard>
|
</NCard>
|
||||||
|
|
||||||
<NCard class="oc-panel-card border border-slate-200 bg-white" :bordered="false">
|
<NCard class="oc-panel-card border border-slate-200 bg-white shadow-sm" :bordered="false">
|
||||||
<div class="grid gap-5 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
|
<div class="grid gap-5 lg:grid-cols-[0.8fr_1.2fr] lg:items-start">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold text-slate-900">登录密码</h2>
|
<h2 class="text-2xl font-bold text-slate-900">登录密码</h2>
|
||||||
@@ -155,63 +148,33 @@ async function savePassword() {
|
|||||||
修改后下次登录需要使用新密码。建议使用至少 8 位且不易猜测的密码。
|
修改后下次登录需要使用新密码。建议使用至少 8 位且不易猜测的密码。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NForm class="grid gap-3" @submit.prevent="savePassword">
|
<div class="grid gap-3">
|
||||||
<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
|
<NInput
|
||||||
v-model:value="newPassword"
|
v-model:value="newPassword"
|
||||||
type="password"
|
type="password"
|
||||||
show-password-on="click"
|
show-password-on="click"
|
||||||
:input-props="{
|
autocomplete="new-password"
|
||||||
autocomplete: 'new-password',
|
|
||||||
name: 'new-password',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
placeholder="新密码"
|
placeholder="新密码"
|
||||||
/>
|
/>
|
||||||
<NInput
|
<NInput
|
||||||
v-model:value="confirmNewPassword"
|
v-model:value="confirmNewPassword"
|
||||||
type="password"
|
type="password"
|
||||||
show-password-on="click"
|
show-password-on="click"
|
||||||
:input-props="{
|
autocomplete="new-password"
|
||||||
autocomplete: 'new-password',
|
|
||||||
name: 'confirm-new-password',
|
|
||||||
required: true,
|
|
||||||
}"
|
|
||||||
placeholder="确认新密码"
|
placeholder="确认新密码"
|
||||||
/>
|
/>
|
||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<NButton
|
<NButton
|
||||||
secondary
|
secondary
|
||||||
strong
|
strong
|
||||||
type="default"
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
attr-type="submit"
|
|
||||||
class="oc-panel-button oc-panel-button--teal"
|
|
||||||
:loading="passwordSaving"
|
:loading="passwordSaving"
|
||||||
|
@click="savePassword"
|
||||||
>
|
>
|
||||||
保存密码
|
保存密码
|
||||||
</NButton>
|
</NButton>
|
||||||
</div>
|
</div>
|
||||||
</NForm>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</NCard>
|
</NCard>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+220
-149
@@ -2,6 +2,7 @@
|
|||||||
import { computed, nextTick, ref, watch } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import { RouterLink, useRoute } from 'vue-router'
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
import { NAlert, NButton, NCard, NDropdown, NEmpty, NIcon, NSkeleton, useMessage } from 'naive-ui'
|
import { NAlert, NButton, NCard, NDropdown, NEmpty, NIcon, NSkeleton, useMessage } from 'naive-ui'
|
||||||
|
import EncyclopediaProgressCard from '@/components/cloud/EncyclopediaProgressCard.vue'
|
||||||
import { Settings } from '@vicons/tabler'
|
import { Settings } from '@vicons/tabler'
|
||||||
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
|
import CloudEditModal, { type CloudEditFormValue } from '@/components/cloud/CloudEditModal.vue'
|
||||||
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
import ImageDetailModal from '@/components/cloud/ImageDetailModal.vue'
|
||||||
@@ -9,8 +10,9 @@ import MiniLocationMap from '@/components/cloud/MiniLocationMap.vue'
|
|||||||
import ContributionHeatmap from '@/components/profile/ContributionHeatmap.vue'
|
import ContributionHeatmap from '@/components/profile/ContributionHeatmap.vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { useCloudsStore } from '@/stores/clouds'
|
import { useCloudsStore } from '@/stores/clouds'
|
||||||
|
import { useEncyclopediaStore } from '@/stores/encyclopedia'
|
||||||
import { useProfileStore, type ProfileCloudItem } from '@/stores/profile'
|
import { useProfileStore, type ProfileCloudItem } from '@/stores/profile'
|
||||||
import type { Profile } from '@/types/database'
|
import type { CloudType, Profile } from '@/types/database'
|
||||||
|
|
||||||
interface TimelineGroup {
|
interface TimelineGroup {
|
||||||
key: string
|
key: string
|
||||||
@@ -21,6 +23,7 @@ interface TimelineGroup {
|
|||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const cloudsStore = useCloudsStore()
|
const cloudsStore = useCloudsStore()
|
||||||
|
const encyclopediaStore = useEncyclopediaStore()
|
||||||
const profileStore = useProfileStore()
|
const profileStore = useProfileStore()
|
||||||
const message = useMessage()
|
const message = useMessage()
|
||||||
|
|
||||||
@@ -34,7 +37,11 @@ const editSaving = ref(false)
|
|||||||
const manageError = ref('')
|
const manageError = ref('')
|
||||||
const editInitialValue = ref<CloudEditFormValue | null>(null)
|
const editInitialValue = ref<CloudEditFormValue | null>(null)
|
||||||
|
|
||||||
const rarityChip = 'border-slate-200 bg-slate-50 text-slate-600'
|
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 }>
|
||||||
|
|
||||||
const statusMeta = {
|
const statusMeta = {
|
||||||
approved: { label: '已通过', chip: 'bg-emerald-100 text-emerald-700 border-emerald-200' },
|
approved: { label: '已通过', chip: 'bg-emerald-100 text-emerald-700 border-emerald-200' },
|
||||||
@@ -42,32 +49,44 @@ const statusMeta = {
|
|||||||
rejected: { label: '未通过', chip: 'bg-rose-100 text-rose-700 border-rose-200' },
|
rejected: { label: '未通过', chip: 'bg-rose-100 text-rose-700 border-rose-200' },
|
||||||
} satisfies Record<ProfileCloudItem['status'], { label: string; chip: string }>
|
} satisfies Record<ProfileCloudItem['status'], { label: string; chip: string }>
|
||||||
|
|
||||||
const viewedIdentifier = computed(() => {
|
const viewedUserId = computed(() => {
|
||||||
const routeUsername = typeof route.params.id === 'string' ? route.params.id : null
|
const routeUserId = typeof route.params.id === 'string' ? route.params.id : null
|
||||||
return routeUsername || authStore.user?.id || null
|
return routeUserId || authStore.user?.id || null
|
||||||
})
|
})
|
||||||
|
|
||||||
const isOwnProfile = computed(() => typeof route.params.id !== 'string')
|
const isOwnProfile = computed(() => {
|
||||||
|
return !!viewedUserId.value && viewedUserId.value === authStore.user?.id
|
||||||
|
})
|
||||||
|
|
||||||
const loading = computed(() => profileStore.isLoading(viewedIdentifier.value, isOwnProfile.value))
|
const loading = computed(() => profileStore.isLoading(viewedUserId.value, isOwnProfile.value))
|
||||||
const errorMsg = computed(() => {
|
const errorMsg = computed(() => {
|
||||||
if (!viewedIdentifier.value) return '未找到要查看的用户。'
|
if (!viewedUserId.value) return '未找到要查看的用户。'
|
||||||
return profileStore.getError(viewedIdentifier.value, isOwnProfile.value)
|
return profileStore.getError(viewedUserId.value, isOwnProfile.value)
|
||||||
})
|
})
|
||||||
const profileData = computed<Profile | null>(() => profileStore.getProfile(viewedIdentifier.value))
|
const profileData = computed<Profile | null>(() => profileStore.getProfile(viewedUserId.value))
|
||||||
const clouds = computed<ProfileCloudItem[]>(() => profileStore.getClouds(viewedIdentifier.value, isOwnProfile.value))
|
const clouds = computed<ProfileCloudItem[]>(() => profileStore.getClouds(viewedUserId.value, isOwnProfile.value))
|
||||||
const receivedLikes = computed(() => isOwnProfile.value ? authStore.user?.received_like_count || 0 : 0)
|
|
||||||
|
|
||||||
const pageTitle = computed(() => {
|
const pageTitle = computed(() => {
|
||||||
return isOwnProfile.value ? '我的天空日志' : `${profileData.value?.username || '这位用户'}的天空日志`
|
return isOwnProfile.value ? '我的天空日志' : `${profileData.value?.username || '这位用户'}的天空日志`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// const profileSubtitle = computed(() => {
|
||||||
|
// if (isOwnProfile.value) {
|
||||||
|
// return '回看你的云图记录、收集进度和拍摄节奏。'
|
||||||
|
// }
|
||||||
|
// return '公开展示的云图记录会按时间顺序陈列在这里。'
|
||||||
|
// })
|
||||||
|
|
||||||
const totalShots = computed(() => clouds.value.length)
|
const totalShots = computed(() => clouds.value.length)
|
||||||
|
|
||||||
const approvedShots = computed(() => {
|
const approvedShots = computed(() => {
|
||||||
return clouds.value.filter(item => item.status === 'approved').length
|
return clouds.value.filter(item => item.status === 'approved').length
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const pendingShots = computed(() => {
|
||||||
|
return clouds.value.filter(item => item.status === 'pending').length
|
||||||
|
})
|
||||||
|
|
||||||
const selectedCloudCount = computed(() => selectedCloudIds.value.size)
|
const selectedCloudCount = computed(() => selectedCloudIds.value.size)
|
||||||
|
|
||||||
const cloudActionOptions = computed(() => {
|
const cloudActionOptions = computed(() => {
|
||||||
@@ -165,6 +184,44 @@ const activeUploadDays = computed(() => {
|
|||||||
return contributionCells.value.filter(cell => cell.count > 0).length
|
return contributionCells.value.filter(cell => cell.count > 0).length
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const currentUploadStreak = computed(() => {
|
||||||
|
let streak = 0
|
||||||
|
const cursor = startOfDay(new Date())
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const key = toLocalDayKey(cursor)
|
||||||
|
if ((uploadDayCounts.value.get(key) || 0) <= 0) break
|
||||||
|
streak += 1
|
||||||
|
cursor.setDate(cursor.getDate() - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return streak
|
||||||
|
})
|
||||||
|
|
||||||
|
const longestUploadStreak = computed(() => {
|
||||||
|
let best = 0
|
||||||
|
let current = 0
|
||||||
|
let previousDate: Date | null = null
|
||||||
|
|
||||||
|
for (const cell of contributionCells.value) {
|
||||||
|
if (cell.count <= 0) continue
|
||||||
|
|
||||||
|
const currentDate = startOfDay(new Date(cell.date))
|
||||||
|
|
||||||
|
if (!previousDate) {
|
||||||
|
current = 1
|
||||||
|
} else {
|
||||||
|
const diffDays = Math.round((currentDate.getTime() - previousDate.getTime()) / 86400000)
|
||||||
|
current = diffDays === 1 ? current + 1 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current > best) best = current
|
||||||
|
previousDate = currentDate
|
||||||
|
}
|
||||||
|
|
||||||
|
return best
|
||||||
|
})
|
||||||
|
|
||||||
function toDayKey(iso: string) {
|
function toDayKey(iso: string) {
|
||||||
const date = new Date(iso)
|
const date = new Date(iso)
|
||||||
return toLocalDayKey(date)
|
return toLocalDayKey(date)
|
||||||
@@ -176,6 +233,10 @@ function toLocalDayKey(date: Date) {
|
|||||||
return `${date.getFullYear()}-${month}-${day}`
|
return `${date.getFullYear()}-${month}-${day}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startOfDay(date: Date) {
|
||||||
|
return new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(iso: string | null) {
|
function formatDate(iso: string | null) {
|
||||||
if (!iso) return '未知时间'
|
if (!iso) return '未知时间'
|
||||||
return new Date(iso).toLocaleDateString('zh-CN', {
|
return new Date(iso).toLocaleDateString('zh-CN', {
|
||||||
@@ -211,9 +272,7 @@ function formatCoordinate(value: number | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatCloudTime(item: ProfileCloudItem) {
|
function formatCloudTime(item: ProfileCloudItem) {
|
||||||
return item.captured_at
|
return formatDateTime(item.captured_at || item.created_at)
|
||||||
? `拍摄于 ${formatDateTime(item.captured_at)}`
|
|
||||||
: `上传于 ${formatDateTime(item.created_at)}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProfileInitial(profile: Profile | null) {
|
function getProfileInitial(profile: Profile | null) {
|
||||||
@@ -263,10 +322,12 @@ function formatDatetimeLocal(iso: string | null) {
|
|||||||
function openEditModal(cloud: ProfileCloudItem) {
|
function openEditModal(cloud: ProfileCloudItem) {
|
||||||
manageError.value = ''
|
manageError.value = ''
|
||||||
editInitialValue.value = {
|
editInitialValue.value = {
|
||||||
cloudCategoryId: cloud.cloud_type_id,
|
cloudCategoryId: cloud.cloud_type_id ?? (cloud.custom_cloud_type ? 'other' : null),
|
||||||
|
customCloudType: cloud.custom_cloud_type || '',
|
||||||
capturedAt: formatDatetimeLocal(cloud.captured_at),
|
capturedAt: formatDatetimeLocal(cloud.captured_at),
|
||||||
latitude: cloud.latitude,
|
latitude: cloud.latitude,
|
||||||
longitude: cloud.longitude,
|
longitude: cloud.longitude,
|
||||||
|
locationName: cloud.location_name || '',
|
||||||
description: cloud.description || '',
|
description: cloud.description || '',
|
||||||
isPublic: !cloud.is_hidden,
|
isPublic: !cloud.is_hidden,
|
||||||
}
|
}
|
||||||
@@ -284,20 +345,24 @@ function blurCoordinate(value: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function submitEditModal(value: CloudEditFormValue) {
|
async function submitEditModal(value: CloudEditFormValue) {
|
||||||
if (!selectedCloud.value || !viewedIdentifier.value) return
|
if (!selectedCloud.value || !viewedUserId.value) return
|
||||||
|
|
||||||
if (!value.cloudCategoryId) {
|
if (!value.cloudCategoryId) {
|
||||||
manageError.value = '请选择云型类别。'
|
manageError.value = '请选择云型类别。'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const hasLat = typeof value.latitude === 'number' && !Number.isNaN(value.latitude)
|
if (value.cloudCategoryId === 'other' && !value.customCloudType.trim()) {
|
||||||
const hasLng = typeof value.longitude === 'number' && !Number.isNaN(value.longitude)
|
manageError.value = '请输入自定义云型名称。'
|
||||||
if (!hasLat || !hasLng) {
|
|
||||||
manageError.value = '经纬度为必填项。'
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (value.description.trim().length > 128) {
|
if (!value.capturedAt) {
|
||||||
manageError.value = '图片描述不能超过 128 个字符。'
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,12 +370,14 @@ async function submitEditModal(value: CloudEditFormValue) {
|
|||||||
manageError.value = ''
|
manageError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updated = await profileStore.updateCloud(viewedIdentifier.value, selectedCloud.value.id, {
|
const updated = await profileStore.updateCloud(viewedUserId.value, selectedCloud.value.id, {
|
||||||
type_id: value.cloudCategoryId,
|
cloud_type_id: value.cloudCategoryId === 'other' ? null : value.cloudCategoryId,
|
||||||
latitude: blurCoordinate(value.latitude as number),
|
custom_cloud_type: value.cloudCategoryId === 'other' ? value.customCloudType.trim() : null,
|
||||||
longitude: blurCoordinate(value.longitude as number),
|
latitude: hasLat ? blurCoordinate(value.latitude as number) : null,
|
||||||
description: value.description.trim(),
|
longitude: hasLng ? blurCoordinate(value.longitude as number) : null,
|
||||||
...(value.capturedAt ? { captured_at: new Date(value.capturedAt).toISOString() } : {}),
|
location_name: value.locationName.trim() || null,
|
||||||
|
description: value.description.trim() || null,
|
||||||
|
captured_at: new Date(value.capturedAt).toISOString(),
|
||||||
is_hidden: !value.isPublic,
|
is_hidden: !value.isPublic,
|
||||||
})
|
})
|
||||||
selectedCloud.value = updated
|
selectedCloud.value = updated
|
||||||
@@ -323,13 +390,13 @@ async function submitEditModal(value: CloudEditFormValue) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function toggleSelectedCloudPrivacy() {
|
async function toggleSelectedCloudPrivacy() {
|
||||||
if (!selectedCloud.value || !viewedIdentifier.value) return
|
if (!selectedCloud.value || !viewedUserId.value) return
|
||||||
|
|
||||||
manageError.value = ''
|
manageError.value = ''
|
||||||
const nextHidden = !selectedCloud.value.is_hidden
|
const nextHidden = !selectedCloud.value.is_hidden
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await profileStore.updateCloudVisibility(viewedIdentifier.value, selectedCloud.value.id, nextHidden)
|
await profileStore.updateCloudVisibility(viewedUserId.value, selectedCloud.value.id, nextHidden)
|
||||||
selectedCloud.value = { ...selectedCloud.value, is_hidden: nextHidden }
|
selectedCloud.value = { ...selectedCloud.value, is_hidden: nextHidden }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
manageError.value = error instanceof Error ? error.message : '私密状态更新失败'
|
manageError.value = error instanceof Error ? error.message : '私密状态更新失败'
|
||||||
@@ -337,14 +404,14 @@ async function toggleSelectedCloudPrivacy() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteCloudByIds(cloudIds: string[]) {
|
async function deleteCloudByIds(cloudIds: string[]) {
|
||||||
if (!viewedIdentifier.value || !cloudIds.length) return
|
if (!viewedUserId.value || !cloudIds.length) return
|
||||||
const confirmed = window.confirm(`确定删除 ${cloudIds.length} 张图片吗?删除后无法在页面中恢复。`)
|
const confirmed = window.confirm(`确定删除 ${cloudIds.length} 张图片吗?删除后无法在页面中恢复。`)
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
|
||||||
manageError.value = ''
|
manageError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const deletedCount = await profileStore.deleteClouds(viewedIdentifier.value, cloudIds)
|
const deletedCount = await profileStore.deleteClouds(viewedUserId.value, cloudIds)
|
||||||
if (selectedCloud.value && cloudIds.includes(selectedCloud.value.id)) {
|
if (selectedCloud.value && cloudIds.includes(selectedCloud.value.id)) {
|
||||||
selectedCloud.value = null
|
selectedCloud.value = null
|
||||||
closeEditModal()
|
closeEditModal()
|
||||||
@@ -371,16 +438,18 @@ async function loadProfilePage(force = false) {
|
|||||||
clearCloudSelection()
|
clearCloudSelection()
|
||||||
selectionMode.value = false
|
selectionMode.value = false
|
||||||
|
|
||||||
const targetIdentifier = viewedIdentifier.value
|
const targetUserId = viewedUserId.value
|
||||||
|
|
||||||
if (!targetIdentifier) {
|
if (!targetUserId) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await profileStore.fetchProfilePage(targetIdentifier, isOwnProfile.value, force)
|
await profileStore.fetchProfilePage(targetUserId, isOwnProfile.value, force)
|
||||||
|
|
||||||
if (isOwnProfile.value) {
|
if (isOwnProfile.value) {
|
||||||
await cloudsStore.fetchCloudTypes()
|
await cloudsStore.fetchCloudTypes()
|
||||||
|
await encyclopediaStore.fetchCloudTypes(force)
|
||||||
|
await encyclopediaStore.fetchMyCollection(force)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,98 +475,77 @@ watch(selectedUploadDate, async newValue => {
|
|||||||
<div class="relative">
|
<div class="relative">
|
||||||
<div class="oc-page-hero">
|
<div 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">
|
||||||
<div
|
<div v-if="loading" class="grid gap-6 lg:grid-cols-[1.25fr_0.95fr]">
|
||||||
v-if="loading"
|
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||||||
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="h-6 w-1/3" />
|
||||||
<NSkeleton class="mt-4 h-10 w-2/3" />
|
<NSkeleton class="mt-4 h-10 w-2/3" />
|
||||||
<NSkeleton class="mt-5 h-4 w-full" :repeat="2" />
|
<NSkeleton class="mt-5 h-4 w-full" :repeat="2" />
|
||||||
</NCard>
|
</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 border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
|
||||||
<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="h-5 w-1/3" />
|
||||||
<NSkeleton class="mt-4 h-8 w-1/2" />
|
<NSkeleton class="mt-4 h-8 w-1/2" />
|
||||||
<NSkeleton class="mt-5 h-3 w-full" />
|
<NSkeleton class="mt-5 h-3 w-full" />
|
||||||
</NCard>
|
</NCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div v-else class="grid gap-6 lg:grid-cols-[1.25fr_0.95fr] lg:items-start">
|
||||||
v-else
|
<div class="flex gap-5">
|
||||||
class="oc-page-hero__split oc-page-hero__split--stretch"
|
<div
|
||||||
>
|
class="flex h-20 w-20 shrink-0 items-center justify-center border border-slate-300 bg-[linear-gradient(135deg,#ecfeff_0%,#ccfbf1_100%)] text-3xl font-bold text-slate-900 shadow-[4px_4px_0_0_rgba(15,23,42,0.08)]"
|
||||||
<NCard class="oc-panel-card h-full border border-slate-200 bg-white/90 backdrop-blur" :bordered="false">
|
>
|
||||||
<div class="flex gap-5">
|
<img
|
||||||
<div
|
v-if="profileData?.avatar_url"
|
||||||
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"
|
:src="profileData.avatar_url"
|
||||||
>
|
:alt="profileData.username"
|
||||||
<img
|
class="h-full w-full object-cover"
|
||||||
v-if="profileData?.avatar_id"
|
/>
|
||||||
:src="profileData.avatar_id"
|
<template v-else>
|
||||||
:alt="profileData.username"
|
{{ getProfileInitial(profileData) }}
|
||||||
class="h-full w-full object-cover"
|
</template>
|
||||||
/>
|
</div>
|
||||||
<template v-else>
|
|
||||||
{{ getProfileInitial(profileData) }}
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="oc-page-eyebrow">Sky Journal</p>
|
<p class="oc-page-eyebrow">Sky Journal</p>
|
||||||
<h1 class="oc-page-title">
|
<h1 class="oc-page-title">
|
||||||
{{ pageTitle }}
|
{{ pageTitle }}
|
||||||
</h1>
|
</h1>
|
||||||
<div class="mt-3 flex flex-wrap items-center gap-3">
|
<div class="mt-3 flex flex-wrap items-center gap-3">
|
||||||
<p class="text-lg font-medium text-slate-700">
|
<p class="text-lg font-medium text-slate-700">
|
||||||
@{{ profileData?.username || '未知用户' }}
|
@{{ profileData?.username || '未知用户' }}
|
||||||
</p>
|
</p>
|
||||||
<RouterLink v-if="isOwnProfile" to="/profile/settings">
|
<RouterLink v-if="isOwnProfile" to="/profile/settings">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="oc-icon-button oc-icon-button--small"
|
class="inline-flex h-8 w-8 items-center justify-center border border-slate-300 bg-white text-slate-600 transition-colors hover:border-slate-900 hover:text-slate-900"
|
||||||
title="个人资料设置"
|
title="个人资料设置"
|
||||||
aria-label="个人资料设置"
|
aria-label="个人资料设置"
|
||||||
>
|
>
|
||||||
<NIcon size="17">
|
<NIcon size="17">
|
||||||
<Settings />
|
<Settings />
|
||||||
</NIcon>
|
</NIcon>
|
||||||
</button>
|
</button>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5 flex flex-wrap items-center gap-3 text-sm text-slate-500">
|
<!-- <p class="mt-4 max-w-2xl text-sm leading-7 text-slate-600">
|
||||||
<span v-if="isOwnProfile">注册于 {{ formatDate(profileData?.created_at || null) }}</span>
|
{{ profileSubtitle }}
|
||||||
</div>
|
</p> -->
|
||||||
|
<div class="mt-5 flex flex-wrap items-center gap-3 text-sm text-slate-500">
|
||||||
|
<span>注册于 {{ formatDate(profileData?.created_at || null) }}</span>
|
||||||
|
<!-- <NTag v-if="isOwnProfile" type="primary" :bordered="false">你的主页</NTag>
|
||||||
|
<NTag v-else :bordered="false">公开展示</NTag> -->
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<NCard v-if="!isOwnProfile" class="oc-panel-card-soft h-full border border-white/80 bg-white/85 backdrop-blur" :bordered="false">
|
<EncyclopediaProgressCard
|
||||||
|
v-if="isOwnProfile"
|
||||||
|
:unlocked-count="encyclopediaStore.unlockedCount"
|
||||||
|
:total-count="encyclopediaStore.cloudTypes.length || 10"
|
||||||
|
:percent="encyclopediaStore.unlockPercent"
|
||||||
|
:is-logged-in="true"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<NCard v-else class="oc-panel-card-soft border border-white/80 bg-white/85 shadow-sm backdrop-blur" :bordered="false">
|
||||||
<p class="text-sm text-slate-500">公开贡献</p>
|
<p class="text-sm text-slate-500">公开贡献</p>
|
||||||
<p class="mt-3 text-3xl font-bold text-slate-900">{{ approvedShots }}</p>
|
<p class="mt-3 text-3xl font-bold text-slate-900">{{ approvedShots }}</p>
|
||||||
<p class="mt-2 text-sm text-slate-500">当前公开可见的云图数量</p>
|
<p class="mt-2 text-sm text-slate-500">当前公开可见的云图数量</p>
|
||||||
@@ -529,12 +577,50 @@ watch(selectedUploadDate, async newValue => {
|
|||||||
</NAlert>
|
</NAlert>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<section ref="timelineSectionRef">
|
<div v-if="loading" class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<NCard v-for="n in 4" :key="n">
|
||||||
|
<NSkeleton class="h-4 w-1/3" />
|
||||||
|
<NSkeleton class="mt-4 h-8 w-2/3" />
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||||||
|
<p class="text-sm text-slate-500">总拍摄数</p>
|
||||||
|
<p class="mt-2 text-3xl font-bold text-slate-900">{{ totalShots }}</p>
|
||||||
|
</NCard>
|
||||||
|
|
||||||
|
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||||||
|
<p class="text-sm text-slate-500">最常拍云型</p>
|
||||||
|
<p class="oc-stat-value">{{ mostCommonCloudType.name }}</p>
|
||||||
|
<p class="mt-2 text-sm text-slate-500" v-if="mostCommonCloudType.count">
|
||||||
|
共 {{ mostCommonCloudType.count }} 张
|
||||||
|
</p>
|
||||||
|
</NCard>
|
||||||
|
|
||||||
|
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||||||
|
<p class="text-sm text-slate-500">拍摄天数</p>
|
||||||
|
<p class="mt-2 text-3xl font-bold text-slate-900">{{ shootingDays }}</p>
|
||||||
|
</NCard>
|
||||||
|
|
||||||
|
<NCard class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||||||
|
<p class="text-sm text-slate-500">{{ isOwnProfile ? '已通过 / 待审核' : '公开通过数' }}</p>
|
||||||
|
<p class="oc-stat-value">
|
||||||
|
<template v-if="isOwnProfile">{{ approvedShots }} / {{ pendingShots }}</template>
|
||||||
|
<template v-else>{{ approvedShots }}</template>
|
||||||
|
</p>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section ref="timelineSectionRef" class="mt-8">
|
||||||
<ContributionHeatmap
|
<ContributionHeatmap
|
||||||
:cells="contributionCells"
|
:cells="contributionCells"
|
||||||
:total-count="totalShots"
|
:total-count="totalShots"
|
||||||
:active-days="activeUploadDays"
|
:active-days="activeUploadDays"
|
||||||
|
:current-streak="currentUploadStreak"
|
||||||
|
:best-streak="longestUploadStreak"
|
||||||
:active-date="selectedUploadDate"
|
:active-date="selectedUploadDate"
|
||||||
|
:subtitle="isOwnProfile ? '按上传日期统计' : '按公开上传日期统计'"
|
||||||
:title="isOwnProfile ? '上传活跃度' : '公开活跃度'"
|
:title="isOwnProfile ? '上传活跃度' : '公开活跃度'"
|
||||||
@select-date="toggleSelectedUploadDate"
|
@select-date="toggleSelectedUploadDate"
|
||||||
/>
|
/>
|
||||||
@@ -545,7 +631,7 @@ watch(selectedUploadDate, async newValue => {
|
|||||||
<div>
|
<div>
|
||||||
<h2 class="text-2xl font-bold text-slate-900">天空日志</h2>
|
<h2 class="text-2xl font-bold text-slate-900">天空日志</h2>
|
||||||
<p class="oc-field-help">
|
<p class="oc-field-help">
|
||||||
按拍摄时间展示;未记录拍摄时间时回退为上传时间。热力图按上传日期计算。
|
按拍摄时间倒序展示,并按月份分组。热力图统计按上传日期计算。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -563,31 +649,25 @@ watch(selectedUploadDate, async newValue => {
|
|||||||
v-if="isOwnProfile"
|
v-if="isOwnProfile"
|
||||||
secondary
|
secondary
|
||||||
strong
|
strong
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
@click="toggleSelectionMode"
|
@click="toggleSelectionMode"
|
||||||
>
|
>
|
||||||
{{ selectionMode ? '取消选择' : '选择图片' }}
|
{{ selectionMode ? '取消选择' : '选择图片' }}
|
||||||
</NButton>
|
</NButton>
|
||||||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="loadProfilePage(true)">
|
<NButton secondary strong class="oc-panel-button oc-panel-button--neutral" @click="loadProfilePage(true)">
|
||||||
刷新数据
|
刷新数据
|
||||||
</NButton>
|
</NButton>
|
||||||
<NButton
|
<NButton
|
||||||
v-if="selectedUploadDate"
|
v-if="selectedUploadDate"
|
||||||
secondary
|
secondary
|
||||||
strong
|
strong
|
||||||
type="default"
|
|
||||||
class="oc-panel-button oc-panel-button--neutral"
|
class="oc-panel-button oc-panel-button--neutral"
|
||||||
@click="clearSelectedUploadDate"
|
@click="clearSelectedUploadDate"
|
||||||
>
|
>
|
||||||
清除筛选
|
清除筛选
|
||||||
</NButton>
|
</NButton>
|
||||||
<RouterLink
|
<RouterLink v-if="isOwnProfile" to="/upload">
|
||||||
v-if="isOwnProfile"
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky">继续上传</NButton>
|
||||||
to="/upload"
|
|
||||||
class="oc-panel-button oc-panel-button--sky"
|
|
||||||
>
|
|
||||||
继续上传
|
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -604,7 +684,7 @@ watch(selectedUploadDate, async newValue => {
|
|||||||
<div v-for="n in 2" :key="n">
|
<div v-for="n in 2" :key="n">
|
||||||
<NSkeleton class="h-6 w-40" />
|
<NSkeleton class="h-6 w-40" />
|
||||||
<div class="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
<div class="mt-4 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||||
<NCard v-for="m in 3" :key="m" class="oc-panel-card border border-slate-200" :bordered="false">
|
<NCard v-for="m in 3" :key="m" class="oc-panel-card border border-slate-200 shadow-sm" :bordered="false">
|
||||||
<NSkeleton class="h-52 w-full" />
|
<NSkeleton class="h-52 w-full" />
|
||||||
<NSkeleton class="mt-4 h-5 w-2/3" />
|
<NSkeleton class="mt-4 h-5 w-2/3" />
|
||||||
<NSkeleton class="mt-3 h-4 w-1/2" />
|
<NSkeleton class="mt-3 h-4 w-1/2" />
|
||||||
@@ -626,8 +706,8 @@ watch(selectedUploadDate, async newValue => {
|
|||||||
:key="item.id"
|
:key="item.id"
|
||||||
type="button"
|
type="button"
|
||||||
@click="selectedCloud = item"
|
@click="selectedCloud = item"
|
||||||
class="oc-interactive-card group overflow-hidden border bg-white text-left transition-all hover:-translate-y-1"
|
class="group overflow-hidden border bg-white text-left shadow-sm transition-all hover:-translate-y-1 hover:shadow-lg"
|
||||||
:class="selectedUploadDate && toDayKey(item.created_at) === selectedUploadDate ? 'oc-interactive-card--selected' : 'border-slate-200'"
|
:class="selectedUploadDate && toDayKey(item.created_at) === selectedUploadDate ? 'border-sky-500 shadow-sky-100' : 'border-slate-200'"
|
||||||
>
|
>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<img
|
<img
|
||||||
@@ -650,8 +730,8 @@ watch(selectedUploadDate, async newValue => {
|
|||||||
/>
|
/>
|
||||||
选择
|
选择
|
||||||
</label>
|
</label>
|
||||||
<span class="inline-flex border px-3 py-1 text-xs font-medium" :class="rarityChip">
|
<span class="inline-flex border px-3 py-1 text-xs font-medium" :class="rarityMeta[item.cloudTypeRarity].chip">
|
||||||
稀有度 {{ item.cloudTypeRarity }}
|
{{ rarityMeta[item.cloudTypeRarity].label }}
|
||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-if="isOwnProfile"
|
v-if="isOwnProfile"
|
||||||
@@ -676,7 +756,7 @@ watch(selectedUploadDate, async newValue => {
|
|||||||
|
|
||||||
<div class="p-5">
|
<div class="p-5">
|
||||||
<p class="truncate text-sm font-medium text-slate-900">
|
<p class="truncate text-sm font-medium text-slate-900">
|
||||||
纬度 {{ formatCoordinate(item.latitude) }} / 经度 {{ formatCoordinate(item.longitude) }}
|
{{ item.location_name || '未填写位置名称' }}
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-3 line-clamp-3 text-sm leading-6 text-slate-500">
|
<p class="mt-3 line-clamp-3 text-sm leading-6 text-slate-500">
|
||||||
{{ item.description || '没有留下额外说明。' }}
|
{{ item.description || '没有留下额外说明。' }}
|
||||||
@@ -688,16 +768,12 @@ watch(selectedUploadDate, async newValue => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="oc-empty-card border border-dashed border-slate-300 bg-white p-10">
|
<div v-else class="oc-empty-card border border-dashed border-slate-300 bg-white p-10">
|
||||||
<NEmpty :description="selectedUploadDate ? '这一天没有上传记录' : (isOwnProfile ? '还没有上传任何云图' : '该用户暂无公开云图')">
|
<NEmpty :description="selectedUploadDate ? '这一天没有上传记录' : (isOwnProfile ? '还没有上传任何云图' : '这位用户还没有公开云图')">
|
||||||
<template #extra>
|
<template #extra>
|
||||||
<RouterLink
|
<RouterLink v-if="isOwnProfile && !selectedUploadDate" to="/upload">
|
||||||
v-if="isOwnProfile && !selectedUploadDate"
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky">去上传第一张</NButton>
|
||||||
to="/upload"
|
|
||||||
class="oc-panel-button oc-panel-button--sky"
|
|
||||||
>
|
|
||||||
去上传第一张
|
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<NButton v-else-if="selectedUploadDate" secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="clearSelectedUploadDate">返回全部记录</NButton>
|
<NButton v-else-if="selectedUploadDate" secondary strong class="oc-panel-button oc-panel-button--neutral" @click="clearSelectedUploadDate">返回全部记录</NButton>
|
||||||
</template>
|
</template>
|
||||||
</NEmpty>
|
</NEmpty>
|
||||||
</div>
|
</div>
|
||||||
@@ -712,9 +788,9 @@ watch(selectedUploadDate, async newValue => {
|
|||||||
:thumbnail-url="selectedCloud.thumbnail_url"
|
:thumbnail-url="selectedCloud.thumbnail_url"
|
||||||
:image-alt="selectedCloud.cloudTypeName"
|
:image-alt="selectedCloud.cloudTypeName"
|
||||||
:title="selectedCloud.cloudTypeName"
|
:title="selectedCloud.cloudTypeName"
|
||||||
:subtitle="formatCloudTime(selectedCloud)"
|
:subtitle="`拍摄时间:${formatCloudTime(selectedCloud)}`"
|
||||||
:badge-label="`稀有度 ${selectedCloud.cloudTypeRarity}`"
|
:badge-label="rarityMeta[selectedCloud.cloudTypeRarity].label"
|
||||||
:badge-class="rarityChip"
|
:badge-class="rarityMeta[selectedCloud.cloudTypeRarity].chip"
|
||||||
@close="selectedCloud = null"
|
@close="selectedCloud = null"
|
||||||
>
|
>
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
@@ -728,7 +804,7 @@ watch(selectedUploadDate, async newValue => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="oc-inset-panel">
|
<div class="oc-inset-panel">
|
||||||
<p class="oc-section-label">位置</p>
|
<p class="oc-section-label">位置</p>
|
||||||
<p class="mt-2 text-sm font-medium text-slate-900">已记录经纬度</p>
|
<p class="mt-2 text-sm font-medium text-slate-900">{{ selectedCloud.location_name || '未填写位置名称' }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="oc-inset-panel">
|
<div class="oc-inset-panel">
|
||||||
<p class="oc-section-label">模糊化经纬度</p>
|
<p class="oc-section-label">模糊化经纬度</p>
|
||||||
@@ -756,6 +832,7 @@ watch(selectedUploadDate, async newValue => {
|
|||||||
<MiniLocationMap
|
<MiniLocationMap
|
||||||
:latitude="selectedCloud.latitude"
|
:latitude="selectedCloud.latitude"
|
||||||
:longitude="selectedCloud.longitude"
|
:longitude="selectedCloud.longitude"
|
||||||
|
:location-name="selectedCloud.location_name"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<template v-if="isOwnProfile" #actions>
|
<template v-if="isOwnProfile" #actions>
|
||||||
@@ -768,7 +845,7 @@ watch(selectedUploadDate, async newValue => {
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="oc-icon-button"
|
class="flex h-10 w-10 items-center justify-center border border-slate-200 bg-slate-50 text-slate-600 transition-colors hover:border-slate-900 hover:text-slate-900"
|
||||||
title="图片管理"
|
title="图片管理"
|
||||||
aria-label="图片管理"
|
aria-label="图片管理"
|
||||||
>
|
>
|
||||||
@@ -795,9 +872,3 @@ watch(selectedUploadDate, async newValue => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.profile-avatar {
|
|
||||||
box-shadow: var(--oc-shadow-sm);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { NButton, NIcon } from 'naive-ui'
|
import { NButton } from 'naive-ui'
|
||||||
import { Lock } from '@vicons/tabler'
|
|
||||||
import { RouterLink, useRoute } from 'vue-router'
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -31,7 +30,7 @@ const description = computed(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="oc-system-page">
|
<div class="oc-system-page">
|
||||||
<div class="oc-system-card">
|
<div class="oc-system-card">
|
||||||
<NIcon size="72" class="text-sky-600"><Lock /></NIcon>
|
<div class="text-7xl leading-none">🔐</div>
|
||||||
<p class="mt-6 text-sm font-medium uppercase tracking-[0.24em] text-sky-600">401 Login Required</p>
|
<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>
|
<h1 class="oc-page-title">{{ title }}</h1>
|
||||||
<p class="oc-page-description mx-auto max-w-xl">
|
<p class="oc-page-description mx-auto max-w-xl">
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NButton, NIcon } from 'naive-ui'
|
import { NButton } from 'naive-ui'
|
||||||
import { HandStop } from '@vicons/tabler'
|
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="oc-system-page">
|
<div class="oc-system-page">
|
||||||
<div class="oc-system-card">
|
<div class="oc-system-card">
|
||||||
<NIcon size="72" class="text-rose-600"><HandStop /></NIcon>
|
<div class="text-7xl leading-none">🖐️</div>
|
||||||
<p class="mt-6 text-sm font-medium uppercase tracking-[0.24em] text-rose-600">403 Forbidden</p>
|
<p class="mt-6 text-sm font-medium uppercase tracking-[0.24em] text-rose-600">403 Forbidden</p>
|
||||||
<h1 class="oc-page-title">这里不让进</h1>
|
<h1 class="oc-page-title">这里不让进</h1>
|
||||||
<p class="oc-page-description mx-auto max-w-xl">
|
<p class="oc-page-description mx-auto max-w-xl">
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { NButton, NIcon } from 'naive-ui'
|
import { NButton } from 'naive-ui'
|
||||||
import { Help } from '@vicons/tabler'
|
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="oc-system-page">
|
<div class="oc-system-page">
|
||||||
<div class="oc-system-card">
|
<div class="oc-system-card">
|
||||||
<NIcon size="72" class="text-amber-600"><Help /></NIcon>
|
<div class="text-7xl leading-none">🤔</div>
|
||||||
<p class="mt-6 text-sm font-medium uppercase tracking-[0.24em] text-amber-600">404 Not Found</p>
|
<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>
|
<h1 class="oc-page-title">我也没想明白</h1>
|
||||||
<p class="oc-page-description mx-auto max-w-xl">
|
<p class="oc-page-description mx-auto max-w-xl">
|
||||||
|
|||||||
+127
-40
@@ -1,19 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||||
import { NAlert, NButton, NCard, NIcon, NProgress, NResult } from 'naive-ui'
|
import { NAlert, NButton, NCard, NProgress, NResult, NTag } from 'naive-ui'
|
||||||
import { CloudUpload, Map as MapIcon, X } from '@vicons/tabler'
|
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useCloudsStore } from '@/stores/clouds'
|
import { useCloudsStore } from '@/stores/clouds'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
import { downloadCloudBadgeCard } from '@/lib/cloudBadges'
|
||||||
import { useUpload } from '@/composables/useUpload'
|
import { useUpload } from '@/composables/useUpload'
|
||||||
import MapPickerModal from '@/components/cloud/MapPickerModal.vue'
|
import MapPickerModal from '@/components/cloud/MapPickerModal.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const authStore = useAuthStore()
|
||||||
const cloudsStore = useCloudsStore()
|
const cloudsStore = useCloudsStore()
|
||||||
const { items, uploading, overallProgress, currentItemIndex, totalItems, addFiles, removeItem, clearAll, validateAll, uploadAll } = useUpload()
|
const { items, uploading, overallProgress, currentItemIndex, totalItems, addFiles, removeItem, clearAll, validateAll, uploadAll } = useUpload()
|
||||||
|
|
||||||
const activeId = ref<string | null>(null)
|
const activeId = ref<string | null>(null)
|
||||||
const dragOver = ref(false)
|
const dragOver = ref(false)
|
||||||
const successMsg = ref(false)
|
const successMsg = ref(false)
|
||||||
|
const unlockedBadges = ref<Awaited<ReturnType<typeof uploadAll>>['unlockedBadges']>([])
|
||||||
const errorMsg = ref('')
|
const errorMsg = ref('')
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
const mapPickerOpen = ref(false)
|
const mapPickerOpen = ref(false)
|
||||||
@@ -83,6 +86,10 @@ function confirmMapPicker(payload: { latitude: number; longitude: number }) {
|
|||||||
closeMapPicker()
|
closeMapPicker()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clearCoordinates() {
|
||||||
|
updateActiveCoordinates(null, null)
|
||||||
|
}
|
||||||
|
|
||||||
function updateActivePublicState(isPublic: boolean) {
|
function updateActivePublicState(isPublic: boolean) {
|
||||||
if (!activeItem.value) return
|
if (!activeItem.value) return
|
||||||
activeItem.value.isHidden = !isPublic
|
activeItem.value.isHidden = !isPublic
|
||||||
@@ -104,17 +111,43 @@ async function handleSubmit() {
|
|||||||
|
|
||||||
const result = await uploadAll()
|
const result = await uploadAll()
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
|
unlockedBadges.value = result.unlockedBadges
|
||||||
successMsg.value = true
|
successMsg.value = true
|
||||||
} else {
|
} else {
|
||||||
errorMsg.value = result.errorMessage || '上传失败,请稍后重试'
|
errorMsg.value = '上传失败,请稍后重试'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetAfterSuccess() {
|
function resetAfterSuccess() {
|
||||||
successMsg.value = false
|
successMsg.value = false
|
||||||
|
unlockedBadges.value = []
|
||||||
errorMsg.value = ''
|
errorMsg.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function saveBadge(badge: NonNullable<typeof unlockedBadges.value[number]>) {
|
||||||
|
await downloadCloudBadgeCard({
|
||||||
|
cloudName: badge.cloudName,
|
||||||
|
cloudNameEn: badge.cloudNameEn,
|
||||||
|
unlockedAt: badge.unlockedAt,
|
||||||
|
username: authStore.profile?.username || authStore.user?.email || 'OpenCloud 用户',
|
||||||
|
rarity: badge.rarity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function rarityLabel(rarity: NonNullable<typeof unlockedBadges.value[number]>['rarity']) {
|
||||||
|
if (rarity === 'common') return '常见'
|
||||||
|
if (rarity === 'uncommon') return '少见'
|
||||||
|
return '罕见'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUnlockedAt(iso: string) {
|
||||||
|
return new Date(iso).toLocaleDateString('zh-CN', {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function onLatInput(e: Event) {
|
function onLatInput(e: Event) {
|
||||||
const val = parseFloat((e.target as HTMLInputElement).value)
|
const val = parseFloat((e.target as HTMLInputElement).value)
|
||||||
if (activeItem.value) {
|
if (activeItem.value) {
|
||||||
@@ -134,7 +167,6 @@ function onLngInput(e: Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatDatetimeLocal(iso: string): string {
|
function formatDatetimeLocal(iso: string): string {
|
||||||
if (!iso) return ''
|
|
||||||
const d = new Date(iso)
|
const d = new Date(iso)
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
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())}`
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
||||||
@@ -142,8 +174,8 @@ function formatDatetimeLocal(iso: string): string {
|
|||||||
|
|
||||||
function onCapturedAtChange(e: Event) {
|
function onCapturedAtChange(e: Event) {
|
||||||
const val = (e.target as HTMLInputElement).value
|
const val = (e.target as HTMLInputElement).value
|
||||||
if (activeItem.value) {
|
if (activeItem.value && val) {
|
||||||
activeItem.value.capturedAt = val ? new Date(val).toISOString() : ''
|
activeItem.value.capturedAt = new Date(val).toISOString()
|
||||||
if (activeItem.value.errors.capturedAt) {
|
if (activeItem.value.errors.capturedAt) {
|
||||||
delete activeItem.value.errors.capturedAt
|
delete activeItem.value.errors.capturedAt
|
||||||
}
|
}
|
||||||
@@ -153,7 +185,9 @@ function onCapturedAtChange(e: Event) {
|
|||||||
function onCategoryChange(e: Event) {
|
function onCategoryChange(e: Event) {
|
||||||
const val = (e.target as HTMLSelectElement).value
|
const val = (e.target as HTMLSelectElement).value
|
||||||
if (!activeItem.value) return
|
if (!activeItem.value) return
|
||||||
if (val === '') {
|
if (val === 'other') {
|
||||||
|
activeItem.value.cloudCategoryId = 'other'
|
||||||
|
} else if (val === '') {
|
||||||
activeItem.value.cloudCategoryId = null
|
activeItem.value.cloudCategoryId = null
|
||||||
} else {
|
} else {
|
||||||
activeItem.value.cloudCategoryId = Number(val)
|
activeItem.value.cloudCategoryId = Number(val)
|
||||||
@@ -174,28 +208,55 @@ onUnmounted(() => {
|
|||||||
<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 class="oc-page-description">
|
||||||
批量整理你的云图记录。类别和经纬度必填;拍摄时间可留空,也可以从图片 EXIF 自动读取。
|
批量整理你的云图记录。类别和拍摄时间必填,经纬度既可以手动输入,也可以在迷你地图里点选回填。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div class="oc-container oc-container--content oc-page-content">
|
<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" />
|
<input ref="fileInput" type="file" accept="image/*" multiple class="hidden" @change="handleFileSelect" />
|
||||||
|
|
||||||
<div v-if="successMsg" class="flex items-center justify-center min-h-[60vh]">
|
<div v-if="successMsg" class="flex items-center justify-center min-h-[60vh]">
|
||||||
<div class="w-full max-w-5xl">
|
<div class="w-full max-w-5xl">
|
||||||
<NResult status="success" title="已提交审核" />
|
<NResult status="success" title="已提交审核" />
|
||||||
|
|
||||||
|
<div v-if="unlockedBadges.length" class="mt-8 grid gap-5 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<NCard
|
||||||
|
v-for="badge in unlockedBadges"
|
||||||
|
:key="badge.cloudTypeId"
|
||||||
|
class="border border-amber-200 bg-[linear-gradient(180deg,#fffbeb_0%,#ffffff_100%)] shadow-sm"
|
||||||
|
>
|
||||||
|
<div class="flex h-16 w-16 items-center justify-center border border-amber-300 bg-amber-400 text-3xl text-white shadow-sm">
|
||||||
|
{{ badge.cloudName.slice(0, 1) }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-5">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<h3 class="text-2xl font-bold text-slate-900">{{ badge.cloudName }}</h3>
|
||||||
|
<NTag :bordered="false" type="warning">
|
||||||
|
{{ rarityLabel(badge.rarity) }}
|
||||||
|
</NTag>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-slate-500">{{ badge.cloudNameEn }}</p>
|
||||||
|
<p class="mt-4 text-sm text-slate-600">解锁时间:{{ formatUnlockedAt(badge.unlockedAt) }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex gap-3">
|
||||||
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--sky flex-1" @click="saveBadge(badge)">保存分享卡片</NButton>
|
||||||
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral flex-1" @click="router.push(`/encyclopedia/${badge.cloudTypeId}`)">查看详情</NButton>
|
||||||
|
</div>
|
||||||
|
</NCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="mt-8 flex flex-wrap items-center justify-center gap-3">
|
<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--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--sky" @click="router.push('/encyclopedia')">前往图鉴</NButton>
|
||||||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="router.push('/profile')">返回个人主页</NButton>
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="router.push('/profile')">返回个人主页</NButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div v-if="uploading" class="oc-panel-card mb-6 border border-slate-200 bg-white p-5">
|
<div v-if="uploading" class="mb-6 border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
<div class="flex items-center justify-between mb-2">
|
<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-slate-700">正在上传 {{ currentItemIndex }} / {{ totalItems }}...</span>
|
||||||
<span class="text-sm font-medium text-sky-600">{{ overallProgress }}%</span>
|
<span class="text-sm font-medium text-sky-600">{{ overallProgress }}%</span>
|
||||||
@@ -204,8 +265,8 @@ onUnmounted(() => {
|
|||||||
type="line"
|
type="line"
|
||||||
:show-indicator="false"
|
:show-indicator="false"
|
||||||
:percentage="overallProgress"
|
:percentage="overallProgress"
|
||||||
color="var(--oc-color-info)"
|
color="#0ea5e9"
|
||||||
rail-color="var(--oc-color-border)"
|
rail-color="#dbe4ee"
|
||||||
:height="10"
|
:height="10"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -216,19 +277,19 @@ onUnmounted(() => {
|
|||||||
@dragleave.prevent="dragOver = false"
|
@dragleave.prevent="dragOver = false"
|
||||||
@drop.prevent="handleDrop"
|
@drop.prevent="handleDrop"
|
||||||
@click="fileInput?.click()"
|
@click="fileInput?.click()"
|
||||||
class="oc-panel-card flex cursor-pointer flex-col items-center justify-center border-2 border-dashed bg-white py-20 transition-colors"
|
class="flex flex-col items-center justify-center border-2 border-dashed py-20 cursor-pointer transition-colors bg-white shadow-sm"
|
||||||
:class="dragOver ? 'border-sky-400 bg-sky-50' : 'border-slate-300 hover:border-sky-400 hover:bg-slate-50'"
|
:class="dragOver ? 'border-sky-400 bg-sky-50' : 'border-slate-300 hover:border-sky-400 hover:bg-slate-50'"
|
||||||
>
|
>
|
||||||
<NIcon size="48" class="mb-4 text-sky-600"><CloudUpload /></NIcon>
|
<span class="text-5xl mb-4">☁️</span>
|
||||||
<p class="mb-1 text-lg font-medium text-slate-700">点击或拖拽图片到此处</p>
|
<p class="mb-1 text-lg font-medium text-slate-700">点击或拖拽图片到此处</p>
|
||||||
<p class="text-sm text-slate-400">支持 JPEG、PNG、WebP,单张不超过 20MB,可一次选择多张</p>
|
<p class="text-sm text-slate-400">支持 JPG、PNG 格式,可一次选择多张</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<div class="flex min-w-0 flex-col gap-6 lg:flex-row">
|
<div class="flex gap-6">
|
||||||
<div class="w-full min-w-0 lg:w-[480px] lg:flex-shrink-0">
|
<div class="flex-shrink-0 w-[480px]">
|
||||||
<div v-if="activeItem" class="mb-3 overflow-hidden border border-slate-900 bg-slate-900">
|
<div v-if="activeItem" class="mb-3 overflow-hidden border border-slate-900 bg-slate-900">
|
||||||
<img :src="activeItem.preview" alt="预览" class="h-64 w-full object-contain sm:h-[360px]" />
|
<img :src="activeItem.preview" alt="预览" class="w-full h-[360px] object-contain" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 overflow-x-auto pb-2">
|
<div class="flex gap-2 overflow-x-auto pb-2">
|
||||||
<div
|
<div
|
||||||
@@ -241,14 +302,10 @@ onUnmounted(() => {
|
|||||||
<img :src="item.preview" alt="" class="w-full h-full object-cover" />
|
<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>
|
<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
|
<button
|
||||||
type="button"
|
|
||||||
@click.stop="handleRemove(item.id)"
|
@click.stop="handleRemove(item.id)"
|
||||||
class="oc-icon-button oc-icon-button--danger oc-icon-button--tiny absolute -right-1 -top-1"
|
class="absolute -top-1 -right-1 flex h-5 w-5 items-center justify-center bg-red-500 text-xs text-white"
|
||||||
title="移除图片"
|
style="opacity:0.8"
|
||||||
aria-label="移除图片"
|
>✕</button>
|
||||||
>
|
|
||||||
<NIcon size="12"><X /></NIcon>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@click="fileInput?.click()"
|
@click="fileInput?.click()"
|
||||||
@@ -260,7 +317,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 min-w-0" v-if="activeItem">
|
<div class="flex-1 min-w-0" v-if="activeItem">
|
||||||
<NCard class="oc-panel-card border border-slate-200">
|
<NCard class="border border-slate-200 shadow-sm">
|
||||||
<div class="space-y-5">
|
<div class="space-y-5">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h2 class="text-lg font-semibold text-slate-900">图片信息</h2>
|
<h2 class="text-lg font-semibold text-slate-900">图片信息</h2>
|
||||||
@@ -279,16 +336,27 @@ onUnmounted(() => {
|
|||||||
>
|
>
|
||||||
<option value="" disabled>请选择</option>
|
<option value="" disabled>请选择</option>
|
||||||
<option v-for="ct in cloudsStore.cloudTypes" :key="ct.id" :value="ct.id">
|
<option v-for="ct in cloudsStore.cloudTypes" :key="ct.id" :value="ct.id">
|
||||||
{{ ct.name }}{{ ct.genus ? '(' + ct.genus + ')' : '' }}
|
{{ ct.name }}({{ ct.name_en }})
|
||||||
</option>
|
</option>
|
||||||
|
<option value="other">其他</option>
|
||||||
</select>
|
</select>
|
||||||
|
<div v-if="activeItem.cloudCategoryId === 'other'" class="mt-2">
|
||||||
|
<input
|
||||||
|
v-model="activeItem.customCloudType"
|
||||||
|
type="text"
|
||||||
|
placeholder="输入云的类型"
|
||||||
|
@input="activeItem.errors = {}"
|
||||||
|
class="oc-field-control"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<p v-if="activeItem.errors.cloudCategory" class="oc-field-error">{{ activeItem.errors.cloudCategory }}</p>
|
<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>
|
||||||
|
|
||||||
<!-- 拍摄时间 -->
|
<!-- 拍摄时间 * -->
|
||||||
<div>
|
<div>
|
||||||
<label class="oc-field-label">拍摄时间</label>
|
<label class="oc-field-label">
|
||||||
|
拍摄时间 <span class="oc-field-required">*</span>
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="datetime-local"
|
type="datetime-local"
|
||||||
:value="formatDatetimeLocal(activeItem.capturedAt)"
|
:value="formatDatetimeLocal(activeItem.capturedAt)"
|
||||||
@@ -298,9 +366,19 @@ onUnmounted(() => {
|
|||||||
<p v-if="activeItem.errors.capturedAt" class="oc-field-error">{{ activeItem.errors.capturedAt }}</p>
|
<p v-if="activeItem.errors.capturedAt" class="oc-field-error">{{ activeItem.errors.capturedAt }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 经纬度 * -->
|
<!-- 经纬度 -->
|
||||||
<div>
|
<div>
|
||||||
<label class="oc-field-label">经纬度 <span class="oc-field-required">*</span></label>
|
<div class="mb-1 flex items-center justify-between gap-3">
|
||||||
|
<label class="oc-field-label mb-0">经纬度</label>
|
||||||
|
<button
|
||||||
|
v-if="activeItem.latitude !== null || activeItem.longitude !== null"
|
||||||
|
type="button"
|
||||||
|
@click="clearCoordinates"
|
||||||
|
class="text-xs font-medium text-slate-400 transition-colors hover:text-slate-600"
|
||||||
|
>
|
||||||
|
清空
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
|
<div class="grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)_auto] gap-2">
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input
|
||||||
@@ -330,13 +408,24 @@ onUnmounted(() => {
|
|||||||
@click="openMapPicker"
|
@click="openMapPicker"
|
||||||
title="地图选点"
|
title="地图选点"
|
||||||
aria-label="地图选点"
|
aria-label="地图选点"
|
||||||
class="oc-icon-button oc-icon-button--sky"
|
class="flex h-10 w-10 items-center justify-center rounded-lg border border-sky-200 bg-sky-50 text-sky-700 transition-colors hover:bg-sky-100"
|
||||||
>
|
>
|
||||||
<NIcon size="20"><MapIcon /></NIcon>
|
<span class="text-lg leading-none">🗺️</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p class="mt-1 text-xs text-slate-400">必填,可手动输入,或点击右侧地图图标选择</p>
|
<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>
|
||||||
|
|
||||||
<!-- 图片描述 -->
|
<!-- 图片描述 -->
|
||||||
@@ -345,11 +434,9 @@ onUnmounted(() => {
|
|||||||
<textarea
|
<textarea
|
||||||
v-model="activeItem.description"
|
v-model="activeItem.description"
|
||||||
rows="3"
|
rows="3"
|
||||||
maxlength="128"
|
|
||||||
placeholder="描述一下这张图片..."
|
placeholder="描述一下这张图片..."
|
||||||
class="oc-field-control resize-none"
|
class="oc-field-control resize-none"
|
||||||
></textarea>
|
></textarea>
|
||||||
<p v-if="activeItem.errors.description" class="oc-field-error">{{ activeItem.errors.description }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 公开状态 -->
|
<!-- 公开状态 -->
|
||||||
@@ -373,9 +460,9 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
<div class="mt-6 flex items-center justify-between">
|
||||||
<p class="text-sm text-slate-500">共 {{ items.length }} 张图片</p>
|
<p class="text-sm text-slate-500">共 {{ items.length }} 张图片</p>
|
||||||
<div class="flex flex-wrap gap-3">
|
<div class="flex gap-3">
|
||||||
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="clearAll()" :disabled="uploading">清空</NButton>
|
<NButton secondary strong type="default" class="oc-panel-button oc-panel-button--neutral" @click="clearAll()" :disabled="uploading">清空</NButton>
|
||||||
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky" @click="handleSubmit" :disabled="uploading">
|
<NButton type="default" secondary strong class="oc-panel-button oc-panel-button--sky" @click="handleSubmit" :disabled="uploading">
|
||||||
{{ uploading ? '上传中...' : '提交上传' }}
|
{{ uploading ? '上传中...' : '提交上传' }}
|
||||||
|
|||||||
Reference in New Issue
Block a user