Initial project commit
This commit is contained in:
@@ -0,0 +1,13 @@
|
|||||||
|
* text=auto
|
||||||
|
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.py text eol=lf
|
||||||
|
*.js text eol=lf
|
||||||
|
*.ts text eol=lf
|
||||||
|
*.vue text eol=lf
|
||||||
|
*.json text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.md text eol=lf
|
||||||
|
*.conf text eol=lf
|
||||||
|
*.sql text eol=lf
|
||||||
+91
@@ -0,0 +1,91 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp/
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
# Package manager caches
|
||||||
|
.npm-cache/
|
||||||
|
.pnpm-store/
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/releases/
|
||||||
|
!.yarn/plugins/
|
||||||
|
!.yarn/patches/
|
||||||
|
!.yarn/sdks/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
build/
|
||||||
|
.output/
|
||||||
|
.vite/
|
||||||
|
.vite-ssg-temp/
|
||||||
|
.temp/
|
||||||
|
.tmp/
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# uni-app
|
||||||
|
unpackage/
|
||||||
|
|
||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
wheels/
|
||||||
|
pip-wheel-metadata/
|
||||||
|
|
||||||
|
# Python tooling
|
||||||
|
.pytest_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.pyre/
|
||||||
|
htmlcov/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
coverage.xml
|
||||||
|
|
||||||
|
# Virtual environments
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.*.example
|
||||||
|
|
||||||
|
# Local data and uploads
|
||||||
|
server/uploads/*
|
||||||
|
!server/uploads/.gitkeep
|
||||||
|
/uploads/
|
||||||
|
pg_backup/
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# Docker local overrides
|
||||||
|
docker-compose.override.yml
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
# 次元取景器
|
||||||
|
|
||||||
|
> 为 Coser 和摄影师打造的二次元取景地发现与分享平台
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
├── clients/ # 用户端 — uni-app Vue3 (HBuilderX 项目 + Pinia)
|
||||||
|
├── server/ # 服务端 — Python (FastAPI + SQLAlchemy 2.x)
|
||||||
|
│ └── app/admin/ # 管理端 — sqladmin(挂载在 FastAPI 上)
|
||||||
|
├── docker-compose.yml
|
||||||
|
└── docs.md # 产品功能规划文档
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 层级 | 技术 |
|
||||||
|
|------|------|
|
||||||
|
| 用户端 | uni-app Vue3 (HBuilderX) · Pinia · 高德地图 |
|
||||||
|
| 服务端 | FastAPI · SQLAlchemy 2.x · Alembic · PostgreSQL + PostGIS · Redis |
|
||||||
|
| 管理端 | sqladmin(基于 FastAPI) |
|
||||||
|
| 存储 | MinIO(S3 兼容) |
|
||||||
|
| 容器 | Docker Compose(Nginx / PostgreSQL / Redis / MinIO) |
|
||||||
|
|
||||||
|
## Docker Compose 一次部署
|
||||||
|
|
||||||
|
项目已支持一次启动后端、管理端、用户端和基础设施:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
默认访问地址:
|
||||||
|
|
||||||
|
- 用户端 H5:http://localhost:5173
|
||||||
|
- 管理前端:http://localhost:5174
|
||||||
|
- 后端 API:http://localhost:5173/api
|
||||||
|
- API 文档:http://localhost:5173/docs
|
||||||
|
|
||||||
|
除 Nginx 网关外,后端、PostgreSQL、Redis、MinIO 和前端静态容器都只在 Docker 内部网络通信,不绑定宿主机端口。
|
||||||
|
|
||||||
|
默认数据库、Redis、MinIO 和端口可以通过环境变量覆盖,常用变量如下:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POSTGRES_USER=shiran
|
||||||
|
POSTGRES_PASSWORD=zxh2488252513
|
||||||
|
POSTGRES_DB=ciyuan_viewfinder
|
||||||
|
CLIENT_WEB_PORT=5173
|
||||||
|
ADMIN_WEB_PORT=5174
|
||||||
|
MINIO_ROOT_USER=minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD=minioadmin
|
||||||
|
SECRET_KEY=change-me-before-production
|
||||||
|
TENCENT_MAP_KEY=
|
||||||
|
```
|
||||||
|
|
||||||
|
后端容器启动时会执行应用内启动迁移逻辑。用户端 H5 和管理前端会在镜像构建阶段生成静态文件,并由 nginx 托管。
|
||||||
|
|
||||||
|
## 本地开发
|
||||||
|
|
||||||
|
### 1. 启动基础设施
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker-compose up -d postgres redis minio
|
||||||
|
```
|
||||||
|
|
||||||
|
这会启动 PostgreSQL(含 PostGIS)、Redis 和 MinIO。
|
||||||
|
|
||||||
|
### 2. 启动服务端
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd server
|
||||||
|
|
||||||
|
# 创建虚拟环境
|
||||||
|
python -m venv .venv
|
||||||
|
# Windows
|
||||||
|
.venv\Scripts\activate
|
||||||
|
# macOS/Linux
|
||||||
|
# source .venv/bin/activate
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# 复制并编辑环境变量
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# 执行数据库迁移
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
# 启动开发服务器
|
||||||
|
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
API 文档:http://localhost:8000/docs
|
||||||
|
管理后台:http://localhost:8000/admin
|
||||||
|
|
||||||
|
### 3. 启动用户端 HBuilderX
|
||||||
|
|
||||||
|
使用 HBuilderX 打开 `clients/` 目录,安装 Pinia 依赖后运行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd clients
|
||||||
|
npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
然后在 HBuilderX 中选择 运行 → 运行到浏览器 / 运行到小程序模拟器。
|
||||||
|
|
||||||
|
也可以构建 H5:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd clients
|
||||||
|
npm install
|
||||||
|
npm run build:h5
|
||||||
|
```
|
||||||
|
|
||||||
|
## 功能阶段
|
||||||
|
|
||||||
|
| 阶段 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| **MVP(阶段 1)** | 登录注册、地图发现、地点详情、上传地点、我的 |
|
||||||
|
| **阶段 2** | 评论评分、标签筛选、搜索 |
|
||||||
|
| **阶段 3** | 约拍广场、报名匹配、站内私信 |
|
||||||
|
| **阶段 4** | 活动发布与报名 |
|
||||||
|
| **阶段 5** | 商业化(推广位、会员) |
|
||||||
|
|
||||||
|
## 开发约定
|
||||||
|
|
||||||
|
- 服务端 API 前缀:`/api/v1/`
|
||||||
|
- 所有 UGC 内容需审核(`audit_status`:pending → approved / rejected)
|
||||||
|
- 前端统一请求封装,自动携带 Token、统一错误处理
|
||||||
|
- 管理端使用 RBAC(admin / moderator / user)
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
.npm-cache
|
||||||
|
dist
|
||||||
|
.env*
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp/
|
||||||
|
.pnp.*
|
||||||
|
|
||||||
|
# Package manager caches
|
||||||
|
.npm-cache/
|
||||||
|
.pnpm-store/
|
||||||
|
.yarn/*
|
||||||
|
!.yarn/releases/
|
||||||
|
!.yarn/plugins/
|
||||||
|
!.yarn/patches/
|
||||||
|
!.yarn/sdks/
|
||||||
|
|
||||||
|
# Build output
|
||||||
|
dist/
|
||||||
|
dist-ssr/
|
||||||
|
.vite/
|
||||||
|
.vite-ssg-temp/
|
||||||
|
.temp/
|
||||||
|
.tmp/
|
||||||
|
.cache/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.*.example
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
|
||||||
|
# IDE / OS
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
Desktop.ini
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
shamefully-hoist=true
|
||||||
|
strict-peer-dependencies=false
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ARG VITE_API_BASE=/api/v1
|
||||||
|
ENV VITE_API_BASE=${VITE_API_BASE}
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# element-plus-vite-starter
|
||||||
|
|
||||||
|
> A starter kit for Element Plus with Vite
|
||||||
|
|
||||||
|
- Preview: <https://vite-starter.element-plus.org>
|
||||||
|
|
||||||
|
This is an example of on-demand element-plus with [unplugin-vue-components](https://github.com/antfu/unplugin-vue-components).
|
||||||
|
|
||||||
|
> If you want to import all, it may be so simple that no examples are needed. Just follow [quickstart | Docs](https://element-plus.org/zh-CN/guide/quickstart.html) and import them.
|
||||||
|
|
||||||
|
If you just want an on-demand import example `manually`, you can check [unplugin-element-plus/examples/vite](https://github.com/element-plus/unplugin-element-plus/tree/main/examples/vite).
|
||||||
|
|
||||||
|
If you want to a nuxt starter, see [element-plus-nuxt-starter](https://github.com/element-plus/element-plus-nuxt-starter/).
|
||||||
|
|
||||||
|
## Project setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# npm install
|
||||||
|
# yarn install
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compiles and hot-reloads for development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Compiles and minifies for production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://github.com/element-plus/element-plus-vite-starter
|
||||||
|
cd element-plus-vite-starter
|
||||||
|
npm i
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### Custom theme
|
||||||
|
|
||||||
|
See `src/styles/element/index.scss`.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import antfu from '@antfu/eslint-config'
|
||||||
|
|
||||||
|
export default antfu({
|
||||||
|
formatters: true,
|
||||||
|
unocss: true,
|
||||||
|
vue: true,
|
||||||
|
})
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Element Plus Vite Starter</title>
|
||||||
|
<!-- element css cdn, if you use custom theme, remove it. -->
|
||||||
|
<!-- <link
|
||||||
|
rel="stylesheet"
|
||||||
|
href="https://cdn.jsdelivr.net/npm/element-plus/dist/index.css"
|
||||||
|
/> -->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://server:8000/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /uploads/ {
|
||||||
|
proxy_pass http://server:8000/uploads/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+6505
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "element-plus-vite-starter",
|
||||||
|
"type": "module",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "pnpm@10.14.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"homepage": "https://vite-starter.element-plus.org",
|
||||||
|
"repository": {
|
||||||
|
"url": "https://github.com/element-plus/element-plus-vite-starter"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"typecheck": "vue-tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@element-plus/icons-vue": "^2.3.1",
|
||||||
|
"@vueuse/core": "^13.6.0",
|
||||||
|
"element-plus": "^2.10.5",
|
||||||
|
"vue": "^3.5.18",
|
||||||
|
"vue-router": "^4.5.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@antfu/eslint-config": "^5.1.0",
|
||||||
|
"@types/node": "^24.1.0",
|
||||||
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
|
"eslint": "^9.32.0",
|
||||||
|
"eslint-plugin-format": "^1.0.1",
|
||||||
|
"sass": "^1.89.2",
|
||||||
|
"typescript": "^5.9.2",
|
||||||
|
"vite": "^5.4.11",
|
||||||
|
"vue-tsc": "^3.0.5"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+5416
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
|||||||
|
vite-starter.element-plus.org
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg id="图层_1" data-name="图层 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44"><defs><style>.cls-1{fill:#409eff;fill-rule:evenodd;}</style></defs><title>element plus-logo-small 副本</title><path id="element_plus-logo-small" data-name="element plus-logo-small" class="cls-1" d="M37.41,32.37c0,1.57-.83,1.93-.83,1.93L21.51,43A1.69,1.69,0,0,1,20,43S5.2,34.4,4.66,34a1.29,1.29,0,0,1-.55-1V15.24c0-.78,1-1.33,1-1.33L19.86,5.36a2,2,0,0,1,1.79,0l14.46,8.41a2.06,2.06,0,0,1,1.25,2.06V32.37Zm-5.9-17L21.35,9.5a1.59,1.59,0,0,0-1.41,0L8.33,16.15s-.77.46-.76,1.08,0,13.92,0,13.92A1,1,0,0,0,8,31.9c.43.3,12,7,12,7a1.31,1.31,0,0,0,1.19,0C21.91,38.5,33,32.11,33,32.11s.65-.28.65-1.51V27.13l-13,7.9V32a3.05,3.05,0,0,1,1-2.07L33.2,23a2.44,2.44,0,0,0,.55-1.46V18.43L20.64,26.35v-3.2a2.22,2.22,0,0,1,.83-1.79ZM41.07,4.22a.39.39,0,0,0-.37-.42H38V1.06c0-.16-.26-.22-.53-.22L36,1.08c-.18,0-.31.12-.31.23V3.8H33a.4.4,0,0,0-.36.37v2h3V9c0,.16.26.27.54.23l1.51-.25c.18,0,.29-.13.29-.23V6.14h3Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 995 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg id="图层_1" data-name="图层 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44"><defs><style>.cls-1{fill:#409eff;fill-rule:evenodd;}</style></defs><title>element plus-logo-small 副本</title><path id="element_plus-logo-small" data-name="element plus-logo-small" class="cls-1" d="M37.41,32.37c0,1.57-.83,1.93-.83,1.93L21.51,43A1.69,1.69,0,0,1,20,43S5.2,34.4,4.66,34a1.29,1.29,0,0,1-.55-1V15.24c0-.78,1-1.33,1-1.33L19.86,5.36a2,2,0,0,1,1.79,0l14.46,8.41a2.06,2.06,0,0,1,1.25,2.06V32.37Zm-5.9-17L21.35,9.5a1.59,1.59,0,0,0-1.41,0L8.33,16.15s-.77.46-.76,1.08,0,13.92,0,13.92A1,1,0,0,0,8,31.9c.43.3,12,7,12,7a1.31,1.31,0,0,0,1.19,0C21.91,38.5,33,32.11,33,32.11s.65-.28.65-1.51V27.13l-13,7.9V32a3.05,3.05,0,0,1,1-2.07L33.2,23a2.44,2.44,0,0,0,.55-1.46V18.43L20.64,26.35v-3.2a2.22,2.22,0,0,1,.83-1.79ZM41.07,4.22a.39.39,0,0,0-.37-.42H38V1.06c0-.16-.26-.22-.53-.22L36,1.08c-.18,0-.31.12-.31.23V3.8H33a.4.4,0,0,0-.36.37v2h3V9c0,.16.26.27.54.23l1.51-.25c.18,0,.29-.13.29-.23V6.14h3Z"/></svg>
|
||||||
|
After Width: | Height: | Size: 995 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<el-config-provider>
|
||||||
|
<RouterView />
|
||||||
|
</el-config-provider>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 497 B |
Vendored
+30
@@ -0,0 +1,30 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
// @ts-nocheck
|
||||||
|
// Generated by unplugin-vue-components
|
||||||
|
// Read more: https://github.com/vuejs/core/pull/3399
|
||||||
|
// biome-ignore lint: disable
|
||||||
|
export {}
|
||||||
|
|
||||||
|
/* prettier-ignore */
|
||||||
|
declare module 'vue' {
|
||||||
|
export interface GlobalComponents {
|
||||||
|
BaseHeader: typeof import('./components/layouts/BaseHeader.vue')['default']
|
||||||
|
BaseSide: typeof import('./components/layouts/BaseSide.vue')['default']
|
||||||
|
ElButton: typeof import('element-plus/es')['ElButton']
|
||||||
|
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||||
|
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||||
|
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||||
|
ElInput: typeof import('element-plus/es')['ElInput']
|
||||||
|
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||||
|
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||||
|
ElMenuItemGroup: typeof import('element-plus/es')['ElMenuItemGroup']
|
||||||
|
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||||
|
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||||
|
ElTag: typeof import('element-plus/es')['ElTag']
|
||||||
|
HelloWorld: typeof import('./components/HelloWorld.vue')['default']
|
||||||
|
Logos: typeof import('./components/Logos.vue')['default']
|
||||||
|
MessageBoxDemo: typeof import('./components/MessageBoxDemo.vue')['default']
|
||||||
|
RouterLink: typeof import('vue-router')['RouterLink']
|
||||||
|
RouterView: typeof import('vue-router')['RouterView']
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
defineProps<{ msg: string }>()
|
||||||
|
|
||||||
|
const count = ref(0)
|
||||||
|
const input = ref('element-plus')
|
||||||
|
|
||||||
|
const curDate = ref('')
|
||||||
|
|
||||||
|
function toast() {
|
||||||
|
ElMessage.success('Hello')
|
||||||
|
}
|
||||||
|
|
||||||
|
const value1 = ref(true)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<h1 color="$ep-color-primary">
|
||||||
|
{{ msg }}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
See
|
||||||
|
<a href="https://element-plus.org" target="_blank">element-plus</a> for more
|
||||||
|
information.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- example components -->
|
||||||
|
<div class="mb-4">
|
||||||
|
<el-button size="large" @click="toast">
|
||||||
|
El Message
|
||||||
|
</el-button>
|
||||||
|
|
||||||
|
<MessageBoxDemo />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="my-2 flex flex-wrap items-center justify-center text-center">
|
||||||
|
<el-button @click="count++">
|
||||||
|
count is: {{ count }}
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" @click="count++">
|
||||||
|
count is: {{ count }}
|
||||||
|
</el-button>
|
||||||
|
<el-button type="success" @click="count++">
|
||||||
|
count is: {{ count }}
|
||||||
|
</el-button>
|
||||||
|
<el-button type="warning" @click="count++">
|
||||||
|
count is: {{ count }}
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" @click="count++">
|
||||||
|
count is: {{ count }}
|
||||||
|
</el-button>
|
||||||
|
<el-button type="info" @click="count++">
|
||||||
|
count is: {{ count }}
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<el-tag type="success" class="m-1">
|
||||||
|
Tag 1
|
||||||
|
</el-tag>
|
||||||
|
<el-tag type="warning" class="m-1">
|
||||||
|
Tag 1
|
||||||
|
</el-tag>
|
||||||
|
<el-tag type="danger" class="m-1">
|
||||||
|
Tag 1
|
||||||
|
</el-tag>
|
||||||
|
<el-tag type="info" class="m-1">
|
||||||
|
Tag 1
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<el-switch v-model="value1" />
|
||||||
|
<el-switch
|
||||||
|
v-model="value1"
|
||||||
|
class="m-2"
|
||||||
|
style="--ep-switch-on-color: black; --ep-switch-off-color: gray;"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="my-2">
|
||||||
|
<el-input v-model="input" class="m-2" style="width: 200px" />
|
||||||
|
<el-date-picker
|
||||||
|
v-model="curDate"
|
||||||
|
class="m-2"
|
||||||
|
type="date"
|
||||||
|
placeholder="Pick a day"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>For example, we can custom primary color to 'green'.</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Edit
|
||||||
|
<code>components/HelloWorld.vue</code> to test components.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Edit
|
||||||
|
<code>styles/element/var.scss</code> to test scss variables.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Full Example:
|
||||||
|
<a
|
||||||
|
href="https://github.com/element-plus/element-plus-vite-starter"
|
||||||
|
target="_blank"
|
||||||
|
>element-plus-vite-starter</a>
|
||||||
|
| On demand Example:
|
||||||
|
<a
|
||||||
|
href="https://github.com/element-plus/unplugin-element-plus"
|
||||||
|
target="_blank"
|
||||||
|
>unplugin-element-plus/examples/vite</a>
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.ep-button {
|
||||||
|
margin: 4px;
|
||||||
|
}
|
||||||
|
.ep-button + .ep-button {
|
||||||
|
margin-left: 0;
|
||||||
|
margin: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<a href="https://vitejs.dev" target="_blank">
|
||||||
|
<img src="/vite.svg" class="logo" alt="Vite logo">
|
||||||
|
</a>
|
||||||
|
<a href="https://vuejs.org/" target="_blank">
|
||||||
|
<img src="../assets/vue.svg" class="logo vue" alt="Vue logo">
|
||||||
|
</a>
|
||||||
|
<a href="https://element-plus.org/" target="_blank">
|
||||||
|
<img src="/element-plus-logo-small.svg" class="logo element-plus" alt="Element Plus logo">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.logo {
|
||||||
|
height: 6em;
|
||||||
|
padding: 1.5em;
|
||||||
|
will-change: filter;
|
||||||
|
transition: filter 300ms;
|
||||||
|
}
|
||||||
|
.logo:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #646cffaa);
|
||||||
|
}
|
||||||
|
.logo.vue:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #42b883aa);
|
||||||
|
}
|
||||||
|
.logo.element-plus:hover {
|
||||||
|
filter: drop-shadow(0 0 2em #409effaa);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import type { Action } from 'element-plus'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
ElMessageBox.alert('This is a message', 'Title', {
|
||||||
|
// if you want to disable its autofocus
|
||||||
|
// autofocus: false,
|
||||||
|
confirmButtonText: 'OK',
|
||||||
|
callback: (action: Action) => {
|
||||||
|
ElMessage({
|
||||||
|
type: 'info',
|
||||||
|
message: `action: ${action}`,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-button plain @click="open">
|
||||||
|
Click to open the Message Box
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { repository } from '~/../package.json'
|
||||||
|
|
||||||
|
import { toggleDark } from '~/composables'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-menu class="el-menu-demo" mode="horizontal" :ellipsis="false" router>
|
||||||
|
<el-menu-item index="/">
|
||||||
|
<div class="flex items-center justify-center gap-2">
|
||||||
|
<div class="text-xl" i-ep-element-plus />
|
||||||
|
<span>Element Plus</span>
|
||||||
|
</div>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-sub-menu index="2">
|
||||||
|
<template #title>
|
||||||
|
Workspace
|
||||||
|
</template>
|
||||||
|
<el-menu-item index="2-1">
|
||||||
|
item one
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="2-2">
|
||||||
|
item two
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="2-3">
|
||||||
|
item three
|
||||||
|
</el-menu-item>
|
||||||
|
<el-sub-menu index="2-4">
|
||||||
|
<template #title>
|
||||||
|
item four
|
||||||
|
</template>
|
||||||
|
<el-menu-item index="2-4-1">
|
||||||
|
item one
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="2-4-2">
|
||||||
|
item two
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="2-4-3">
|
||||||
|
item three
|
||||||
|
</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
|
</el-sub-menu>
|
||||||
|
<el-menu-item index="3" disabled>
|
||||||
|
Info
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="4">
|
||||||
|
Orders
|
||||||
|
</el-menu-item>
|
||||||
|
|
||||||
|
<el-menu-item h="full" @click="toggleDark()">
|
||||||
|
<button
|
||||||
|
class="w-full cursor-pointer border-none bg-transparent"
|
||||||
|
style="height: var(--ep-menu-item-height)"
|
||||||
|
>
|
||||||
|
<i inline-flex i="dark:ep-moon ep-sunny" />
|
||||||
|
</button>
|
||||||
|
</el-menu-item>
|
||||||
|
|
||||||
|
<el-menu-item h="full">
|
||||||
|
<a class="size-full flex items-center justify-center" :href="repository.url" target="_blank">
|
||||||
|
<div i-ri-github-fill />
|
||||||
|
</a>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.el-menu-demo {
|
||||||
|
&.ep-menu--horizontal > .ep-menu-item:nth-child(1) {
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import {
|
||||||
|
Document,
|
||||||
|
Menu as IconMenu,
|
||||||
|
Location,
|
||||||
|
Setting,
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
|
||||||
|
// const isCollapse = ref(true)
|
||||||
|
function handleOpen(key: string, keyPath: string[]) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(key, keyPath)
|
||||||
|
}
|
||||||
|
function handleClose(key: string, keyPath: string[]) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.log(key, keyPath)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-menu
|
||||||
|
router
|
||||||
|
default-active="1"
|
||||||
|
class="el-menu-vertical-demo"
|
||||||
|
@open="handleOpen"
|
||||||
|
@close="handleClose"
|
||||||
|
>
|
||||||
|
<el-sub-menu index="1">
|
||||||
|
<template #title>
|
||||||
|
<el-icon>
|
||||||
|
<Location />
|
||||||
|
</el-icon>
|
||||||
|
<span>Navigator One</span>
|
||||||
|
</template>
|
||||||
|
<el-menu-item-group>
|
||||||
|
<template #title>
|
||||||
|
<span>Group One</span>
|
||||||
|
</template>
|
||||||
|
<el-menu-item index="/nav/1/item-1">
|
||||||
|
item one
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="1-2">
|
||||||
|
item two
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu-item-group>
|
||||||
|
<el-menu-item-group title="Group Two">
|
||||||
|
<el-menu-item index="1-3">
|
||||||
|
item three
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu-item-group>
|
||||||
|
<el-sub-menu index="1-4">
|
||||||
|
<template #title>
|
||||||
|
<span>item four</span>
|
||||||
|
</template>
|
||||||
|
<el-menu-item index="1-4-1">
|
||||||
|
item one
|
||||||
|
</el-menu-item>
|
||||||
|
</el-sub-menu>
|
||||||
|
</el-sub-menu>
|
||||||
|
<el-menu-item index="/nav/2">
|
||||||
|
<el-icon>
|
||||||
|
<IconMenu />
|
||||||
|
</el-icon>
|
||||||
|
<template #title>
|
||||||
|
Navigator Two
|
||||||
|
</template>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="3" disabled>
|
||||||
|
<el-icon>
|
||||||
|
<Document />
|
||||||
|
</el-icon>
|
||||||
|
<template #title>
|
||||||
|
Navigator Three
|
||||||
|
</template>
|
||||||
|
</el-menu-item>
|
||||||
|
<el-menu-item index="/nav/4">
|
||||||
|
<el-icon>
|
||||||
|
<Setting />
|
||||||
|
</el-icon>
|
||||||
|
<template #title>
|
||||||
|
Navigator Four
|
||||||
|
</template>
|
||||||
|
</el-menu-item>
|
||||||
|
</el-menu>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,595 @@
|
|||||||
|
import { clearAdminToken, getAdminToken, setAdminToken, setAdminUser } from './admin-auth'
|
||||||
|
|
||||||
|
const API_BASE
|
||||||
|
= import.meta.env.VITE_API_BASE
|
||||||
|
|| (import.meta.env.DEV ? 'http://127.0.0.1:8000/api/v1' : '/api/v1')
|
||||||
|
|
||||||
|
type RequestError = Error & { status?: number }
|
||||||
|
|
||||||
|
async function request<T = any>(path: string, init: RequestInit = {}) {
|
||||||
|
const token = getAdminToken()
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(init.headers as Record<string, string> || {}),
|
||||||
|
}
|
||||||
|
if (token)
|
||||||
|
headers.Authorization = `Bearer ${token}`
|
||||||
|
|
||||||
|
let resp: Response
|
||||||
|
try {
|
||||||
|
resp = await fetch(`${API_BASE}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
throw new Error(`接口连接失败:${API_BASE}${path}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.status === 401) {
|
||||||
|
clearAdminToken()
|
||||||
|
throw new Error('登录已失效,请重新登录')
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await resp.json().catch(() => ({}))
|
||||||
|
if (!resp.ok) {
|
||||||
|
const message = data?.detail || data?.message || `请求失败(${resp.status})`
|
||||||
|
const error = new Error(message) as RequestError
|
||||||
|
error.status = resp.status
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
return data as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRequestNotFound(error: unknown) {
|
||||||
|
return (error as RequestError)?.status === 404
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminLogin(account: string, password: string) {
|
||||||
|
const data = await request<{
|
||||||
|
access_token: string
|
||||||
|
refresh_token: string
|
||||||
|
user: Record<string, any>
|
||||||
|
}>('/admin/auth/login', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ account, password }),
|
||||||
|
})
|
||||||
|
setAdminToken(data.access_token)
|
||||||
|
setAdminUser(data.user)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminMe() {
|
||||||
|
return request('/admin/auth/me')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminDashboard() {
|
||||||
|
return request('/admin/dashboard')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminModuleDesign() {
|
||||||
|
return request('/admin/module-design')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminListUsers(params: Record<string, any>) {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
Object.entries(params || {}).forEach(([k, v]) => {
|
||||||
|
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||||
|
q.set(k, String(v))
|
||||||
|
})
|
||||||
|
return request(`/admin/users?${q.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminCreateUser(payload: Record<string, any>) {
|
||||||
|
return request('/admin/users', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminUpdateUser(userId: number, payload: Record<string, any>) {
|
||||||
|
return request(`/admin/users/${userId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminDeleteUser(userId: number) {
|
||||||
|
return request(`/admin/users/${userId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminUserOptions(keyword = '') {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
if (keyword.trim())
|
||||||
|
q.set('keyword', keyword.trim())
|
||||||
|
return request(`/admin/user-options?${q.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminListSpots(params: Record<string, any>) {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
Object.entries(params || {}).forEach(([k, v]) => {
|
||||||
|
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||||
|
q.set(k, String(v))
|
||||||
|
})
|
||||||
|
return request(`/admin/spots?${q.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminGetSpot(spotId: number) {
|
||||||
|
return request(`/spots/${spotId}`).then((detail: any) => ({
|
||||||
|
id: detail.id,
|
||||||
|
title: detail.title || '',
|
||||||
|
city: detail.city || '',
|
||||||
|
creator_id: detail.creator?.id || detail.creator_id || 0,
|
||||||
|
longitude: detail.longitude ?? 0,
|
||||||
|
latitude: detail.latitude ?? 0,
|
||||||
|
description: detail.description || '',
|
||||||
|
transport: detail.transport || '',
|
||||||
|
best_time: detail.best_time || '',
|
||||||
|
difficulty: detail.difficulty || '',
|
||||||
|
is_free: detail.is_free ?? true,
|
||||||
|
price_min: detail.price_min ?? null,
|
||||||
|
price_max: detail.price_max ?? null,
|
||||||
|
audit_status: detail.audit_status || 'pending',
|
||||||
|
reject_reason: detail.reject_reason || '',
|
||||||
|
tag_ids: (detail.tags || []).map((t: any) => t.id),
|
||||||
|
image_urls: (detail.images || []).map((img: any) => img.image_url),
|
||||||
|
images: detail.images || [],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminCreateSpot(payload: Record<string, any>) {
|
||||||
|
return request('/admin/spots', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminUpdateSpot(spotId: number, payload: Record<string, any>) {
|
||||||
|
return request(`/admin/spots/${spotId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminDeleteSpot(spotId: number) {
|
||||||
|
return request(`/admin/spots/${spotId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminSpotTagOptions(keyword = '') {
|
||||||
|
return request('/tags?sort=hot').then((items: any[]) => {
|
||||||
|
const key = keyword.trim().toLowerCase()
|
||||||
|
const normalized = (items || []).map((item: any) => ({
|
||||||
|
id: item.id,
|
||||||
|
title: item.name || item.title || '',
|
||||||
|
}))
|
||||||
|
if (!key)
|
||||||
|
return normalized
|
||||||
|
return normalized.filter(item => String(item.title).toLowerCase().includes(key))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminAuditSpot(spotId: number, payload: { audit_status: string, reject_reason?: string }) {
|
||||||
|
return request(`/admin/spots/${spotId}/audit`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminBatchAuditSpots(payload: { ids: number[], audit_status: string, reject_reason?: string }) {
|
||||||
|
return request('/admin/spots/batch-audit', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminListEvents(params: Record<string, any>) {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
Object.entries(params || {}).forEach(([k, v]) => {
|
||||||
|
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||||
|
q.set(k, String(v))
|
||||||
|
})
|
||||||
|
return request(`/admin/events?${q.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminGetEvent(eventId: number) {
|
||||||
|
return request(`/admin/events/${eventId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminCreateEvent(payload: Record<string, any>) {
|
||||||
|
return request('/admin/events', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminUpdateEvent(eventId: number, payload: Record<string, any>) {
|
||||||
|
return request(`/admin/events/${eventId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminDeleteEvent(eventId: number) {
|
||||||
|
return request(`/admin/events/${eventId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminListEventRegistrations(eventId: number) {
|
||||||
|
return request(`/admin/events/${eventId}/registrations`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminCreateEventRegistration(eventId: number, payload: Record<string, any>) {
|
||||||
|
return request(`/admin/events/${eventId}/registrations`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminUpdateEventRegistration(eventId: number, registrationId: number, payload: Record<string, any>) {
|
||||||
|
return request(`/admin/events/${eventId}/registrations/${registrationId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminDeleteEventRegistration(eventId: number, registrationId: number) {
|
||||||
|
return request(`/admin/events/${eventId}/registrations/${registrationId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminListEventPhotos(eventId: number) {
|
||||||
|
return request(`/admin/events/${eventId}/photos`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminCreateEventPhoto(eventId: number, payload: Record<string, any>) {
|
||||||
|
return request(`/admin/events/${eventId}/photos`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminUpdateEventPhoto(eventId: number, photoId: number, payload: Record<string, any>) {
|
||||||
|
return request(`/admin/events/${eventId}/photos/${photoId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminDeleteEventPhoto(eventId: number, photoId: number) {
|
||||||
|
return request(`/admin/events/${eventId}/photos/${photoId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminAuditEvent(eventId: number, payload: { audit_status: string, reject_reason?: string }) {
|
||||||
|
return request(`/admin/events/${eventId}/audit`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminBatchAuditEvents(payload: { ids: number[], audit_status: string, reject_reason?: string }) {
|
||||||
|
return request('/admin/events/batch-audit', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminListShooting(params: Record<string, any>) {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
Object.entries(params || {}).forEach(([k, v]) => {
|
||||||
|
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||||
|
q.set(k, String(v))
|
||||||
|
})
|
||||||
|
return request(`/admin/shooting?${q.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminGetShooting(requestId: number) {
|
||||||
|
return request(`/admin/shooting/${requestId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminCreateShooting(payload: Record<string, any>) {
|
||||||
|
return request('/admin/shooting', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminUpdateShooting(requestId: number, payload: Record<string, any>) {
|
||||||
|
return request(`/admin/shooting/${requestId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminDeleteShooting(requestId: number) {
|
||||||
|
return request(`/admin/shooting/${requestId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminListShootingApplications(requestId: number) {
|
||||||
|
return request(`/admin/shooting/${requestId}/applications`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminCreateShootingApplication(requestId: number, payload: Record<string, any>) {
|
||||||
|
return request(`/admin/shooting/${requestId}/applications`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminUpdateShootingApplication(requestId: number, applicationId: number, payload: Record<string, any>) {
|
||||||
|
return request(`/admin/shooting/${requestId}/applications/${applicationId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminDeleteShootingApplication(requestId: number, applicationId: number) {
|
||||||
|
return request(`/admin/shooting/${requestId}/applications/${applicationId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminAuditShooting(requestId: number, payload: { audit_status: string, reject_reason?: string }) {
|
||||||
|
return request(`/admin/shooting/${requestId}/audit`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminBatchAuditShooting(payload: { ids: number[], audit_status: string, reject_reason?: string }) {
|
||||||
|
return request('/admin/shooting/batch-audit', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminListAppNavConfigs(params: Record<string, any>) {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
Object.entries(params || {}).forEach(([k, v]) => {
|
||||||
|
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||||
|
q.set(k, String(v))
|
||||||
|
})
|
||||||
|
return request(`/admin/app-nav-configs?${q.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminCreateAppNavConfig(payload: Record<string, any>) {
|
||||||
|
return request('/admin/app-nav-configs', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminUpdateAppNavConfig(id: number, payload: Record<string, any>) {
|
||||||
|
return request(`/admin/app-nav-configs/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminDeleteAppNavConfig(id: number) {
|
||||||
|
return request(`/admin/app-nav-configs/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminListPromotions(params: Record<string, any>) {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
Object.entries(params || {}).forEach(([k, v]) => {
|
||||||
|
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||||
|
q.set(k, String(v))
|
||||||
|
})
|
||||||
|
return request(`/admin/promotions?${q.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminCreatePromotion(payload: Record<string, any>) {
|
||||||
|
return request('/admin/promotions', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminUpdatePromotion(id: number, payload: Record<string, any>) {
|
||||||
|
return request(`/admin/promotions/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminDeletePromotion(id: number) {
|
||||||
|
return request(`/admin/promotions/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminPromotionLinkOptions(linkType: 'spot' | 'event' | 'shooting', keyword = '') {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
q.set('link_type', linkType)
|
||||||
|
if (keyword.trim())
|
||||||
|
q.set('keyword', keyword.trim())
|
||||||
|
return request(`/admin/promotion-link-options?${q.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminListMembershipPlans(params: Record<string, any>) {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
Object.entries(params || {}).forEach(([k, v]) => {
|
||||||
|
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||||
|
q.set(k, String(v))
|
||||||
|
})
|
||||||
|
return request(`/admin/membership/plans?${q.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminCreateMembershipPlan(payload: Record<string, any>) {
|
||||||
|
return request('/admin/membership/plans', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminUpdateMembershipPlan(id: number, payload: Record<string, any>) {
|
||||||
|
return request(`/admin/membership/plans/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminDeleteMembershipPlan(id: number) {
|
||||||
|
return request(`/admin/membership/plans/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminListUserMemberships(params: Record<string, any>) {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
Object.entries(params || {}).forEach(([k, v]) => {
|
||||||
|
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||||
|
q.set(k, String(v))
|
||||||
|
})
|
||||||
|
return request(`/admin/membership/user-memberships?${q.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminCreateUserMembership(payload: Record<string, any>) {
|
||||||
|
return request('/admin/membership/user-memberships', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminUpdateUserMembership(id: number, payload: Record<string, any>) {
|
||||||
|
return request(`/admin/membership/user-memberships/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminDeleteUserMembership(id: number) {
|
||||||
|
return request(`/admin/membership/user-memberships/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminListPointLedger(params: Record<string, any>) {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
Object.entries(params || {}).forEach(([k, v]) => {
|
||||||
|
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||||
|
q.set(k, String(v))
|
||||||
|
})
|
||||||
|
return request(`/admin/points/ledger?${q.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminAdjustPoints(payload: Record<string, any>) {
|
||||||
|
return request('/admin/points/adjust', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminRollbackPointLedger(ledgerId: number) {
|
||||||
|
return request(`/admin/points/ledger/${ledgerId}/rollback`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminListNotifications(params: Record<string, any>) {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
Object.entries(params || {}).forEach(([k, v]) => {
|
||||||
|
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||||
|
q.set(k, String(v))
|
||||||
|
})
|
||||||
|
return request(`/admin/notifications?${q.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminCreateNotification(payload: Record<string, any>) {
|
||||||
|
return request('/admin/notifications', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminUpdateNotification(id: number, payload: Record<string, any>) {
|
||||||
|
return request(`/admin/notifications/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminDeleteNotification(id: number) {
|
||||||
|
return request(`/admin/notifications/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminListAuditLogs(params: Record<string, any>) {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
Object.entries(params || {}).forEach(([k, v]) => {
|
||||||
|
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||||
|
q.set(k, String(v))
|
||||||
|
})
|
||||||
|
return request(`/admin/audit-logs?${q.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminCreateAuditLog(payload: Record<string, any>) {
|
||||||
|
return request('/admin/audit-logs', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminListReports(params: Record<string, any>) {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
Object.entries(params || {}).forEach(([k, v]) => {
|
||||||
|
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||||
|
q.set(k, String(v))
|
||||||
|
})
|
||||||
|
return request(`/admin/reports?${q.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminUpdateReport(id: number, payload: Record<string, any>) {
|
||||||
|
return request(`/admin/reports/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminDeleteReport(id: number) {
|
||||||
|
return request(`/admin/reports/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminListSystemConfigs(params: Record<string, any>) {
|
||||||
|
const q = new URLSearchParams()
|
||||||
|
Object.entries(params || {}).forEach(([k, v]) => {
|
||||||
|
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||||
|
q.set(k, String(v))
|
||||||
|
})
|
||||||
|
return request(`/admin/system-configs?${q.toString()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminCreateSystemConfig(payload: Record<string, any>) {
|
||||||
|
return request('/admin/system-configs', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminUpdateSystemConfig(id: number, payload: Record<string, any>) {
|
||||||
|
return request(`/admin/system-configs/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function adminDeleteSystemConfig(id: number) {
|
||||||
|
return request(`/admin/system-configs/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
const ADMIN_TOKEN_KEY = 'admin_access_token'
|
||||||
|
const ADMIN_USER_KEY = 'admin_user'
|
||||||
|
|
||||||
|
export function getAdminToken() {
|
||||||
|
if (typeof window === 'undefined')
|
||||||
|
return ''
|
||||||
|
return localStorage.getItem(ADMIN_TOKEN_KEY) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAdminToken(token: string) {
|
||||||
|
if (typeof window === 'undefined')
|
||||||
|
return
|
||||||
|
localStorage.setItem(ADMIN_TOKEN_KEY, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearAdminToken() {
|
||||||
|
if (typeof window === 'undefined')
|
||||||
|
return
|
||||||
|
localStorage.removeItem(ADMIN_TOKEN_KEY)
|
||||||
|
localStorage.removeItem(ADMIN_USER_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setAdminUser(user: Record<string, any>) {
|
||||||
|
if (typeof window === 'undefined')
|
||||||
|
return
|
||||||
|
localStorage.setItem(ADMIN_USER_KEY, JSON.stringify(user || {}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAdminUser() {
|
||||||
|
if (typeof window === 'undefined')
|
||||||
|
return null
|
||||||
|
const raw = localStorage.getItem(ADMIN_USER_KEY)
|
||||||
|
if (!raw)
|
||||||
|
return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw)
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { useDark, useToggle } from '@vueuse/core'
|
||||||
|
|
||||||
|
export const isDark = useDark()
|
||||||
|
export const toggleDark = useToggle(isDark)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './dark'
|
||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.vue' {
|
||||||
|
import type { DefineComponent } from 'vue'
|
||||||
|
|
||||||
|
const component: DefineComponent<object, object, any>
|
||||||
|
export default component
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { Bell, DataAnalysis, HelpFilled, Location, Promotion, SetUp, Star, Tickets, User, Warning } from '@element-plus/icons-vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { clearAdminToken, getAdminUser } from '~/composables/admin-auth'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const adminUser = computed(() => getAdminUser() || {})
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ key: '/dashboard', label: 'Dashboard', icon: DataAnalysis },
|
||||||
|
{ key: '/spots', label: '取景地管理', icon: Location },
|
||||||
|
{ key: '/events', label: '活动管理', icon: Promotion },
|
||||||
|
{ key: '/shooting', label: '约拍管理', icon: User },
|
||||||
|
{ key: '/users', label: '用户与权限', icon: User },
|
||||||
|
{ key: '/membership', label: '会员体系', icon: Star },
|
||||||
|
{ key: '/ops', label: '消息与风控', icon: Warning },
|
||||||
|
{ key: '/promotions', label: '推广位管理', icon: Promotion },
|
||||||
|
{ key: '/nav-configs', label: '导航配置', icon: SetUp },
|
||||||
|
{ key: '/module-design', label: '模块整合', icon: Tickets },
|
||||||
|
]
|
||||||
|
|
||||||
|
function go(path: string) {
|
||||||
|
if (route.path === path)
|
||||||
|
return
|
||||||
|
router.push(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
clearAdminToken()
|
||||||
|
router.replace('/login')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="admin-shell">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-logo">
|
||||||
|
CV
|
||||||
|
</div>
|
||||||
|
<div class="brand-text">
|
||||||
|
<h1>次元取景器</h1>
|
||||||
|
<p>Admin Console</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav class="nav">
|
||||||
|
<button
|
||||||
|
v-for="item in menuItems"
|
||||||
|
:key="item.key"
|
||||||
|
class="nav-item"
|
||||||
|
:class="{ active: route.path === item.key }"
|
||||||
|
@click="go(item.key)"
|
||||||
|
>
|
||||||
|
<span class="nav-icon">
|
||||||
|
<el-icon><component :is="item.icon" /></el-icon>
|
||||||
|
</span>
|
||||||
|
<span>{{ item.label }}</span>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="search-wrap">
|
||||||
|
<input class="global-search" placeholder="搜索功能、页面或数据..." />
|
||||||
|
</div>
|
||||||
|
<div class="top-actions">
|
||||||
|
<button class="icon-btn" title="通知">
|
||||||
|
<el-icon><Bell /></el-icon>
|
||||||
|
</button>
|
||||||
|
<button class="icon-btn" title="帮助">
|
||||||
|
<el-icon><HelpFilled /></el-icon>
|
||||||
|
</button>
|
||||||
|
<div class="user-box">
|
||||||
|
<div class="avatar">
|
||||||
|
{{ (adminUser.nickname || 'A').slice(0, 1) }}
|
||||||
|
</div>
|
||||||
|
<div class="user-meta">
|
||||||
|
<strong>{{ adminUser.nickname || '管理员' }}</strong>
|
||||||
|
<small>{{ adminUser.role || 'admin' }}</small>
|
||||||
|
</div>
|
||||||
|
<button class="logout-btn" @click="logout">
|
||||||
|
退出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="content">
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { createApp } from 'vue'
|
||||||
|
import ElementPlus from 'element-plus'
|
||||||
|
import 'element-plus/dist/index.css'
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
import '~/styles/index.scss'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
app.use(router)
|
||||||
|
app.use(ElementPlus)
|
||||||
|
app.mount('#app')
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { adminDashboard } from '~/composables/admin-api'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const stats = ref({
|
||||||
|
spots_total: 0,
|
||||||
|
spots_pending: 0,
|
||||||
|
spots_approved: 0,
|
||||||
|
spots_rejected: 0,
|
||||||
|
users_total: 0,
|
||||||
|
events_total: 0,
|
||||||
|
shooting_total: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const trends = ref([
|
||||||
|
{ day: 'Mon', value: 12 },
|
||||||
|
{ day: 'Tue', value: 18 },
|
||||||
|
{ day: 'Wed', value: 15 },
|
||||||
|
{ day: 'Thu', value: 24 },
|
||||||
|
{ day: 'Fri', value: 21 },
|
||||||
|
{ day: 'Sat', value: 17 },
|
||||||
|
{ day: 'Sun', value: 26 },
|
||||||
|
])
|
||||||
|
|
||||||
|
const activities = ref([
|
||||||
|
{ id: 1, text: '审核员通过了 3 条取景地投稿', time: '5 分钟前' },
|
||||||
|
{ id: 2, text: '用户提交了新的活动内容', time: '16 分钟前' },
|
||||||
|
{ id: 3, text: '推广位配置已更新', time: '32 分钟前' },
|
||||||
|
{ id: 4, text: '约拍申请增加 12 条', time: '1 小时前' },
|
||||||
|
])
|
||||||
|
|
||||||
|
const teams = ref([
|
||||||
|
{ id: 1, name: '内容审核组', count: 6 },
|
||||||
|
{ id: 2, name: '运营增长组', count: 4 },
|
||||||
|
{ id: 3, name: '社区支持组', count: 3 },
|
||||||
|
])
|
||||||
|
|
||||||
|
async function loadDashboard() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await adminDashboard()
|
||||||
|
stats.value = data
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '加载 Dashboard 失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadDashboard)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-head">
|
||||||
|
<h2>Dashboard</h2>
|
||||||
|
<p>管理核心指标、审核进度、动态流与团队协作入口</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="kpi-grid">
|
||||||
|
<article class="kpi panel-card">
|
||||||
|
<small>总取景地</small>
|
||||||
|
<h3>{{ stats.spots_total }}</h3>
|
||||||
|
<span>全部内容资产</span>
|
||||||
|
</article>
|
||||||
|
<article class="kpi panel-card">
|
||||||
|
<small>待审核</small>
|
||||||
|
<h3 class="warn">{{ stats.spots_pending }}</h3>
|
||||||
|
<span>需优先处理</span>
|
||||||
|
</article>
|
||||||
|
<article class="kpi panel-card">
|
||||||
|
<small>用户规模</small>
|
||||||
|
<h3>{{ stats.users_total }}</h3>
|
||||||
|
<span>累计注册用户</span>
|
||||||
|
</article>
|
||||||
|
<article class="kpi panel-card">
|
||||||
|
<small>活动 / 约拍</small>
|
||||||
|
<h3>{{ stats.events_total }} / {{ stats.shooting_total }}</h3>
|
||||||
|
<span>社区供给情况</span>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dash-grid">
|
||||||
|
<article class="chart panel-card">
|
||||||
|
<header>
|
||||||
|
<h4>审核趋势(7天)</h4>
|
||||||
|
<span>单位:条</span>
|
||||||
|
</header>
|
||||||
|
<div class="chart-bars">
|
||||||
|
<div v-for="item in trends" :key="item.day" class="bar-col">
|
||||||
|
<div class="bar" :style="{ height: `${item.value * 3}px` }" />
|
||||||
|
<small>{{ item.day }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="activity panel-card">
|
||||||
|
<header>
|
||||||
|
<h4>动态流</h4>
|
||||||
|
</header>
|
||||||
|
<ul>
|
||||||
|
<li v-for="a in activities" :key="a.id">
|
||||||
|
<span>{{ a.text }}</span>
|
||||||
|
<small>{{ a.time }}</small>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="team panel-card">
|
||||||
|
<header>
|
||||||
|
<h4>团队入口</h4>
|
||||||
|
</header>
|
||||||
|
<div class="team-list">
|
||||||
|
<button v-for="t in teams" :key="t.id" class="team-item">
|
||||||
|
<strong>{{ t.name }}</strong>
|
||||||
|
<small>{{ t.count }} 人</small>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi {
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi small {
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi h3 {
|
||||||
|
margin: 10px 0 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi h3.warn {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
.kpi span {
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dash-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart,
|
||||||
|
.activity,
|
||||||
|
.team {
|
||||||
|
background: #fff;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
header span {
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-bars {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
height: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: linear-gradient(180deg, #53a5ff, #1677ff);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-col small {
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity ul {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity li {
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--border-split);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity li:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.activity small {
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-list {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-item {
|
||||||
|
height: 78px;
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
border-radius: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-item:hover {
|
||||||
|
border-color: rgba(22, 119, 255, 0.35);
|
||||||
|
box-shadow: var(--shadow-card-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.team-item small {
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.kpi-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.dash-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.kpi-grid,
|
||||||
|
.team-list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { getAdminToken } from '~/composables/admin-auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!getAdminToken()) {
|
||||||
|
router.replace('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
router.replace('/spots')
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { adminLogin } from '~/composables/admin-api'
|
||||||
|
import { getAdminToken } from '~/composables/admin-auth'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
if (getAdminToken())
|
||||||
|
router.replace('/spots')
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const form = reactive({
|
||||||
|
account: '',
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
if (!form.account.trim() || !form.password.trim()) {
|
||||||
|
ElMessage.error('请输入账号和密码')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await adminLogin(form.account.trim(), form.password)
|
||||||
|
ElMessage.success('登录成功')
|
||||||
|
router.replace('/spots')
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '登录失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="login-page">
|
||||||
|
<el-card class="login-card" shadow="hover">
|
||||||
|
<template #header>
|
||||||
|
<div class="card-header">
|
||||||
|
<span>管理后台登录</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<el-form label-position="top" class="admin-form">
|
||||||
|
<el-form-item label="账号(手机号或邮箱)">
|
||||||
|
<el-input v-model="form.account" clearable placeholder="请输入管理员账号" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="密码">
|
||||||
|
<el-input v-model="form.password" type="password" show-password placeholder="请输入密码" @keyup.enter="handleLogin" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-button type="primary" :loading="loading" class="login-btn" @click="handleLogin">
|
||||||
|
登录
|
||||||
|
</el-button>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f3f6fb;
|
||||||
|
}
|
||||||
|
.login-card {
|
||||||
|
width: 420px;
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--border-split);
|
||||||
|
box-shadow: 0 12px 34px rgba(22, 119, 255, 0.08);
|
||||||
|
}
|
||||||
|
.card-header {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.login-btn {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,677 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
adminAdjustPoints,
|
||||||
|
adminCreateMembershipPlan,
|
||||||
|
adminCreateUserMembership,
|
||||||
|
adminDeleteMembershipPlan,
|
||||||
|
adminDeleteUserMembership,
|
||||||
|
adminListMembershipPlans,
|
||||||
|
adminListPointLedger,
|
||||||
|
adminRollbackPointLedger,
|
||||||
|
adminListUserMemberships,
|
||||||
|
adminUpdateMembershipPlan,
|
||||||
|
adminUpdateUserMembership,
|
||||||
|
adminUserOptions,
|
||||||
|
} from '~/composables/admin-api'
|
||||||
|
|
||||||
|
const activeTab = ref('plans')
|
||||||
|
|
||||||
|
const plansLoading = ref(false)
|
||||||
|
const plans = ref<any[]>([])
|
||||||
|
const planTotal = ref(0)
|
||||||
|
const planFilters = reactive({ page: 1, page_size: 20, keyword: '', is_active: '' })
|
||||||
|
const planDialogVisible = ref(false)
|
||||||
|
const planSubmitting = ref(false)
|
||||||
|
const editingPlanId = ref<number | null>(null)
|
||||||
|
const planForm = reactive({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
duration_days: 30,
|
||||||
|
price: 0,
|
||||||
|
benefits: '',
|
||||||
|
extra_uploads: 0,
|
||||||
|
extra_top_count: 0,
|
||||||
|
is_active: true,
|
||||||
|
sort_order: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const userMembershipsLoading = ref(false)
|
||||||
|
const userMemberships = ref<any[]>([])
|
||||||
|
const userMembershipTotal = ref(0)
|
||||||
|
const userMembershipFilters = reactive({ page: 1, page_size: 20, user_id: null as number | null, plan_id: null as number | null, is_active: '' })
|
||||||
|
const userMembershipDialogVisible = ref(false)
|
||||||
|
const userMembershipSubmitting = ref(false)
|
||||||
|
const editingUserMembershipId = ref<number | null>(null)
|
||||||
|
const userMembershipForm = reactive({
|
||||||
|
user_id: null as number | null,
|
||||||
|
plan_id: null as number | null,
|
||||||
|
start_date: '',
|
||||||
|
end_date: '',
|
||||||
|
is_active: true,
|
||||||
|
})
|
||||||
|
const userOptions = ref<Array<{ id: number, title: string }>>([])
|
||||||
|
|
||||||
|
const ledgerLoading = ref(false)
|
||||||
|
const ledgerRows = ref<any[]>([])
|
||||||
|
const ledgerTotal = ref(0)
|
||||||
|
const ledgerFilters = reactive({ page: 1, page_size: 20, user_id: null as number | null, reason: '' })
|
||||||
|
const adjustDialogVisible = ref(false)
|
||||||
|
const adjustSubmitting = ref(false)
|
||||||
|
const adjustForm = reactive({
|
||||||
|
user_id: null as number | null,
|
||||||
|
change: 0,
|
||||||
|
reason: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadPlans() {
|
||||||
|
plansLoading.value = true
|
||||||
|
try {
|
||||||
|
const params: Record<string, any> = { ...planFilters }
|
||||||
|
if (planFilters.is_active !== '')
|
||||||
|
params.is_active = planFilters.is_active === 'true'
|
||||||
|
const data = await adminListMembershipPlans(params)
|
||||||
|
plans.value = data.items || []
|
||||||
|
planTotal.value = data.total || 0
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '会员套餐加载失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
plansLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUserMemberships() {
|
||||||
|
userMembershipsLoading.value = true
|
||||||
|
try {
|
||||||
|
const params: Record<string, any> = { ...userMembershipFilters }
|
||||||
|
if (!params.user_id)
|
||||||
|
delete params.user_id
|
||||||
|
if (!params.plan_id)
|
||||||
|
delete params.plan_id
|
||||||
|
if (params.is_active !== '')
|
||||||
|
params.is_active = params.is_active === 'true'
|
||||||
|
const data = await adminListUserMemberships(params)
|
||||||
|
userMemberships.value = data.items || []
|
||||||
|
userMembershipTotal.value = data.total || 0
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '用户会员加载失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
userMembershipsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLedger() {
|
||||||
|
ledgerLoading.value = true
|
||||||
|
try {
|
||||||
|
const params: Record<string, any> = { ...ledgerFilters }
|
||||||
|
if (!params.user_id)
|
||||||
|
delete params.user_id
|
||||||
|
const data = await adminListPointLedger(params)
|
||||||
|
ledgerRows.value = data.items || []
|
||||||
|
ledgerTotal.value = data.total || 0
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '积分流水加载失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
ledgerLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPlanForm() {
|
||||||
|
editingPlanId.value = null
|
||||||
|
planForm.name = ''
|
||||||
|
planForm.description = ''
|
||||||
|
planForm.duration_days = 30
|
||||||
|
planForm.price = 0
|
||||||
|
planForm.benefits = ''
|
||||||
|
planForm.extra_uploads = 0
|
||||||
|
planForm.extra_top_count = 0
|
||||||
|
planForm.is_active = true
|
||||||
|
planForm.sort_order = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreatePlan() {
|
||||||
|
resetPlanForm()
|
||||||
|
planDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditPlan(row: any) {
|
||||||
|
editingPlanId.value = row.id
|
||||||
|
planForm.name = row.name
|
||||||
|
planForm.description = row.description || ''
|
||||||
|
planForm.duration_days = row.duration_days
|
||||||
|
planForm.price = row.price
|
||||||
|
planForm.benefits = row.benefits || ''
|
||||||
|
planForm.extra_uploads = row.extra_uploads || 0
|
||||||
|
planForm.extra_top_count = row.extra_top_count || 0
|
||||||
|
planForm.is_active = row.is_active
|
||||||
|
planForm.sort_order = row.sort_order || 0
|
||||||
|
planDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitPlan() {
|
||||||
|
planSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const payload = { ...planForm }
|
||||||
|
if (editingPlanId.value) {
|
||||||
|
await adminUpdateMembershipPlan(editingPlanId.value, payload)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await adminCreateMembershipPlan(payload)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
planDialogVisible.value = false
|
||||||
|
loadPlans()
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '保存失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
planSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disablePlan(row: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认停用套餐「${row.name}」吗?`, '停用确认', { type: 'warning' })
|
||||||
|
await adminDeleteMembershipPlan(row.id)
|
||||||
|
ElMessage.success('已停用')
|
||||||
|
loadPlans()
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchUsers(keyword = '') {
|
||||||
|
userOptions.value = await adminUserOptions(keyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetUserMembershipForm() {
|
||||||
|
editingUserMembershipId.value = null
|
||||||
|
userMembershipForm.user_id = null
|
||||||
|
userMembershipForm.plan_id = null
|
||||||
|
userMembershipForm.start_date = ''
|
||||||
|
userMembershipForm.end_date = ''
|
||||||
|
userMembershipForm.is_active = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCreateUserMembership() {
|
||||||
|
resetUserMembershipForm()
|
||||||
|
userMembershipDialogVisible.value = true
|
||||||
|
await searchUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEditUserMembership(row: any) {
|
||||||
|
editingUserMembershipId.value = row.id
|
||||||
|
userMembershipForm.user_id = row.user_id
|
||||||
|
userMembershipForm.plan_id = row.plan_id
|
||||||
|
userMembershipForm.start_date = row.start_date
|
||||||
|
userMembershipForm.end_date = row.end_date
|
||||||
|
userMembershipForm.is_active = row.is_active
|
||||||
|
userMembershipDialogVisible.value = true
|
||||||
|
await searchUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitUserMembership() {
|
||||||
|
userMembershipSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const payload = { ...userMembershipForm }
|
||||||
|
if (editingUserMembershipId.value) {
|
||||||
|
await adminUpdateUserMembership(editingUserMembershipId.value, payload)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await adminCreateUserMembership(payload)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
userMembershipDialogVisible.value = false
|
||||||
|
loadUserMemberships()
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '保存失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
userMembershipSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function disableUserMembership(row: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认停用用户会员记录 #${row.id} 吗?`, '停用确认', { type: 'warning' })
|
||||||
|
await adminDeleteUserMembership(row.id)
|
||||||
|
ElMessage.success('已停用')
|
||||||
|
loadUserMemberships()
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openAdjustDialog() {
|
||||||
|
adjustForm.user_id = null
|
||||||
|
adjustForm.change = 0
|
||||||
|
adjustForm.reason = ''
|
||||||
|
adjustDialogVisible.value = true
|
||||||
|
await searchUsers()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAdjust() {
|
||||||
|
adjustSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await adminAdjustPoints(adjustForm)
|
||||||
|
ElMessage.success('调账成功')
|
||||||
|
adjustDialogVisible.value = false
|
||||||
|
loadLedger()
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '调账失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
adjustSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rollbackLedger(row: any) {
|
||||||
|
if (row.ref_type === 'points_rollback') {
|
||||||
|
ElMessage.error('回滚流水不可再次回滚')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (row.rolled_back) {
|
||||||
|
ElMessage.error('该流水已回滚')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认回滚积分流水 #${row.id} 吗?`, '回滚确认', { type: 'warning' })
|
||||||
|
await adminRollbackPointLedger(row.id)
|
||||||
|
ElMessage.success('回滚成功')
|
||||||
|
loadLedger()
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadPlans(), loadUserMemberships(), loadLedger()])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-head">
|
||||||
|
<h2>会员体系</h2>
|
||||||
|
<p>套餐管理、用户会员管理、积分流水与调账管理</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-tabs v-model="activeTab">
|
||||||
|
<el-tab-pane label="会员套餐" name="plans">
|
||||||
|
<el-card shadow="never" class="filter-card panel-card">
|
||||||
|
<el-form inline class="admin-form">
|
||||||
|
<div class="filter-grid">
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input v-model="planFilters.keyword" placeholder="套餐名称/描述" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="planFilters.is_active" clearable placeholder="全部">
|
||||||
|
<el-option label="启用" value="true" />
|
||||||
|
<el-option label="停用" value="false" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="planFilters.page = 1; loadPlans()">
|
||||||
|
查询
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="planFilters.keyword = ''; planFilters.is_active = ''; planFilters.page = 1; loadPlans()">
|
||||||
|
重置
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" plain @click="openCreatePlan">
|
||||||
|
新增套餐
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="panel-card">
|
||||||
|
<el-table :data="plans" v-loading="plansLoading">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="name" label="套餐名称" width="160" />
|
||||||
|
<el-table-column prop="duration_days" label="时长(天)" width="100" />
|
||||||
|
<el-table-column prop="price" label="价格" width="100" />
|
||||||
|
<el-table-column prop="extra_uploads" label="额外上传" width="100" />
|
||||||
|
<el-table-column prop="extra_top_count" label="额外置顶" width="100" />
|
||||||
|
<el-table-column prop="sort_order" label="排序" width="80" />
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.is_active ? 'success' : 'info'" effect="light" round>
|
||||||
|
{{ row.is_active ? '启用' : '停用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link @click="openEditPlan(row)">
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" link @click="disablePlan(row)">
|
||||||
|
停用
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
<div class="pager">
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
:total="planTotal"
|
||||||
|
:page-size="planFilters.page_size"
|
||||||
|
:current-page="planFilters.page"
|
||||||
|
@current-change="(page) => { planFilters.page = page; loadPlans() }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="用户会员" name="user-memberships">
|
||||||
|
<el-card shadow="never" class="filter-card panel-card">
|
||||||
|
<el-form inline class="admin-form">
|
||||||
|
<div class="filter-grid">
|
||||||
|
<el-form-item label="用户(筛选)">
|
||||||
|
<el-select
|
||||||
|
v-model="userMembershipFilters.user_id"
|
||||||
|
filterable
|
||||||
|
remote
|
||||||
|
reserve-keyword
|
||||||
|
clearable
|
||||||
|
:remote-method="searchUsers"
|
||||||
|
placeholder="输入昵称/手机号搜索"
|
||||||
|
>
|
||||||
|
<el-option v-for="u in userOptions" :key="u.id" :label="u.title" :value="u.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="套餐(筛选)">
|
||||||
|
<el-select v-model="userMembershipFilters.plan_id" clearable filterable>
|
||||||
|
<el-option v-for="p in plans" :key="p.id" :label="p.name" :value="p.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="userMembershipFilters.is_active" clearable placeholder="全部">
|
||||||
|
<el-option label="启用" value="true" />
|
||||||
|
<el-option label="停用" value="false" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="userMembershipFilters.page = 1; loadUserMemberships()">
|
||||||
|
查询
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="userMembershipFilters.user_id = null; userMembershipFilters.plan_id = null; userMembershipFilters.is_active = ''; userMembershipFilters.page = 1; loadUserMemberships()">
|
||||||
|
重置
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" plain @click="openCreateUserMembership">
|
||||||
|
新增记录
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="panel-card">
|
||||||
|
<el-table :data="userMemberships" v-loading="userMembershipsLoading">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="user_nickname" label="用户" width="140" />
|
||||||
|
<el-table-column prop="plan_name" label="套餐" width="160" />
|
||||||
|
<el-table-column prop="start_date" label="开始时间" min-width="170" />
|
||||||
|
<el-table-column prop="end_date" label="结束时间" min-width="170" />
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.is_active ? 'success' : 'info'" effect="light" round>
|
||||||
|
{{ row.is_active ? '启用' : '停用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link @click="openEditUserMembership(row)">
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" link @click="disableUserMembership(row)">
|
||||||
|
停用
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
<div class="pager">
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
:total="userMembershipTotal"
|
||||||
|
:page-size="userMembershipFilters.page_size"
|
||||||
|
:current-page="userMembershipFilters.page"
|
||||||
|
@current-change="(page) => { userMembershipFilters.page = page; loadUserMemberships() }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="积分流水" name="points">
|
||||||
|
<el-card shadow="never" class="filter-card panel-card">
|
||||||
|
<el-form inline class="admin-form">
|
||||||
|
<div class="filter-grid">
|
||||||
|
<el-form-item label="用户(筛选)">
|
||||||
|
<el-select
|
||||||
|
v-model="ledgerFilters.user_id"
|
||||||
|
filterable
|
||||||
|
remote
|
||||||
|
reserve-keyword
|
||||||
|
clearable
|
||||||
|
:remote-method="searchUsers"
|
||||||
|
placeholder="输入昵称/手机号搜索"
|
||||||
|
>
|
||||||
|
<el-option v-for="u in userOptions" :key="u.id" :label="u.title" :value="u.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="原因关键词">
|
||||||
|
<el-input v-model="ledgerFilters.reason" placeholder="原因" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="ledgerFilters.page = 1; loadLedger()">
|
||||||
|
查询
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="ledgerFilters.user_id = null; ledgerFilters.reason = ''; ledgerFilters.page = 1; loadLedger()">
|
||||||
|
重置
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" plain @click="openAdjustDialog">
|
||||||
|
人工调账
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="panel-card">
|
||||||
|
<el-table :data="ledgerRows" v-loading="ledgerLoading">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="user_nickname" label="用户" width="140" />
|
||||||
|
<el-table-column prop="change" label="变动值" width="100" />
|
||||||
|
<el-table-column prop="balance" label="余额" width="100" />
|
||||||
|
<el-table-column prop="reason" label="原因" min-width="180" />
|
||||||
|
<el-table-column label="回滚状态" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag
|
||||||
|
:type="row.ref_type === 'points_rollback' ? 'info' : row.rolled_back ? 'warning' : 'success'"
|
||||||
|
effect="light"
|
||||||
|
round
|
||||||
|
>
|
||||||
|
{{ row.ref_type === 'points_rollback' ? '回滚记录' : row.rolled_back ? '已回滚' : '可回滚' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="created_at" label="时间" min-width="170" />
|
||||||
|
<el-table-column label="操作" width="120" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button
|
||||||
|
type="danger"
|
||||||
|
link
|
||||||
|
:disabled="row.ref_type === 'points_rollback' || row.rolled_back"
|
||||||
|
@click="rollbackLedger(row)"
|
||||||
|
>
|
||||||
|
回滚
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
<div class="pager">
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
:total="ledgerTotal"
|
||||||
|
:page-size="ledgerFilters.page_size"
|
||||||
|
:current-page="ledgerFilters.page"
|
||||||
|
@current-change="(page) => { ledgerFilters.page = page; loadLedger() }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<el-dialog v-model="planDialogVisible" :title="editingPlanId ? '编辑套餐' : '新增套餐'" width="620px">
|
||||||
|
<el-form class="admin-form" label-position="top">
|
||||||
|
<div class="form-grid">
|
||||||
|
<el-form-item label="名称">
|
||||||
|
<el-input v-model="planForm.name" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="价格">
|
||||||
|
<el-input-number v-model="planForm.price" :min="0" :step="1" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="时长(天)">
|
||||||
|
<el-input-number v-model="planForm.duration_days" :min="1" :max="3650" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="排序">
|
||||||
|
<el-input-number v-model="planForm.sort_order" :min="0" :max="999" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="额外上传数">
|
||||||
|
<el-input-number v-model="planForm.extra_uploads" :min="0" :max="9999" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="额外置顶数">
|
||||||
|
<el-input-number v-model="planForm.extra_top_count" :min="0" :max="9999" />
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-form-item label="描述">
|
||||||
|
<el-input v-model="planForm.description" type="textarea" :rows="3" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="权益说明">
|
||||||
|
<el-input v-model="planForm.benefits" type="textarea" :rows="3" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-switch v-model="planForm.is_active" active-text="启用" inactive-text="停用" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="planDialogVisible = false">
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" :loading="planSubmitting" @click="submitPlan">
|
||||||
|
保存
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="userMembershipDialogVisible" :title="editingUserMembershipId ? '编辑用户会员' : '新增用户会员'" width="620px">
|
||||||
|
<el-form class="admin-form" label-position="top">
|
||||||
|
<div class="form-grid">
|
||||||
|
<el-form-item label="用户(筛选选择)">
|
||||||
|
<el-select
|
||||||
|
v-model="userMembershipForm.user_id"
|
||||||
|
filterable
|
||||||
|
remote
|
||||||
|
reserve-keyword
|
||||||
|
:remote-method="searchUsers"
|
||||||
|
placeholder="输入昵称/手机号搜索"
|
||||||
|
>
|
||||||
|
<el-option v-for="u in userOptions" :key="u.id" :label="u.title" :value="u.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="套餐">
|
||||||
|
<el-select v-model="userMembershipForm.plan_id" filterable>
|
||||||
|
<el-option v-for="p in plans" :key="p.id" :label="p.name" :value="p.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="开始时间">
|
||||||
|
<el-date-picker v-model="userMembershipForm.start_date" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="结束时间">
|
||||||
|
<el-date-picker v-model="userMembershipForm.end_date" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" />
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-switch v-model="userMembershipForm.is_active" active-text="启用" inactive-text="停用" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="userMembershipDialogVisible = false">
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" :loading="userMembershipSubmitting" @click="submitUserMembership">
|
||||||
|
保存
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="adjustDialogVisible" title="人工调账" width="520px">
|
||||||
|
<el-form class="admin-form" label-position="top">
|
||||||
|
<el-form-item label="用户(筛选选择)">
|
||||||
|
<el-select
|
||||||
|
v-model="adjustForm.user_id"
|
||||||
|
filterable
|
||||||
|
remote
|
||||||
|
reserve-keyword
|
||||||
|
:remote-method="searchUsers"
|
||||||
|
placeholder="输入昵称/手机号搜索"
|
||||||
|
>
|
||||||
|
<el-option v-for="u in userOptions" :key="u.id" :label="u.title" :value="u.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="积分变动值(可正可负,不能为0)">
|
||||||
|
<el-input-number v-model="adjustForm.change" :min="-100000" :max="100000" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="原因">
|
||||||
|
<el-input v-model="adjustForm.reason" placeholder="请输入调整原因" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="adjustDialogVisible = false">
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" :loading="adjustSubmitting" @click="submitAdjust">
|
||||||
|
确认调账
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.filter-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 8px;
|
||||||
|
}
|
||||||
|
.pager {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0 12px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { adminModuleDesign } from '~/composables/admin-api'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const data = ref<any>({
|
||||||
|
total_modules: 0,
|
||||||
|
full_covered: 0,
|
||||||
|
partial_covered: 0,
|
||||||
|
missing_covered: 0,
|
||||||
|
items: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const modules = computed(() => data.value.items || [])
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
data.value = await adminModuleDesign()
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '模块设计数据加载失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadData)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-head">
|
||||||
|
<h2>模块整合设计</h2>
|
||||||
|
<p>后端模块梳理 + 管理后台 CRUD 覆盖状态,作为迭代开发清单</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stat-row">
|
||||||
|
<article class="stat panel-card">
|
||||||
|
<span>模块总数</span>
|
||||||
|
<strong>{{ data.total_modules }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="stat panel-card ok">
|
||||||
|
<span>完整覆盖</span>
|
||||||
|
<strong>{{ data.full_covered }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="stat panel-card warn">
|
||||||
|
<span>部分覆盖</span>
|
||||||
|
<strong>{{ data.partial_covered }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="stat panel-card danger">
|
||||||
|
<span>未覆盖</span>
|
||||||
|
<strong>{{ data.missing_covered }}</strong>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-skeleton v-if="loading" :rows="8" animated class="panel-card loading-card" />
|
||||||
|
<section v-else class="module-grid">
|
||||||
|
<article v-for="item in modules" :key="item.module_key" class="module-card panel-card">
|
||||||
|
<header>
|
||||||
|
<h4>{{ item.module_name }}</h4>
|
||||||
|
<el-tag :type="item.status === 'full' ? 'success' : item.status === 'partial' ? 'warning' : 'danger'" effect="light" round>
|
||||||
|
{{ item.status }}
|
||||||
|
</el-tag>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="meta-block">
|
||||||
|
<span class="label">模型</span>
|
||||||
|
<div class="chips">
|
||||||
|
<el-tag v-for="name in item.models" :key="name" size="small" effect="plain">
|
||||||
|
{{ name }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="meta-block">
|
||||||
|
<span class="label">接口前缀</span>
|
||||||
|
<div class="chips">
|
||||||
|
<el-tag v-for="prefix in item.api_endpoint_prefixes" :key="prefix" size="small" type="info" effect="plain">
|
||||||
|
{{ prefix }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="crud-line">
|
||||||
|
<span :class="{ on: item.coverage.create }">C</span>
|
||||||
|
<span :class="{ on: item.coverage.read }">R</span>
|
||||||
|
<span :class="{ on: item.coverage.update }">U</span>
|
||||||
|
<span :class="{ on: item.coverage.delete }">D</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="note">
|
||||||
|
{{ item.notes }}
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.stat-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.stat span {
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.stat strong {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.stat.ok strong {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
.stat.warn strong {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
.stat.danger strong {
|
||||||
|
color: var(--danger);
|
||||||
|
}
|
||||||
|
.module-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.module-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.module-card header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.module-card h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.meta-block {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.label {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.crud-line {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.crud-line span {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text-3);
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.crud-line span.on {
|
||||||
|
color: #fff;
|
||||||
|
border-color: var(--brand-primary);
|
||||||
|
background: var(--brand-primary);
|
||||||
|
}
|
||||||
|
.note {
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: var(--text-2);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.loading-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.stat-row,
|
||||||
|
.module-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
adminCreateAppNavConfig,
|
||||||
|
adminDeleteAppNavConfig,
|
||||||
|
adminListAppNavConfigs,
|
||||||
|
adminUpdateAppNavConfig,
|
||||||
|
} from '~/composables/admin-api'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const total = ref(0)
|
||||||
|
const rows = ref<any[]>([])
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const editingId = ref<number | null>(null)
|
||||||
|
const filters = reactive({
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
keyword: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
key: '',
|
||||||
|
label: '',
|
||||||
|
page_path: '',
|
||||||
|
icon: '',
|
||||||
|
active_icon: '',
|
||||||
|
color: '#8a919f',
|
||||||
|
active_color: '#1677ff',
|
||||||
|
is_active: true,
|
||||||
|
sort_order: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await adminListAppNavConfigs(filters)
|
||||||
|
rows.value = data.items || []
|
||||||
|
total.value = data.total || 0
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '导航配置加载失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch() {
|
||||||
|
filters.page = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageChange(page: number) {
|
||||||
|
filters.page = page
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
editingId.value = null
|
||||||
|
form.key = ''
|
||||||
|
form.label = ''
|
||||||
|
form.page_path = ''
|
||||||
|
form.icon = ''
|
||||||
|
form.active_icon = ''
|
||||||
|
form.color = '#8a919f'
|
||||||
|
form.active_color = '#1677ff'
|
||||||
|
form.is_active = true
|
||||||
|
form.sort_order = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
resetForm()
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: any) {
|
||||||
|
editingId.value = row.id
|
||||||
|
form.key = row.key
|
||||||
|
form.label = row.label
|
||||||
|
form.page_path = row.page_path
|
||||||
|
form.icon = row.icon
|
||||||
|
form.active_icon = row.active_icon
|
||||||
|
form.color = row.color
|
||||||
|
form.active_color = row.active_color
|
||||||
|
form.is_active = row.is_active
|
||||||
|
form.sort_order = row.sort_order
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForm() {
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const payload = { ...form }
|
||||||
|
if (editingId.value) {
|
||||||
|
const { key: _key, ...updatePayload } = payload
|
||||||
|
await adminUpdateAppNavConfig(editingId.value, updatePayload)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await adminCreateAppNavConfig(payload)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '保存失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeItem(row: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认删除导航配置「${row.label}」吗?`, '删除确认', { type: 'warning' })
|
||||||
|
await adminDeleteAppNavConfig(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadData)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-head">
|
||||||
|
<h2>前端导航配置</h2>
|
||||||
|
<p>管理 uniapp 底栏导航文案、路径、uni-icons 图标和颜色配置</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="filter-card panel-card">
|
||||||
|
<el-form inline class="admin-form">
|
||||||
|
<div class="filter-grid">
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input v-model="filters.keyword" placeholder="key/标题" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="loading" @click="onSearch">
|
||||||
|
查询
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="filters.keyword = ''; onSearch()">
|
||||||
|
重置
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" plain @click="openCreate">
|
||||||
|
新增导航
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="panel-card">
|
||||||
|
<el-table :data="rows" v-loading="loading">
|
||||||
|
<el-table-column prop="sort_order" label="排序" width="80" />
|
||||||
|
<el-table-column prop="key" label="Key" width="120" />
|
||||||
|
<el-table-column prop="label" label="标题" width="140" />
|
||||||
|
<el-table-column prop="page_path" label="页面路径" min-width="160" />
|
||||||
|
<el-table-column prop="icon" label="图标" width="140" />
|
||||||
|
<el-table-column prop="active_icon" label="激活图标" width="140" />
|
||||||
|
<el-table-column label="颜色" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<div class="color-row">
|
||||||
|
<span class="dot" :style="{ background: row.color }" />
|
||||||
|
<span class="dot" :style="{ background: row.active_color }" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.is_active ? 'success' : 'info'" effect="light" round>
|
||||||
|
{{ row.is_active ? '启用' : '停用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link @click="openEdit(row)">
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" link @click="removeItem(row)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<div class="pager">
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
:total="total"
|
||||||
|
:page-size="filters.page_size"
|
||||||
|
:current-page="filters.page"
|
||||||
|
@current-change="onPageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑导航配置' : '新增导航配置'" width="560px">
|
||||||
|
<el-form class="admin-form" label-position="top">
|
||||||
|
<div class="form-grid">
|
||||||
|
<el-form-item label="Key(唯一)">
|
||||||
|
<el-input v-model="form.key" :disabled="!!editingId" placeholder="如 home / shooting_square" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="标题">
|
||||||
|
<el-input v-model="form.label" placeholder="如 首页 / 活动" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="页面路径">
|
||||||
|
<el-input v-model="form.page_path" placeholder="/pages/index/index" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="排序">
|
||||||
|
<el-input-number v-model="form.sort_order" :min="0" :max="999" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="默认图标(uni-icons)">
|
||||||
|
<el-input v-model="form.icon" placeholder="home / calendar / chat / location" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="激活图标(uni-icons)">
|
||||||
|
<el-input v-model="form.active_icon" placeholder="home-filled / calendar-filled" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="默认颜色">
|
||||||
|
<el-input v-model="form.color" placeholder="#8a919f" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="激活颜色">
|
||||||
|
<el-input v-model="form.active_color" placeholder="#1677ff" />
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-switch v-model="form.is_active" active-text="启用" inactive-text="停用" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="submitForm">
|
||||||
|
保存
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.filter-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 8px;
|
||||||
|
}
|
||||||
|
.pager {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.color-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1px solid #dbe3ec;
|
||||||
|
}
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0 12px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
Item One
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
Navigation 2
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
Navigation 4
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,739 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
adminCreateSystemConfig,
|
||||||
|
adminCreateAuditLog,
|
||||||
|
adminCreateNotification,
|
||||||
|
adminDeleteSystemConfig,
|
||||||
|
adminDeleteNotification,
|
||||||
|
adminDeleteReport,
|
||||||
|
adminListAuditLogs,
|
||||||
|
adminListNotifications,
|
||||||
|
adminListReports,
|
||||||
|
adminListSystemConfigs,
|
||||||
|
adminUpdateSystemConfig,
|
||||||
|
adminUpdateNotification,
|
||||||
|
adminUpdateReport,
|
||||||
|
adminUserOptions,
|
||||||
|
adminPromotionLinkOptions,
|
||||||
|
} from '~/composables/admin-api'
|
||||||
|
|
||||||
|
const activeTab = ref('notifications')
|
||||||
|
const userOptions = ref<Array<{ id: number, title: string }>>([])
|
||||||
|
const notificationRefOptions = ref<Array<{ id: number, title: string }>>([])
|
||||||
|
|
||||||
|
const notificationsLoading = ref(false)
|
||||||
|
const notifications = ref<any[]>([])
|
||||||
|
const notificationTotal = ref(0)
|
||||||
|
const notificationFilters = reactive({ page: 1, page_size: 20, user_id: null as number | null, type: '', is_read: '' })
|
||||||
|
const notificationDialogVisible = ref(false)
|
||||||
|
const notificationSubmitting = ref(false)
|
||||||
|
const editingNotificationId = ref<number | null>(null)
|
||||||
|
const notificationForm = reactive({
|
||||||
|
user_id: null as number | null,
|
||||||
|
type: 'system',
|
||||||
|
title: '',
|
||||||
|
content: '',
|
||||||
|
ref_type: '',
|
||||||
|
ref_id: null as number | null,
|
||||||
|
is_read: false,
|
||||||
|
})
|
||||||
|
const notificationRefLinkType = ref<'spot' | 'event' | 'shooting' | ''>('')
|
||||||
|
const notificationRefLinkId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const reportsLoading = ref(false)
|
||||||
|
const reports = ref<any[]>([])
|
||||||
|
const reportTotal = ref(0)
|
||||||
|
const reportFilters = reactive({ page: 1, page_size: 20, status: '', target_type: '', reporter_id: null as number | null })
|
||||||
|
const reportDialogVisible = ref(false)
|
||||||
|
const reportSubmitting = ref(false)
|
||||||
|
const editingReportId = ref<number | null>(null)
|
||||||
|
const reportForm = reactive({
|
||||||
|
status: 'processing',
|
||||||
|
handler_id: null as number | null,
|
||||||
|
conclusion: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const logsLoading = ref(false)
|
||||||
|
const logs = ref<any[]>([])
|
||||||
|
const logTotal = ref(0)
|
||||||
|
const logFilters = reactive({ page: 1, page_size: 20, operator_id: null as number | null, action: '', target_type: '' })
|
||||||
|
const logDialogVisible = ref(false)
|
||||||
|
const logSubmitting = ref(false)
|
||||||
|
const logForm = reactive({
|
||||||
|
action: '',
|
||||||
|
target_type: '',
|
||||||
|
target_id: null as number | null,
|
||||||
|
detail: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const systemConfigsLoading = ref(false)
|
||||||
|
const systemConfigs = ref<any[]>([])
|
||||||
|
const systemConfigTotal = ref(0)
|
||||||
|
const systemConfigFilters = reactive({ page: 1, page_size: 20, category: '', keyword: '' })
|
||||||
|
const systemConfigDialogVisible = ref(false)
|
||||||
|
const systemConfigSubmitting = ref(false)
|
||||||
|
const editingSystemConfigId = ref<number | null>(null)
|
||||||
|
const systemConfigForm = reactive({
|
||||||
|
config_key: '',
|
||||||
|
category: 'notification_template',
|
||||||
|
title: '',
|
||||||
|
config_json: '{}',
|
||||||
|
description: '',
|
||||||
|
is_active: true,
|
||||||
|
sort_order: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
async function searchUsers(keyword = '') {
|
||||||
|
userOptions.value = await adminUserOptions(keyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchNotificationRefOptions(keyword = '') {
|
||||||
|
if (!notificationRefLinkType.value) {
|
||||||
|
notificationRefOptions.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
notificationRefOptions.value = await adminPromotionLinkOptions(notificationRefLinkType.value, keyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNotifications() {
|
||||||
|
notificationsLoading.value = true
|
||||||
|
try {
|
||||||
|
const params: Record<string, any> = { ...notificationFilters }
|
||||||
|
if (!params.user_id) delete params.user_id
|
||||||
|
if (params.is_read !== '') params.is_read = params.is_read === 'true'
|
||||||
|
const data = await adminListNotifications(params)
|
||||||
|
notifications.value = data.items || []
|
||||||
|
notificationTotal.value = data.total || 0
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '通知数据加载失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
notificationsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadReports() {
|
||||||
|
reportsLoading.value = true
|
||||||
|
try {
|
||||||
|
const params: Record<string, any> = { ...reportFilters }
|
||||||
|
if (!params.reporter_id) delete params.reporter_id
|
||||||
|
const data = await adminListReports(params)
|
||||||
|
reports.value = data.items || []
|
||||||
|
reportTotal.value = data.total || 0
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '举报数据加载失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
reportsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLogs() {
|
||||||
|
logsLoading.value = true
|
||||||
|
try {
|
||||||
|
const params: Record<string, any> = { ...logFilters }
|
||||||
|
if (!params.operator_id) delete params.operator_id
|
||||||
|
const data = await adminListAuditLogs(params)
|
||||||
|
logs.value = data.items || []
|
||||||
|
logTotal.value = data.total || 0
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '审计日志加载失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
logsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSystemConfigs() {
|
||||||
|
systemConfigsLoading.value = true
|
||||||
|
try {
|
||||||
|
const data = await adminListSystemConfigs(systemConfigFilters)
|
||||||
|
systemConfigs.value = data.items || []
|
||||||
|
systemConfigTotal.value = data.total || 0
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '规则配置加载失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
systemConfigsLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateNotification() {
|
||||||
|
editingNotificationId.value = null
|
||||||
|
notificationForm.user_id = null
|
||||||
|
notificationForm.type = 'system'
|
||||||
|
notificationForm.title = ''
|
||||||
|
notificationForm.content = ''
|
||||||
|
notificationForm.ref_type = ''
|
||||||
|
notificationForm.ref_id = null
|
||||||
|
notificationForm.is_read = false
|
||||||
|
notificationRefLinkType.value = ''
|
||||||
|
notificationRefLinkId.value = null
|
||||||
|
notificationRefOptions.value = []
|
||||||
|
notificationDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditNotification(row: any) {
|
||||||
|
editingNotificationId.value = row.id
|
||||||
|
notificationForm.user_id = row.user_id
|
||||||
|
notificationForm.type = row.type
|
||||||
|
notificationForm.title = row.title
|
||||||
|
notificationForm.content = row.content || ''
|
||||||
|
notificationForm.ref_type = row.ref_type || ''
|
||||||
|
notificationForm.ref_id = row.ref_id || null
|
||||||
|
notificationForm.is_read = !!row.is_read
|
||||||
|
if (row.ref_type === 'spot' || row.ref_type === 'event' || row.ref_type === 'shooting') {
|
||||||
|
notificationRefLinkType.value = row.ref_type
|
||||||
|
notificationRefLinkId.value = row.ref_id || null
|
||||||
|
searchNotificationRefOptions()
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
notificationRefLinkType.value = ''
|
||||||
|
notificationRefLinkId.value = null
|
||||||
|
notificationRefOptions.value = []
|
||||||
|
}
|
||||||
|
notificationDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitNotification() {
|
||||||
|
notificationSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const payload: Record<string, any> = { ...notificationForm }
|
||||||
|
if (notificationRefLinkType.value) {
|
||||||
|
payload.ref_type = notificationRefLinkType.value
|
||||||
|
payload.ref_id = notificationRefLinkId.value || null
|
||||||
|
}
|
||||||
|
if (!payload.ref_type) payload.ref_type = null
|
||||||
|
if (!payload.ref_id) payload.ref_id = null
|
||||||
|
if (editingNotificationId.value) {
|
||||||
|
await adminUpdateNotification(editingNotificationId.value, payload)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await adminCreateNotification(payload)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
notificationDialogVisible.value = false
|
||||||
|
loadNotifications()
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '保存失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
notificationSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeNotification(row: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认删除通知 #${row.id} 吗?`, '删除确认', { type: 'warning' })
|
||||||
|
await adminDeleteNotification(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadNotifications()
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReportProcess(row: any) {
|
||||||
|
editingReportId.value = row.id
|
||||||
|
reportForm.status = row.status || 'processing'
|
||||||
|
reportForm.handler_id = row.handler_id || null
|
||||||
|
reportForm.conclusion = row.conclusion || ''
|
||||||
|
reportDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitReport() {
|
||||||
|
if (!editingReportId.value) return
|
||||||
|
reportSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await adminUpdateReport(editingReportId.value, { ...reportForm })
|
||||||
|
ElMessage.success('处理成功')
|
||||||
|
reportDialogVisible.value = false
|
||||||
|
loadReports()
|
||||||
|
loadLogs()
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '处理失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
reportSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeReport(row: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认删除举报记录 #${row.id} 吗?`, '删除确认', { type: 'warning' })
|
||||||
|
await adminDeleteReport(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadReports()
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateLog() {
|
||||||
|
logForm.action = ''
|
||||||
|
logForm.target_type = ''
|
||||||
|
logForm.target_id = null
|
||||||
|
logForm.detail = ''
|
||||||
|
logDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitLog() {
|
||||||
|
logSubmitting.value = true
|
||||||
|
try {
|
||||||
|
await adminCreateAuditLog({ ...logForm })
|
||||||
|
ElMessage.success('写入成功')
|
||||||
|
logDialogVisible.value = false
|
||||||
|
loadLogs()
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '写入失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
logSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreateSystemConfig() {
|
||||||
|
editingSystemConfigId.value = null
|
||||||
|
systemConfigForm.config_key = ''
|
||||||
|
systemConfigForm.category = 'notification_template'
|
||||||
|
systemConfigForm.title = ''
|
||||||
|
systemConfigForm.config_json = '{}'
|
||||||
|
systemConfigForm.description = ''
|
||||||
|
systemConfigForm.is_active = true
|
||||||
|
systemConfigForm.sort_order = 0
|
||||||
|
systemConfigDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditSystemConfig(row: any) {
|
||||||
|
editingSystemConfigId.value = row.id
|
||||||
|
systemConfigForm.config_key = row.config_key
|
||||||
|
systemConfigForm.category = row.category
|
||||||
|
systemConfigForm.title = row.title
|
||||||
|
systemConfigForm.config_json = row.config_json
|
||||||
|
systemConfigForm.description = row.description || ''
|
||||||
|
systemConfigForm.is_active = row.is_active
|
||||||
|
systemConfigForm.sort_order = row.sort_order || 0
|
||||||
|
systemConfigDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitSystemConfig() {
|
||||||
|
systemConfigSubmitting.value = true
|
||||||
|
try {
|
||||||
|
const payload = { ...systemConfigForm }
|
||||||
|
if (editingSystemConfigId.value) {
|
||||||
|
const { config_key: _config_key, ...updatePayload } = payload
|
||||||
|
await adminUpdateSystemConfig(editingSystemConfigId.value, updatePayload)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await adminCreateSystemConfig(payload)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
systemConfigDialogVisible.value = false
|
||||||
|
loadSystemConfigs()
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '保存失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
systemConfigSubmitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSystemConfig(row: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认删除配置「${row.title}」吗?`, '删除确认', { type: 'warning' })
|
||||||
|
await adminDeleteSystemConfig(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadSystemConfigs()
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([searchUsers(), loadNotifications(), loadReports(), loadLogs(), loadSystemConfigs()])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-head">
|
||||||
|
<h2>消息与风控</h2>
|
||||||
|
<p>通知消息、举报处理、审计日志统一管理</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-tabs v-model="activeTab">
|
||||||
|
<el-tab-pane label="通知消息" name="notifications">
|
||||||
|
<el-card shadow="never" class="filter-card panel-card">
|
||||||
|
<el-form inline class="admin-form">
|
||||||
|
<div class="filter-grid">
|
||||||
|
<el-form-item label="用户(筛选)">
|
||||||
|
<el-select v-model="notificationFilters.user_id" filterable remote reserve-keyword clearable :remote-method="searchUsers" placeholder="输入昵称/手机号搜索">
|
||||||
|
<el-option v-for="u in userOptions" :key="u.id" :label="u.title" :value="u.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="类型">
|
||||||
|
<el-input v-model="notificationFilters.type" placeholder="system/event/shooting..." />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="已读状态">
|
||||||
|
<el-select v-model="notificationFilters.is_read" clearable placeholder="全部">
|
||||||
|
<el-option label="已读" value="true" />
|
||||||
|
<el-option label="未读" value="false" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="notificationFilters.page = 1; loadNotifications()">
|
||||||
|
查询
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="notificationFilters.user_id = null; notificationFilters.type = ''; notificationFilters.is_read = ''; notificationFilters.page = 1; loadNotifications()">
|
||||||
|
重置
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" plain @click="openCreateNotification">
|
||||||
|
新建通知
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="panel-card">
|
||||||
|
<el-table :data="notifications" v-loading="notificationsLoading">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="user_nickname" label="用户" width="140" />
|
||||||
|
<el-table-column prop="type" label="类型" width="120" />
|
||||||
|
<el-table-column prop="title" label="标题" min-width="180" />
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.is_read ? 'success' : 'warning'" effect="light" round>
|
||||||
|
{{ row.is_read ? '已读' : '未读' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="created_at" label="时间" min-width="170" />
|
||||||
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link @click="openEditNotification(row)">
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" link @click="removeNotification(row)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="举报处理" name="reports">
|
||||||
|
<el-card shadow="never" class="filter-card panel-card">
|
||||||
|
<el-form inline class="admin-form">
|
||||||
|
<div class="filter-grid">
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="reportFilters.status" clearable placeholder="全部">
|
||||||
|
<el-option label="待处理" value="pending" />
|
||||||
|
<el-option label="处理中" value="processing" />
|
||||||
|
<el-option label="已解决" value="resolved" />
|
||||||
|
<el-option label="已驳回" value="rejected" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="目标类型">
|
||||||
|
<el-input v-model="reportFilters.target_type" placeholder="comment/spot/..." />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="举报人(筛选)">
|
||||||
|
<el-select v-model="reportFilters.reporter_id" filterable remote reserve-keyword clearable :remote-method="searchUsers" placeholder="输入昵称/手机号搜索">
|
||||||
|
<el-option v-for="u in userOptions" :key="u.id" :label="u.title" :value="u.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="reportFilters.page = 1; loadReports()">
|
||||||
|
查询
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="reportFilters.status = ''; reportFilters.target_type = ''; reportFilters.reporter_id = null; reportFilters.page = 1; loadReports()">
|
||||||
|
重置
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="panel-card">
|
||||||
|
<el-table :data="reports" v-loading="reportsLoading">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="reporter_nickname" label="举报人" width="120" />
|
||||||
|
<el-table-column prop="target_type" label="目标类型" width="120" />
|
||||||
|
<el-table-column prop="target_id" label="目标ID" width="100" />
|
||||||
|
<el-table-column prop="reason" label="原因" min-width="200" />
|
||||||
|
<el-table-column prop="status" label="状态" width="110" />
|
||||||
|
<el-table-column label="操作" width="200" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link @click="openReportProcess(row)">
|
||||||
|
处理
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" link @click="removeReport(row)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="审计日志" name="audit-logs">
|
||||||
|
<el-card shadow="never" class="filter-card panel-card">
|
||||||
|
<el-form inline class="admin-form">
|
||||||
|
<div class="filter-grid">
|
||||||
|
<el-form-item label="操作人(筛选)">
|
||||||
|
<el-select v-model="logFilters.operator_id" filterable remote reserve-keyword clearable :remote-method="searchUsers" placeholder="输入昵称/手机号搜索">
|
||||||
|
<el-option v-for="u in userOptions" :key="u.id" :label="u.title" :value="u.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="动作">
|
||||||
|
<el-input v-model="logFilters.action" placeholder="admin_*" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="目标类型">
|
||||||
|
<el-input v-model="logFilters.target_type" placeholder="report/notification/..." />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="logFilters.page = 1; loadLogs()">
|
||||||
|
查询
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="logFilters.operator_id = null; logFilters.action = ''; logFilters.target_type = ''; logFilters.page = 1; loadLogs()">
|
||||||
|
重置
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" plain @click="openCreateLog">
|
||||||
|
新增日志
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="panel-card">
|
||||||
|
<el-table :data="logs" v-loading="logsLoading">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="operator_nickname" label="操作人" width="120" />
|
||||||
|
<el-table-column prop="action" label="动作" min-width="170" />
|
||||||
|
<el-table-column prop="target_type" label="目标类型" width="120" />
|
||||||
|
<el-table-column prop="target_id" label="目标ID" width="100" />
|
||||||
|
<el-table-column prop="detail" label="详情" min-width="200" />
|
||||||
|
<el-table-column prop="created_at" label="时间" min-width="170" />
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
|
||||||
|
<el-tab-pane label="模板与规则" name="system-configs">
|
||||||
|
<el-card shadow="never" class="filter-card panel-card">
|
||||||
|
<el-form inline class="admin-form">
|
||||||
|
<div class="filter-grid">
|
||||||
|
<el-form-item label="分类">
|
||||||
|
<el-select v-model="systemConfigFilters.category" clearable placeholder="全部">
|
||||||
|
<el-option label="通知模板" value="notification_template" />
|
||||||
|
<el-option label="通知规则" value="notification_rule" />
|
||||||
|
<el-option label="举报SOP" value="report_sop" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input v-model="systemConfigFilters.keyword" placeholder="key/标题" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" @click="systemConfigFilters.page = 1; loadSystemConfigs()">
|
||||||
|
查询
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="systemConfigFilters.category = ''; systemConfigFilters.keyword = ''; systemConfigFilters.page = 1; loadSystemConfigs()">
|
||||||
|
重置
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" plain @click="openCreateSystemConfig">
|
||||||
|
新增配置
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="panel-card">
|
||||||
|
<el-table :data="systemConfigs" v-loading="systemConfigsLoading">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="config_key" label="配置键" width="220" />
|
||||||
|
<el-table-column prop="category" label="分类" width="160" />
|
||||||
|
<el-table-column prop="title" label="标题" min-width="160" />
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.is_active ? 'success' : 'info'" effect="light" round>
|
||||||
|
{{ row.is_active ? '启用' : '停用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link @click="openEditSystemConfig(row)">
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" link @click="removeSystemConfig(row)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
</el-tab-pane>
|
||||||
|
</el-tabs>
|
||||||
|
|
||||||
|
<el-dialog v-model="notificationDialogVisible" :title="editingNotificationId ? '编辑通知' : '新建通知'" width="620px">
|
||||||
|
<el-form class="admin-form" label-position="top">
|
||||||
|
<div class="form-grid">
|
||||||
|
<el-form-item label="用户(筛选)">
|
||||||
|
<el-select v-model="notificationForm.user_id" filterable remote reserve-keyword :remote-method="searchUsers" placeholder="输入昵称/手机号搜索">
|
||||||
|
<el-option v-for="u in userOptions" :key="u.id" :label="u.title" :value="u.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="类型">
|
||||||
|
<el-input v-model="notificationForm.type" placeholder="system/event/shooting..." />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="标题">
|
||||||
|
<el-input v-model="notificationForm.title" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="已读">
|
||||||
|
<el-switch v-model="notificationForm.is_read" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="引用类型">
|
||||||
|
<el-select v-model="notificationRefLinkType" clearable placeholder="可选(取景地/活动/约拍)" @change="notificationRefLinkId = null; searchNotificationRefOptions()">
|
||||||
|
<el-option label="取景地" value="spot" />
|
||||||
|
<el-option label="活动" value="event" />
|
||||||
|
<el-option label="约拍" value="shooting" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="引用ID">
|
||||||
|
<el-select
|
||||||
|
v-model="notificationRefLinkId"
|
||||||
|
filterable
|
||||||
|
remote
|
||||||
|
reserve-keyword
|
||||||
|
clearable
|
||||||
|
:disabled="!notificationRefLinkType"
|
||||||
|
:remote-method="searchNotificationRefOptions"
|
||||||
|
placeholder="先选择引用类型,再搜索对象"
|
||||||
|
>
|
||||||
|
<el-option v-for="item in notificationRefOptions" :key="item.id" :label="item.title" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-form-item label="内容">
|
||||||
|
<el-input v-model="notificationForm.content" type="textarea" :rows="4" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="notificationDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="notificationSubmitting" @click="submitNotification">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="reportDialogVisible" title="处理举报" width="560px">
|
||||||
|
<el-form class="admin-form" label-position="top">
|
||||||
|
<el-form-item label="处理状态">
|
||||||
|
<el-select v-model="reportForm.status">
|
||||||
|
<el-option label="待处理" value="pending" />
|
||||||
|
<el-option label="处理中" value="processing" />
|
||||||
|
<el-option label="已解决" value="resolved" />
|
||||||
|
<el-option label="已驳回" value="rejected" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="处理人(筛选)">
|
||||||
|
<el-select v-model="reportForm.handler_id" filterable remote reserve-keyword clearable :remote-method="searchUsers">
|
||||||
|
<el-option v-for="u in userOptions" :key="u.id" :label="u.title" :value="u.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="处理结论">
|
||||||
|
<el-input v-model="reportForm.conclusion" type="textarea" :rows="4" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="reportDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="reportSubmitting" @click="submitReport">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="logDialogVisible" title="新增审计日志" width="520px">
|
||||||
|
<el-form class="admin-form" label-position="top">
|
||||||
|
<el-form-item label="动作">
|
||||||
|
<el-input v-model="logForm.action" placeholder="admin_custom_action" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="目标类型">
|
||||||
|
<el-input v-model="logForm.target_type" placeholder="report/notification/..." />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="目标ID">
|
||||||
|
<el-input-number v-model="logForm.target_id" :min="1" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="详情">
|
||||||
|
<el-input v-model="logForm.detail" type="textarea" :rows="4" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="logDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="logSubmitting" @click="submitLog">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="systemConfigDialogVisible" :title="editingSystemConfigId ? '编辑规则配置' : '新增规则配置'" width="700px">
|
||||||
|
<el-form class="admin-form" label-position="top">
|
||||||
|
<div class="form-grid">
|
||||||
|
<el-form-item label="配置键(唯一)">
|
||||||
|
<el-input v-model="systemConfigForm.config_key" :disabled="!!editingSystemConfigId" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="分类">
|
||||||
|
<el-select v-model="systemConfigForm.category">
|
||||||
|
<el-option label="通知模板" value="notification_template" />
|
||||||
|
<el-option label="通知规则" value="notification_rule" />
|
||||||
|
<el-option label="举报SOP" value="report_sop" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="标题">
|
||||||
|
<el-input v-model="systemConfigForm.title" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="排序">
|
||||||
|
<el-input-number v-model="systemConfigForm.sort_order" :min="0" :max="999" />
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-form-item label="JSON配置字符串">
|
||||||
|
<el-input v-model="systemConfigForm.config_json" type="textarea" :rows="8" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="说明">
|
||||||
|
<el-input v-model="systemConfigForm.description" type="textarea" :rows="3" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-switch v-model="systemConfigForm.is_active" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="systemConfigDialogVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="systemConfigSubmitting" @click="submitSystemConfig">保存</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.filter-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 8px;
|
||||||
|
}
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0 12px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
adminCreatePromotion,
|
||||||
|
adminDeletePromotion,
|
||||||
|
adminListPromotions,
|
||||||
|
adminPromotionLinkOptions,
|
||||||
|
adminUpdatePromotion,
|
||||||
|
} from '~/composables/admin-api'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const total = ref(0)
|
||||||
|
const rows = ref<any[]>([])
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const editingId = ref<number | null>(null)
|
||||||
|
const linkOptions = ref<Array<{ id: number, title: string }>>([])
|
||||||
|
const filters = reactive({
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
keyword: '',
|
||||||
|
position: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
title: '',
|
||||||
|
image_url: '',
|
||||||
|
link_type: 'spot',
|
||||||
|
selected_link_id: null as number | null,
|
||||||
|
link_url: '',
|
||||||
|
position: 'home_banner',
|
||||||
|
sort_order: 0,
|
||||||
|
is_active: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const needsSelector = computed(() => ['spot', 'event', 'shooting'].includes(form.link_type))
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await adminListPromotions(filters)
|
||||||
|
rows.value = data.items || []
|
||||||
|
total.value = data.total || 0
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '推广位数据加载失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch() {
|
||||||
|
filters.page = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageChange(page: number) {
|
||||||
|
filters.page = page
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function searchLinkOptions(keyword = '') {
|
||||||
|
if (!needsSelector.value)
|
||||||
|
return
|
||||||
|
linkOptions.value = await adminPromotionLinkOptions(form.link_type as any, keyword)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
editingId.value = null
|
||||||
|
form.title = ''
|
||||||
|
form.image_url = ''
|
||||||
|
form.link_type = 'spot'
|
||||||
|
form.selected_link_id = null
|
||||||
|
form.link_url = ''
|
||||||
|
form.position = 'home_banner'
|
||||||
|
form.sort_order = 0
|
||||||
|
form.is_active = true
|
||||||
|
linkOptions.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCreate() {
|
||||||
|
resetForm()
|
||||||
|
dialogVisible.value = true
|
||||||
|
await searchLinkOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEdit(row: any) {
|
||||||
|
editingId.value = row.id
|
||||||
|
form.title = row.title
|
||||||
|
form.image_url = row.image_url
|
||||||
|
form.link_type = row.link_type
|
||||||
|
form.selected_link_id = row.link_id || row.spot_id || row.event_id || row.shooting_id || null
|
||||||
|
form.link_url = row.link_url || ''
|
||||||
|
form.position = row.position
|
||||||
|
form.sort_order = row.sort_order
|
||||||
|
form.is_active = row.is_active
|
||||||
|
dialogVisible.value = true
|
||||||
|
if (needsSelector.value)
|
||||||
|
await searchLinkOptions()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForm() {
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const payload: Record<string, any> = {
|
||||||
|
title: form.title,
|
||||||
|
image_url: form.image_url,
|
||||||
|
link_type: form.link_type,
|
||||||
|
position: form.position,
|
||||||
|
sort_order: form.sort_order,
|
||||||
|
is_active: form.is_active,
|
||||||
|
}
|
||||||
|
if (needsSelector.value)
|
||||||
|
payload.link_id = form.selected_link_id
|
||||||
|
else
|
||||||
|
payload.link_url = form.link_url
|
||||||
|
|
||||||
|
if (editingId.value) {
|
||||||
|
await adminUpdatePromotion(editingId.value, payload)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await adminCreatePromotion(payload)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '保存失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeItem(row: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认删除推广位「${row.title}」吗?`, '删除确认', { type: 'warning' })
|
||||||
|
await adminDeletePromotion(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadData)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-head">
|
||||||
|
<h2>推广位管理</h2>
|
||||||
|
<p>管理 Banner 和运营位,关联对象统一使用筛选器选择</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="filter-card panel-card">
|
||||||
|
<el-form inline class="admin-form">
|
||||||
|
<div class="filter-grid">
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input v-model="filters.keyword" placeholder="标题" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="位置">
|
||||||
|
<el-select v-model="filters.position" clearable placeholder="全部">
|
||||||
|
<el-option label="首页 Banner" value="home_banner" />
|
||||||
|
<el-option label="首页弹层" value="home_popup" />
|
||||||
|
<el-option label="列表顶部" value="list_top" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="loading" @click="onSearch">
|
||||||
|
查询
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="filters.keyword = ''; filters.position = ''; onSearch()">
|
||||||
|
重置
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" plain @click="openCreate">
|
||||||
|
新增推广位
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="panel-card">
|
||||||
|
<el-table :data="rows" v-loading="loading">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="title" label="标题" min-width="180" />
|
||||||
|
<el-table-column prop="position" label="位置" width="120" />
|
||||||
|
<el-table-column prop="link_type" label="关联类型" width="110" />
|
||||||
|
<el-table-column label="关联目标" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.link_id || row.spot_id || row.event_id || row.shooting_id || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="sort_order" label="排序" width="90" />
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.is_active ? 'success' : 'info'" effect="light" round>
|
||||||
|
{{ row.is_active ? '启用' : '停用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="180" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link @click="openEdit(row)">
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" link @click="removeItem(row)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<div class="pager">
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
:total="total"
|
||||||
|
:page-size="filters.page_size"
|
||||||
|
:current-page="filters.page"
|
||||||
|
@current-change="onPageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑推广位' : '新增推广位'" width="620px">
|
||||||
|
<el-form class="admin-form" label-position="top">
|
||||||
|
<div class="form-grid">
|
||||||
|
<el-form-item label="标题">
|
||||||
|
<el-input v-model="form.title" placeholder="请输入标题" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="图片地址">
|
||||||
|
<el-input v-model="form.image_url" placeholder="https://..." />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="位置">
|
||||||
|
<el-select v-model="form.position">
|
||||||
|
<el-option label="首页 Banner" value="home_banner" />
|
||||||
|
<el-option label="首页弹层" value="home_popup" />
|
||||||
|
<el-option label="列表顶部" value="list_top" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="排序">
|
||||||
|
<el-input-number v-model="form.sort_order" :min="0" :max="999" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="关联类型">
|
||||||
|
<el-select v-model="form.link_type" @change="form.selected_link_id = null; form.link_url = ''; searchLinkOptions()">
|
||||||
|
<el-option label="取景地" value="spot" />
|
||||||
|
<el-option label="活动" value="event" />
|
||||||
|
<el-option label="约拍" value="shooting" />
|
||||||
|
<el-option label="外链" value="url" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-if="needsSelector" label="关联对象(筛选选择)">
|
||||||
|
<el-select
|
||||||
|
v-model="form.selected_link_id"
|
||||||
|
filterable
|
||||||
|
remote
|
||||||
|
reserve-keyword
|
||||||
|
:remote-method="searchLinkOptions"
|
||||||
|
placeholder="输入关键词搜索"
|
||||||
|
>
|
||||||
|
<el-option v-for="item in linkOptions" :key="item.id" :label="item.title" :value="item.id" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-else label="外链地址">
|
||||||
|
<el-input v-model="form.link_url" placeholder="https://..." />
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-switch v-model="form.is_active" active-text="启用" inactive-text="停用" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="submitForm">
|
||||||
|
保存
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.filter-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 8px;
|
||||||
|
}
|
||||||
|
.pager {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0 12px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,989 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
adminAuditShooting,
|
||||||
|
adminBatchAuditShooting,
|
||||||
|
adminCreateShooting,
|
||||||
|
adminCreateShootingApplication,
|
||||||
|
adminDeleteShooting,
|
||||||
|
adminDeleteShootingApplication,
|
||||||
|
adminGetShooting,
|
||||||
|
adminListShooting,
|
||||||
|
adminListShootingApplications,
|
||||||
|
adminPromotionLinkOptions,
|
||||||
|
adminUpdateShooting,
|
||||||
|
adminUpdateShootingApplication,
|
||||||
|
adminUserOptions,
|
||||||
|
isRequestNotFound,
|
||||||
|
} from '~/composables/admin-api'
|
||||||
|
|
||||||
|
type OptionItem = { label: string, value: number }
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const total = ref(0)
|
||||||
|
const rows = ref<any[]>([])
|
||||||
|
const selectedIds = ref<number[]>([])
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const detailData = ref<any>(null)
|
||||||
|
|
||||||
|
const userOptions = ref<OptionItem[]>([])
|
||||||
|
const creatorOptions = ref<OptionItem[]>([])
|
||||||
|
const spotOptions = ref<OptionItem[]>([])
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const currentRow = ref<any>(null)
|
||||||
|
const auditForm = reactive({
|
||||||
|
audit_status: 'approved',
|
||||||
|
reject_reason: '',
|
||||||
|
})
|
||||||
|
const batchDialogVisible = ref(false)
|
||||||
|
const batchAuditForm = reactive({
|
||||||
|
audit_status: 'approved',
|
||||||
|
reject_reason: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const formVisible = ref(false)
|
||||||
|
const formMode = ref<'create' | 'edit'>('create')
|
||||||
|
const currentRequestId = ref<number | null>(null)
|
||||||
|
const form = reactive({
|
||||||
|
creator_id: undefined as number | undefined,
|
||||||
|
title: '',
|
||||||
|
city: '',
|
||||||
|
description: '',
|
||||||
|
style: '',
|
||||||
|
shoot_date: '',
|
||||||
|
is_free: false,
|
||||||
|
budget_min: undefined as number | undefined,
|
||||||
|
budget_max: undefined as number | undefined,
|
||||||
|
role_needed: 'photographer',
|
||||||
|
max_applicants: 1,
|
||||||
|
contact_info: '',
|
||||||
|
spot_id: undefined as number | undefined,
|
||||||
|
status: 'open',
|
||||||
|
audit_status: 'pending',
|
||||||
|
reject_reason: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const appVisible = ref(false)
|
||||||
|
const appRows = ref<any[]>([])
|
||||||
|
const appRequest = ref<any>(null)
|
||||||
|
const appForm = reactive({
|
||||||
|
applicant_id: undefined as number | undefined,
|
||||||
|
message: '',
|
||||||
|
status: 'pending',
|
||||||
|
})
|
||||||
|
const appEditRow = ref<any>(null)
|
||||||
|
|
||||||
|
const filters = reactive({
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
keyword: '',
|
||||||
|
city: '',
|
||||||
|
role_needed: '',
|
||||||
|
status: '',
|
||||||
|
audit_status: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const pendingCount = computed(() => rows.value.filter(r => r.audit_status === 'pending').length)
|
||||||
|
const openCount = computed(() => rows.value.filter(r => r.status === 'open').length)
|
||||||
|
const canSubmitRejectReason = computed(() => auditForm.audit_status !== 'rejected' || auditForm.reject_reason.trim().length > 0)
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await adminListShooting(filters)
|
||||||
|
rows.value = data.items || []
|
||||||
|
total.value = data.total || 0
|
||||||
|
const currentIds = new Set(rows.value.map((item: any) => item.id))
|
||||||
|
selectedIds.value = selectedIds.value.filter(id => currentIds.has(id))
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '约拍数据加载失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUserOptions(keyword = '') {
|
||||||
|
try {
|
||||||
|
const items = await adminUserOptions(keyword)
|
||||||
|
userOptions.value = (items || []).map((item: any) => ({ label: `${item.title} (#${item.id})`, value: item.id }))
|
||||||
|
creatorOptions.value = [...userOptions.value]
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
userOptions.value = []
|
||||||
|
creatorOptions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSpotOptions(keyword = '') {
|
||||||
|
try {
|
||||||
|
const items = await adminPromotionLinkOptions('spot', keyword)
|
||||||
|
spotOptions.value = (items || []).map((item: any) => ({ label: `${item.title} (#${item.id})`, value: item.id }))
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
spotOptions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch() {
|
||||||
|
filters.page = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onReset() {
|
||||||
|
filters.keyword = ''
|
||||||
|
filters.city = ''
|
||||||
|
filters.role_needed = ''
|
||||||
|
filters.status = ''
|
||||||
|
filters.audit_status = ''
|
||||||
|
onSearch()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageChange(page: number) {
|
||||||
|
filters.page = page
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAudit(row: any) {
|
||||||
|
currentRow.value = row
|
||||||
|
auditForm.audit_status = row.audit_status || 'approved'
|
||||||
|
auditForm.reject_reason = row.reject_reason || ''
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelect(row: any) {
|
||||||
|
if (selectedIds.value.includes(row.id))
|
||||||
|
selectedIds.value = selectedIds.value.filter(id => id !== row.id)
|
||||||
|
else
|
||||||
|
selectedIds.value.push(row.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBatchAudit() {
|
||||||
|
if (!selectedIds.value.length) {
|
||||||
|
ElMessage.error('请先勾选要批量审核的数据')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
batchAuditForm.audit_status = 'approved'
|
||||||
|
batchAuditForm.reject_reason = ''
|
||||||
|
batchDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAudit() {
|
||||||
|
if (!currentRow.value)
|
||||||
|
return
|
||||||
|
if (!canSubmitRejectReason.value) {
|
||||||
|
ElMessage.error('驳回时必须填写驳回原因')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await adminAuditShooting(currentRow.value.id, {
|
||||||
|
audit_status: auditForm.audit_status,
|
||||||
|
reject_reason: auditForm.reject_reason || undefined,
|
||||||
|
})
|
||||||
|
ElMessage.success('约拍审核已更新')
|
||||||
|
dialogVisible.value = false
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '提交失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBatchAudit() {
|
||||||
|
if (batchAuditForm.audit_status === 'rejected' && !batchAuditForm.reject_reason.trim()) {
|
||||||
|
ElMessage.error('批量驳回时必须填写原因')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await adminBatchAuditShooting({
|
||||||
|
ids: [...selectedIds.value],
|
||||||
|
audit_status: batchAuditForm.audit_status,
|
||||||
|
reject_reason: batchAuditForm.reject_reason || undefined,
|
||||||
|
})
|
||||||
|
ElMessage.success(`批量审核完成(${selectedIds.value.length}条)`)
|
||||||
|
batchDialogVisible.value = false
|
||||||
|
selectedIds.value = []
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '批量审核失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.creator_id = undefined
|
||||||
|
form.title = ''
|
||||||
|
form.city = ''
|
||||||
|
form.description = ''
|
||||||
|
form.style = ''
|
||||||
|
form.shoot_date = ''
|
||||||
|
form.is_free = false
|
||||||
|
form.budget_min = undefined
|
||||||
|
form.budget_max = undefined
|
||||||
|
form.role_needed = 'photographer'
|
||||||
|
form.max_applicants = 1
|
||||||
|
form.contact_info = ''
|
||||||
|
form.spot_id = undefined
|
||||||
|
form.status = 'open'
|
||||||
|
form.audit_status = 'pending'
|
||||||
|
form.reject_reason = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCreate() {
|
||||||
|
formMode.value = 'create'
|
||||||
|
currentRequestId.value = null
|
||||||
|
resetForm()
|
||||||
|
await Promise.all([loadUserOptions(), loadSpotOptions()])
|
||||||
|
formVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEdit(row: any) {
|
||||||
|
formMode.value = 'edit'
|
||||||
|
currentRequestId.value = row.id
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const detail = await adminGetShooting(row.id)
|
||||||
|
form.creator_id = detail.creator_id
|
||||||
|
form.title = detail.title || ''
|
||||||
|
form.city = detail.city || ''
|
||||||
|
form.description = detail.description || ''
|
||||||
|
form.style = detail.style || ''
|
||||||
|
form.shoot_date = detail.shoot_date ? String(detail.shoot_date).slice(0, 19) : ''
|
||||||
|
form.is_free = detail.is_free
|
||||||
|
form.budget_min = detail.budget_min ?? undefined
|
||||||
|
form.budget_max = detail.budget_max ?? undefined
|
||||||
|
form.role_needed = detail.role_needed || 'photographer'
|
||||||
|
form.max_applicants = detail.max_applicants || 1
|
||||||
|
form.contact_info = detail.contact_info || ''
|
||||||
|
form.spot_id = detail.spot_id || undefined
|
||||||
|
form.status = detail.status || 'open'
|
||||||
|
form.audit_status = detail.audit_status || 'pending'
|
||||||
|
form.reject_reason = detail.reject_reason || ''
|
||||||
|
await Promise.all([loadUserOptions(), loadSpotOptions()])
|
||||||
|
formVisible.value = true
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
if (isRequestNotFound(err)) {
|
||||||
|
ElMessage.warning('约拍数据不存在或已被删除,列表已刷新')
|
||||||
|
loadData()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.error(err?.message || '加载详情失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(row: any) {
|
||||||
|
detailVisible.value = true
|
||||||
|
detailLoading.value = true
|
||||||
|
detailData.value = null
|
||||||
|
try {
|
||||||
|
detailData.value = await adminGetShooting(row.id)
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
if (isRequestNotFound(err)) {
|
||||||
|
detailVisible.value = false
|
||||||
|
ElMessage.warning('约拍数据不存在或已被删除,列表已刷新')
|
||||||
|
loadData()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.error(err?.message || '详情加载失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm() {
|
||||||
|
if (!form.creator_id) {
|
||||||
|
ElMessage.error('请选择发布用户')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!form.title.trim()) {
|
||||||
|
ElMessage.error('请填写标题')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!form.city.trim()) {
|
||||||
|
ElMessage.error('请填写城市')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!form.is_free && form.budget_min !== undefined && form.budget_max !== undefined && Number(form.budget_min) > Number(form.budget_max)) {
|
||||||
|
ElMessage.error('预算区间不正确')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (form.audit_status === 'rejected' && !form.reject_reason.trim()) {
|
||||||
|
ElMessage.error('驳回状态必须填写原因')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPayload() {
|
||||||
|
return {
|
||||||
|
creator_id: form.creator_id,
|
||||||
|
title: form.title.trim(),
|
||||||
|
city: form.city.trim(),
|
||||||
|
description: form.description?.trim() || null,
|
||||||
|
style: form.style?.trim() || null,
|
||||||
|
shoot_date: form.shoot_date || null,
|
||||||
|
is_free: form.is_free,
|
||||||
|
budget_min: form.is_free ? null : form.budget_min ?? null,
|
||||||
|
budget_max: form.is_free ? null : form.budget_max ?? null,
|
||||||
|
role_needed: form.role_needed,
|
||||||
|
max_applicants: Number(form.max_applicants || 1),
|
||||||
|
contact_info: form.contact_info?.trim() || null,
|
||||||
|
spot_id: form.spot_id || null,
|
||||||
|
status: form.status,
|
||||||
|
audit_status: form.audit_status,
|
||||||
|
reject_reason: form.audit_status === 'rejected' ? (form.reject_reason?.trim() || null) : null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForm() {
|
||||||
|
if (!validateForm())
|
||||||
|
return
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const payload = buildPayload()
|
||||||
|
if (formMode.value === 'create')
|
||||||
|
await adminCreateShooting(payload)
|
||||||
|
else if (currentRequestId.value)
|
||||||
|
await adminUpdateShooting(currentRequestId.value, payload)
|
||||||
|
ElMessage.success(formMode.value === 'create' ? '约拍已创建' : '约拍已更新')
|
||||||
|
formVisible.value = false
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '提交失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(row: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认删除约拍「${row.title}」?删除后不可恢复。`, '删除确认', { type: 'warning' })
|
||||||
|
await adminDeleteShooting(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openApps(row: any) {
|
||||||
|
try {
|
||||||
|
appRequest.value = row
|
||||||
|
appRows.value = await adminListShootingApplications(row.id)
|
||||||
|
appForm.applicant_id = undefined
|
||||||
|
appForm.message = ''
|
||||||
|
appForm.status = 'pending'
|
||||||
|
appEditRow.value = null
|
||||||
|
await loadUserOptions()
|
||||||
|
appVisible.value = true
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
if (isRequestNotFound(err)) {
|
||||||
|
ElMessage.warning('约拍数据不存在或已被删除,列表已刷新')
|
||||||
|
loadData()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.error(err?.message || '申请记录加载失败')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function editApp(row: any) {
|
||||||
|
appEditRow.value = row
|
||||||
|
appForm.applicant_id = row.applicant_id
|
||||||
|
appForm.message = row.message || ''
|
||||||
|
appForm.status = row.status || 'pending'
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitApp() {
|
||||||
|
if (!appRequest.value)
|
||||||
|
return
|
||||||
|
if (!appForm.applicant_id) {
|
||||||
|
ElMessage.error('请选择申请用户')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
if (appEditRow.value) {
|
||||||
|
await adminUpdateShootingApplication(appRequest.value.id, appEditRow.value.id, {
|
||||||
|
message: appForm.message?.trim() || null,
|
||||||
|
status: appForm.status,
|
||||||
|
})
|
||||||
|
ElMessage.success('申请记录已更新')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
await adminCreateShootingApplication(appRequest.value.id, {
|
||||||
|
applicant_id: appForm.applicant_id,
|
||||||
|
message: appForm.message?.trim() || null,
|
||||||
|
status: appForm.status,
|
||||||
|
})
|
||||||
|
ElMessage.success('申请记录已新增')
|
||||||
|
}
|
||||||
|
appRows.value = await adminListShootingApplications(appRequest.value.id)
|
||||||
|
appForm.applicant_id = undefined
|
||||||
|
appForm.message = ''
|
||||||
|
appForm.status = 'pending'
|
||||||
|
appEditRow.value = null
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '操作失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeApp(row: any) {
|
||||||
|
if (!appRequest.value)
|
||||||
|
return
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('确认删除该申请记录?', '删除确认', { type: 'warning' })
|
||||||
|
await adminDeleteShootingApplication(appRequest.value.id, row.id)
|
||||||
|
appRows.value = await adminListShootingApplications(appRequest.value.id)
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadData)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-head">
|
||||||
|
<div>
|
||||||
|
<h2>约拍管理</h2>
|
||||||
|
<p>覆盖约拍完整 CRUD,并支持申请记录管理</p>
|
||||||
|
</div>
|
||||||
|
<div class="head-actions">
|
||||||
|
<el-button @click="openBatchAudit">
|
||||||
|
批量审核({{ selectedIds.length }})
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" @click="openCreate">
|
||||||
|
新建约拍
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stat-row">
|
||||||
|
<article class="stat panel-card">
|
||||||
|
<span>本页总量</span>
|
||||||
|
<strong>{{ rows.length }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="stat panel-card warn">
|
||||||
|
<span>待审核</span>
|
||||||
|
<strong>{{ pendingCount }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="stat panel-card ok">
|
||||||
|
<span>开放招募</span>
|
||||||
|
<strong>{{ openCount }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="stat panel-card">
|
||||||
|
<span>全量数据</span>
|
||||||
|
<strong>{{ total }}</strong>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="filter-card panel-card">
|
||||||
|
<el-form inline class="admin-form">
|
||||||
|
<div class="filter-grid">
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input v-model="filters.keyword" placeholder="标题/描述" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="城市">
|
||||||
|
<el-input v-model="filters.city" placeholder="城市" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="需求角色">
|
||||||
|
<el-select v-model="filters.role_needed" clearable placeholder="全部">
|
||||||
|
<el-option label="摄影师" value="photographer" />
|
||||||
|
<el-option label="Coser" value="cosplayer" />
|
||||||
|
<el-option label="不限" value="both" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="业务状态">
|
||||||
|
<el-select v-model="filters.status" clearable placeholder="全部">
|
||||||
|
<el-option label="招募中" value="open" />
|
||||||
|
<el-option label="匹配完成" value="matched" />
|
||||||
|
<el-option label="已关闭" value="closed" />
|
||||||
|
<el-option label="已取消" value="cancelled" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="审核状态">
|
||||||
|
<el-select v-model="filters.audit_status" clearable placeholder="全部">
|
||||||
|
<el-option label="待审核" value="pending" />
|
||||||
|
<el-option label="已通过" value="approved" />
|
||||||
|
<el-option label="已驳回" value="rejected" />
|
||||||
|
<el-option label="已删除" value="deleted" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="loading" @click="onSearch">
|
||||||
|
查询
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="onReset">
|
||||||
|
重置
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<section v-if="!loading && rows.length" class="spot-grid">
|
||||||
|
<article v-for="row in rows" :key="row.id" class="spot-card panel-card">
|
||||||
|
<header>
|
||||||
|
<div class="title-wrap">
|
||||||
|
<el-checkbox :model-value="selectedIds.includes(row.id)" @change="toggleSelect(row)" />
|
||||||
|
<h4>{{ row.title }}</h4>
|
||||||
|
</div>
|
||||||
|
<el-tag :type="row.audit_status === 'approved' ? 'success' : row.audit_status === 'pending' ? 'warning' : 'danger'" effect="light" round>
|
||||||
|
{{ row.audit_status }}
|
||||||
|
</el-tag>
|
||||||
|
</header>
|
||||||
|
<div class="meta">
|
||||||
|
<span>城市:{{ row.city || '-' }}</span>
|
||||||
|
<span>角色需求:{{ row.role_needed || '-' }}</span>
|
||||||
|
<span>业务状态:{{ row.status || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="meta">
|
||||||
|
<span>发布人:#{{ row.creator_id }}</span>
|
||||||
|
<span>是否免费:{{ row.is_free ? '是' : '否' }}</span>
|
||||||
|
<span>人数上限:{{ row.max_applicants || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="reason">
|
||||||
|
{{ row.reject_reason || '暂无驳回原因' }}
|
||||||
|
</p>
|
||||||
|
<footer>
|
||||||
|
<el-button @click="openDetail(row)">
|
||||||
|
详情聚合
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" plain @click="openEdit(row)">
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="openAudit(row)">
|
||||||
|
审核
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="openApps(row)">
|
||||||
|
申请管理
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" plain @click="onDelete(row)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-empty v-else-if="!loading && !rows.length" description="暂无约拍数据" class="panel-card empty" />
|
||||||
|
<el-skeleton v-else :rows="6" animated class="panel-card loading-card" />
|
||||||
|
|
||||||
|
<div class="pager">
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
:total="total"
|
||||||
|
:page-size="filters.page_size"
|
||||||
|
:current-page="filters.page"
|
||||||
|
@current-change="onPageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" title="约拍审核设置" width="460px">
|
||||||
|
<el-form label-position="top" class="admin-form">
|
||||||
|
<el-form-item label="审核状态">
|
||||||
|
<el-select v-model="auditForm.audit_status">
|
||||||
|
<el-option label="待审核" value="pending" />
|
||||||
|
<el-option label="已通过" value="approved" />
|
||||||
|
<el-option label="已驳回" value="rejected" />
|
||||||
|
<el-option label="已删除" value="deleted" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="驳回原因(驳回时必填)">
|
||||||
|
<el-input v-model="auditForm.reject_reason" type="textarea" :rows="4" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="submitAudit">
|
||||||
|
提交
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="batchDialogVisible" title="批量审核设置" width="460px">
|
||||||
|
<el-form label-position="top" class="admin-form">
|
||||||
|
<el-form-item label="审核状态">
|
||||||
|
<el-select v-model="batchAuditForm.audit_status">
|
||||||
|
<el-option label="待审核" value="pending" />
|
||||||
|
<el-option label="已通过" value="approved" />
|
||||||
|
<el-option label="已驳回" value="rejected" />
|
||||||
|
<el-option label="已删除" value="deleted" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="驳回原因(批量驳回时必填)">
|
||||||
|
<el-input v-model="batchAuditForm.reject_reason" type="textarea" :rows="4" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-alert type="info" :closable="false" show-icon :title="`已选择 ${selectedIds.length} 条记录`" />
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="batchDialogVisible = false">
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="submitBatchAudit">
|
||||||
|
提交
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="formVisible" :title="formMode === 'create' ? '新建约拍' : '编辑约拍'" width="860px">
|
||||||
|
<el-form label-position="top" class="admin-form event-form">
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col :md="12" :sm="24">
|
||||||
|
<el-form-item label="发布用户">
|
||||||
|
<el-select v-model="form.creator_id" filterable remote reserve-keyword clearable :remote-method="loadUserOptions" placeholder="搜索用户">
|
||||||
|
<el-option v-for="item in creatorOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :md="12" :sm="24">
|
||||||
|
<el-form-item label="关联取景地">
|
||||||
|
<el-select v-model="form.spot_id" filterable remote reserve-keyword clearable :remote-method="loadSpotOptions" placeholder="可选">
|
||||||
|
<el-option v-for="item in spotOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col :md="12" :sm="24">
|
||||||
|
<el-form-item label="标题">
|
||||||
|
<el-input v-model="form.title" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :md="12" :sm="24">
|
||||||
|
<el-form-item label="城市">
|
||||||
|
<el-input v-model="form.city" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col :md="12" :sm="24">
|
||||||
|
<el-form-item label="风格">
|
||||||
|
<el-input v-model="form.style" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :md="12" :sm="24">
|
||||||
|
<el-form-item label="拍摄时间">
|
||||||
|
<el-input v-model="form.shoot_date" placeholder="YYYY-MM-DDTHH:mm:ss" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col :md="8" :sm="24">
|
||||||
|
<el-form-item label="需求角色">
|
||||||
|
<el-select v-model="form.role_needed">
|
||||||
|
<el-option label="摄影师" value="photographer" />
|
||||||
|
<el-option label="Coser" value="cosplayer" />
|
||||||
|
<el-option label="不限" value="both" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :md="8" :sm="24">
|
||||||
|
<el-form-item label="业务状态">
|
||||||
|
<el-select v-model="form.status">
|
||||||
|
<el-option label="招募中" value="open" />
|
||||||
|
<el-option label="匹配完成" value="matched" />
|
||||||
|
<el-option label="已关闭" value="closed" />
|
||||||
|
<el-option label="已取消" value="cancelled" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :md="8" :sm="24">
|
||||||
|
<el-form-item label="审核状态">
|
||||||
|
<el-select v-model="form.audit_status">
|
||||||
|
<el-option label="待审核" value="pending" />
|
||||||
|
<el-option label="已通过" value="approved" />
|
||||||
|
<el-option label="已驳回" value="rejected" />
|
||||||
|
<el-option label="已删除" value="deleted" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col :md="8" :sm="24">
|
||||||
|
<el-form-item label="人数上限">
|
||||||
|
<el-input-number v-model="form.max_applicants" :min="1" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :md="8" :sm="24">
|
||||||
|
<el-form-item label="是否免费">
|
||||||
|
<el-switch v-model="form.is_free" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :md="8" :sm="24">
|
||||||
|
<el-form-item label="联系方式">
|
||||||
|
<el-input v-model="form.contact_info" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col :md="12" :sm="24">
|
||||||
|
<el-form-item label="预算最低">
|
||||||
|
<el-input-number v-model="form.budget_min" :disabled="form.is_free" :min="0" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :md="12" :sm="24">
|
||||||
|
<el-form-item label="预算最高">
|
||||||
|
<el-input-number v-model="form.budget_max" :disabled="form.is_free" :min="0" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-form-item label="描述">
|
||||||
|
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item v-if="form.audit_status === 'rejected'" label="驳回原因">
|
||||||
|
<el-input v-model="form.reject_reason" type="textarea" :rows="3" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="formVisible = false">
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="submitForm">
|
||||||
|
提交
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="appVisible" :title="`申请管理:${appRequest?.title || ''}`" width="760px">
|
||||||
|
<el-form inline class="admin-form">
|
||||||
|
<el-form-item label="申请用户">
|
||||||
|
<el-select v-model="appForm.applicant_id" filterable remote reserve-keyword clearable :remote-method="loadUserOptions" placeholder="选择用户">
|
||||||
|
<el-option v-for="item in userOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="appForm.status">
|
||||||
|
<el-option label="待处理" value="pending" />
|
||||||
|
<el-option label="已接受" value="accepted" />
|
||||||
|
<el-option label="已拒绝" value="rejected" />
|
||||||
|
<el-option label="已取消" value="cancelled" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="留言">
|
||||||
|
<el-input v-model="appForm.message" placeholder="可选" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="submitApp">
|
||||||
|
{{ appEditRow ? '更新' : '新增' }}
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
|
||||||
|
<el-table :data="appRows" border class="table-box">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="applicant_nickname" label="申请用户" min-width="130" />
|
||||||
|
<el-table-column prop="status" label="状态" width="110" />
|
||||||
|
<el-table-column prop="message" label="留言" min-width="150" show-overflow-tooltip />
|
||||||
|
<el-table-column prop="created_at" label="创建时间" min-width="170" />
|
||||||
|
<el-table-column label="操作" width="180">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button link type="primary" @click="editApp(row)">
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button link type="danger" @click="removeApp(row)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-drawer v-model="detailVisible" title="约拍详情聚合" size="720px">
|
||||||
|
<el-skeleton v-if="detailLoading" :rows="10" animated />
|
||||||
|
<template v-else-if="detailData">
|
||||||
|
<el-descriptions :column="2" border class="detail-desc">
|
||||||
|
<el-descriptions-item label="ID">{{ detailData.id }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="标题">{{ detailData.title }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="城市">{{ detailData.city }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="发布者">#{{ detailData.creator_id }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="需求角色">{{ detailData.role_needed }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="业务状态">{{ detailData.status }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="审核状态">{{ detailData.audit_status }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="人数上限">{{ detailData.max_applicants }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<div class="detail-block">
|
||||||
|
<h4>申请记录({{ detailData.applications?.length || 0 }})</h4>
|
||||||
|
<ul class="detail-list">
|
||||||
|
<li v-for="item in (detailData.applications || [])" :key="item.id">
|
||||||
|
#{{ item.id }} 申请人#{{ item.applicant_id }} {{ item.applicant_nickname }} - {{ item.status }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="detail-block">
|
||||||
|
<h4>描述</h4>
|
||||||
|
<p>{{ detailData.description || '-' }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.head-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.filter-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.filter-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 8px;
|
||||||
|
}
|
||||||
|
.stat-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.stat span {
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.stat strong {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.stat.warn strong {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
.stat.ok strong {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
.spot-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.spot-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.spot-card:hover {
|
||||||
|
box-shadow: var(--shadow-card-hover);
|
||||||
|
}
|
||||||
|
.spot-card header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.title-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.spot-card h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.reason {
|
||||||
|
min-height: 36px;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.spot-card footer {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.loading-card,
|
||||||
|
.empty {
|
||||||
|
padding: 18px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.pager {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.event-form {
|
||||||
|
max-height: 68vh;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 6px;
|
||||||
|
}
|
||||||
|
.table-box {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
.detail-desc {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.detail-block {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.detail-block h4 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.detail-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.spot-grid,
|
||||||
|
.stat-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,880 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
adminAuditSpot,
|
||||||
|
adminBatchAuditSpots,
|
||||||
|
adminCreateSpot,
|
||||||
|
adminDeleteSpot,
|
||||||
|
adminGetSpot,
|
||||||
|
adminListSpots,
|
||||||
|
adminSpotTagOptions,
|
||||||
|
adminUpdateSpot,
|
||||||
|
adminUserOptions,
|
||||||
|
isRequestNotFound,
|
||||||
|
} from '~/composables/admin-api'
|
||||||
|
|
||||||
|
type OptionItem = { label: string, value: number }
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const total = ref(0)
|
||||||
|
const rows = ref<any[]>([])
|
||||||
|
const creatorOptions = ref<OptionItem[]>([])
|
||||||
|
const tagOptions = ref<OptionItem[]>([])
|
||||||
|
const selectedIds = ref<number[]>([])
|
||||||
|
const detailVisible = ref(false)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const detailData = ref<any>(null)
|
||||||
|
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const currentRow = ref<any>(null)
|
||||||
|
const auditForm = reactive({
|
||||||
|
audit_status: 'approved',
|
||||||
|
reject_reason: '',
|
||||||
|
})
|
||||||
|
const batchDialogVisible = ref(false)
|
||||||
|
const batchAuditForm = reactive({
|
||||||
|
audit_status: 'approved',
|
||||||
|
reject_reason: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const formVisible = ref(false)
|
||||||
|
const formMode = ref<'create' | 'edit'>('create')
|
||||||
|
const currentSpotId = ref<number | null>(null)
|
||||||
|
const form = reactive({
|
||||||
|
title: '',
|
||||||
|
city: '',
|
||||||
|
creator_id: undefined as number | undefined,
|
||||||
|
longitude: 121.4737,
|
||||||
|
latitude: 31.2304,
|
||||||
|
description: '',
|
||||||
|
transport: '',
|
||||||
|
best_time: '',
|
||||||
|
difficulty: '',
|
||||||
|
is_free: true,
|
||||||
|
price_min: undefined as number | undefined,
|
||||||
|
price_max: undefined as number | undefined,
|
||||||
|
audit_status: 'pending',
|
||||||
|
reject_reason: '',
|
||||||
|
tag_ids: [] as number[],
|
||||||
|
image_urls: [''] as string[],
|
||||||
|
})
|
||||||
|
|
||||||
|
const filters = reactive({
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
keyword: '',
|
||||||
|
city: '',
|
||||||
|
audit_status: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const canSubmitRejectReason = computed(() => auditForm.audit_status !== 'rejected' || auditForm.reject_reason.trim().length > 0)
|
||||||
|
const pendingCount = computed(() => rows.value.filter(r => r.audit_status === 'pending').length)
|
||||||
|
const approvedCount = computed(() => rows.value.filter(r => r.audit_status === 'approved').length)
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form.title = ''
|
||||||
|
form.city = ''
|
||||||
|
form.creator_id = undefined
|
||||||
|
form.longitude = 121.4737
|
||||||
|
form.latitude = 31.2304
|
||||||
|
form.description = ''
|
||||||
|
form.transport = ''
|
||||||
|
form.best_time = ''
|
||||||
|
form.difficulty = ''
|
||||||
|
form.is_free = true
|
||||||
|
form.price_min = undefined
|
||||||
|
form.price_max = undefined
|
||||||
|
form.audit_status = 'pending'
|
||||||
|
form.reject_reason = ''
|
||||||
|
form.tag_ids = []
|
||||||
|
form.image_urls = ['']
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await adminListSpots(filters)
|
||||||
|
rows.value = data.items || []
|
||||||
|
total.value = data.total || 0
|
||||||
|
const currentIds = new Set(rows.value.map((item: any) => item.id))
|
||||||
|
selectedIds.value = selectedIds.value.filter(id => currentIds.has(id))
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '加载失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCreatorOptions(keyword = '') {
|
||||||
|
try {
|
||||||
|
const items = await adminUserOptions(keyword)
|
||||||
|
creatorOptions.value = (items || []).map((item: any) => ({
|
||||||
|
label: `${item.title} (#${item.id})`,
|
||||||
|
value: item.id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
creatorOptions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTagOptions(keyword = '') {
|
||||||
|
try {
|
||||||
|
const items = await adminSpotTagOptions(keyword)
|
||||||
|
tagOptions.value = (items || []).map((item: any) => ({
|
||||||
|
label: item.title,
|
||||||
|
value: item.id,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
tagOptions.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addImageInput() {
|
||||||
|
form.image_urls.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImageInput(index: number) {
|
||||||
|
form.image_urls.splice(index, 1)
|
||||||
|
if (!form.image_urls.length)
|
||||||
|
form.image_urls.push('')
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePayload() {
|
||||||
|
const image_urls = form.image_urls.map(item => item.trim()).filter(Boolean)
|
||||||
|
const payload: Record<string, any> = {
|
||||||
|
title: form.title.trim(),
|
||||||
|
city: form.city.trim(),
|
||||||
|
creator_id: form.creator_id,
|
||||||
|
longitude: Number(form.longitude),
|
||||||
|
latitude: Number(form.latitude),
|
||||||
|
description: form.description?.trim() || null,
|
||||||
|
transport: form.transport?.trim() || null,
|
||||||
|
best_time: form.best_time?.trim() || null,
|
||||||
|
difficulty: form.difficulty?.trim() || null,
|
||||||
|
is_free: form.is_free,
|
||||||
|
price_min: form.is_free ? null : form.price_min ?? null,
|
||||||
|
price_max: form.is_free ? null : form.price_max ?? null,
|
||||||
|
audit_status: form.audit_status,
|
||||||
|
reject_reason: form.audit_status === 'rejected' ? (form.reject_reason?.trim() || null) : null,
|
||||||
|
tag_ids: [...form.tag_ids],
|
||||||
|
image_urls,
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm() {
|
||||||
|
if (!form.title.trim()) {
|
||||||
|
ElMessage.error('请填写标题')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!form.city.trim()) {
|
||||||
|
ElMessage.error('请填写城市')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!form.creator_id) {
|
||||||
|
ElMessage.error('请选择创建用户')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (Number.isNaN(Number(form.longitude)) || Number.isNaN(Number(form.latitude))) {
|
||||||
|
ElMessage.error('请填写有效经纬度')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!form.is_free && form.price_min !== undefined && form.price_max !== undefined && Number(form.price_min) > Number(form.price_max)) {
|
||||||
|
ElMessage.error('价格区间不正确')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (form.audit_status === 'rejected' && !form.reject_reason.trim()) {
|
||||||
|
ElMessage.error('驳回状态必须填写原因')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch() {
|
||||||
|
filters.page = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageChange(page: number) {
|
||||||
|
filters.page = page
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openAudit(row: any) {
|
||||||
|
currentRow.value = row
|
||||||
|
auditForm.audit_status = row.audit_status || 'approved'
|
||||||
|
auditForm.reject_reason = row.reject_reason || ''
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelect(row: any) {
|
||||||
|
if (selectedIds.value.includes(row.id))
|
||||||
|
selectedIds.value = selectedIds.value.filter(id => id !== row.id)
|
||||||
|
else
|
||||||
|
selectedIds.value.push(row.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openBatchAudit() {
|
||||||
|
if (!selectedIds.value.length) {
|
||||||
|
ElMessage.error('请先勾选要批量审核的数据')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
batchAuditForm.audit_status = 'approved'
|
||||||
|
batchAuditForm.reject_reason = ''
|
||||||
|
batchDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitAudit() {
|
||||||
|
if (!currentRow.value)
|
||||||
|
return
|
||||||
|
if (!canSubmitRejectReason.value) {
|
||||||
|
ElMessage.error('驳回时必须填写驳回原因')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await adminAuditSpot(currentRow.value.id, {
|
||||||
|
audit_status: auditForm.audit_status,
|
||||||
|
reject_reason: auditForm.reject_reason || undefined,
|
||||||
|
})
|
||||||
|
ElMessage.success('审核状态已更新')
|
||||||
|
dialogVisible.value = false
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '提交失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitBatchAudit() {
|
||||||
|
if (batchAuditForm.audit_status === 'rejected' && !batchAuditForm.reject_reason.trim()) {
|
||||||
|
ElMessage.error('批量驳回时必须填写原因')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
await adminBatchAuditSpots({
|
||||||
|
ids: [...selectedIds.value],
|
||||||
|
audit_status: batchAuditForm.audit_status,
|
||||||
|
reject_reason: batchAuditForm.reject_reason || undefined,
|
||||||
|
})
|
||||||
|
ElMessage.success(`批量审核完成(${selectedIds.value.length}条)`)
|
||||||
|
batchDialogVisible.value = false
|
||||||
|
selectedIds.value = []
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '批量审核失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCreate() {
|
||||||
|
formMode.value = 'create'
|
||||||
|
currentSpotId.value = null
|
||||||
|
resetForm()
|
||||||
|
await Promise.all([loadCreatorOptions(), loadTagOptions()])
|
||||||
|
formVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openEdit(row: any) {
|
||||||
|
formMode.value = 'edit'
|
||||||
|
currentSpotId.value = row.id
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const detail = await adminGetSpot(row.id)
|
||||||
|
form.title = detail.title || ''
|
||||||
|
form.city = detail.city || ''
|
||||||
|
form.creator_id = detail.creator_id
|
||||||
|
form.longitude = detail.longitude || 0
|
||||||
|
form.latitude = detail.latitude || 0
|
||||||
|
form.description = detail.description || ''
|
||||||
|
form.transport = detail.transport || ''
|
||||||
|
form.best_time = detail.best_time || ''
|
||||||
|
form.difficulty = detail.difficulty || ''
|
||||||
|
form.is_free = detail.is_free
|
||||||
|
form.price_min = detail.price_min ?? undefined
|
||||||
|
form.price_max = detail.price_max ?? undefined
|
||||||
|
form.audit_status = detail.audit_status || 'pending'
|
||||||
|
form.reject_reason = detail.reject_reason || ''
|
||||||
|
form.tag_ids = detail.tag_ids || []
|
||||||
|
form.image_urls = detail.image_urls?.length ? [...detail.image_urls] : ['']
|
||||||
|
await Promise.all([loadCreatorOptions(), loadTagOptions()])
|
||||||
|
formVisible.value = true
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
if (isRequestNotFound(err)) {
|
||||||
|
ElMessage.warning('取景地数据不存在或已被删除,列表已刷新')
|
||||||
|
loadData()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.error(err?.message || '加载详情失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(row: any) {
|
||||||
|
detailVisible.value = true
|
||||||
|
detailLoading.value = true
|
||||||
|
detailData.value = null
|
||||||
|
try {
|
||||||
|
detailData.value = await adminGetSpot(row.id)
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
if (isRequestNotFound(err)) {
|
||||||
|
detailVisible.value = false
|
||||||
|
ElMessage.warning('取景地数据不存在或已被删除,列表已刷新')
|
||||||
|
loadData()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ElMessage.error(err?.message || '详情加载失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForm() {
|
||||||
|
if (!validateForm())
|
||||||
|
return
|
||||||
|
const payload = normalizePayload()
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
if (formMode.value === 'create') {
|
||||||
|
await adminCreateSpot(payload)
|
||||||
|
ElMessage.success('取景地已创建')
|
||||||
|
}
|
||||||
|
else if (currentSpotId.value) {
|
||||||
|
await adminUpdateSpot(currentSpotId.value, payload)
|
||||||
|
ElMessage.success('取景地已更新')
|
||||||
|
}
|
||||||
|
formVisible.value = false
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '提交失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(row: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认删除取景地「${row.title}」?删除后不可恢复。`, '删除确认', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '删除',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
})
|
||||||
|
await adminDeleteSpot(row.id)
|
||||||
|
ElMessage.success('删除成功')
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
catch {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([loadData(), loadTagOptions(), loadCreatorOptions()])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-head">
|
||||||
|
<div>
|
||||||
|
<h2>取景地管理</h2>
|
||||||
|
<p>支持筛选、审核与完整 CRUD(含图片列表与标签关系)</p>
|
||||||
|
</div>
|
||||||
|
<div class="head-actions">
|
||||||
|
<el-button @click="openBatchAudit">
|
||||||
|
批量审核({{ selectedIds.length }})
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" @click="openCreate">
|
||||||
|
新建取景地
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="stat-row">
|
||||||
|
<article class="stat panel-card">
|
||||||
|
<span>本页总量</span>
|
||||||
|
<strong>{{ rows.length }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="stat panel-card warn">
|
||||||
|
<span>待审核</span>
|
||||||
|
<strong>{{ pendingCount }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="stat panel-card ok">
|
||||||
|
<span>已通过</span>
|
||||||
|
<strong>{{ approvedCount }}</strong>
|
||||||
|
</article>
|
||||||
|
<article class="stat panel-card">
|
||||||
|
<span>全量数据</span>
|
||||||
|
<strong>{{ total }}</strong>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="filter-card panel-card">
|
||||||
|
<el-form inline class="admin-form">
|
||||||
|
<div class="filter-grid">
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input v-model="filters.keyword" placeholder="标题/描述" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="城市">
|
||||||
|
<el-input v-model="filters.city" placeholder="城市" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="审核状态">
|
||||||
|
<el-select v-model="filters.audit_status" clearable placeholder="全部">
|
||||||
|
<el-option label="待审核" value="pending" />
|
||||||
|
<el-option label="已通过" value="approved" />
|
||||||
|
<el-option label="已驳回" value="rejected" />
|
||||||
|
<el-option label="已删除" value="deleted" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="loading" @click="onSearch">
|
||||||
|
查询
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="filters.keyword = ''; filters.city = ''; filters.audit_status = ''; onSearch()">
|
||||||
|
重置
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<section v-if="!loading && rows.length" class="spot-grid">
|
||||||
|
<article v-for="row in rows" :key="row.id" class="spot-card panel-card">
|
||||||
|
<header>
|
||||||
|
<div class="title-wrap">
|
||||||
|
<el-checkbox :model-value="selectedIds.includes(row.id)" @change="toggleSelect(row)" />
|
||||||
|
<h4>{{ row.title }}</h4>
|
||||||
|
</div>
|
||||||
|
<el-tag
|
||||||
|
:type="row.audit_status === 'approved' ? 'success' : row.audit_status === 'pending' ? 'warning' : 'danger'"
|
||||||
|
effect="light"
|
||||||
|
round
|
||||||
|
>
|
||||||
|
{{ row.audit_status }}
|
||||||
|
</el-tag>
|
||||||
|
</header>
|
||||||
|
<div class="meta">
|
||||||
|
<span>城市:{{ row.city || '-' }}</span>
|
||||||
|
<span>创建者:#{{ row.creator_id }}</span>
|
||||||
|
<span>ID:{{ row.id }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="reason">
|
||||||
|
{{ row.reject_reason || '暂无驳回原因' }}
|
||||||
|
</p>
|
||||||
|
<footer>
|
||||||
|
<el-button @click="openDetail(row)">
|
||||||
|
详情聚合
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" plain @click="openEdit(row)">
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="openAudit(row)">
|
||||||
|
审核
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" plain @click="onDelete(row)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</footer>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-empty v-else-if="!loading && !rows.length" description="暂无数据" class="panel-card empty" />
|
||||||
|
<el-skeleton v-else :rows="6" animated class="panel-card loading-card" />
|
||||||
|
|
||||||
|
<div class="pager">
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
:total="total"
|
||||||
|
:page-size="filters.page_size"
|
||||||
|
:current-page="filters.page"
|
||||||
|
@current-change="onPageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" title="审核设置" width="460px">
|
||||||
|
<el-form label-position="top" class="admin-form">
|
||||||
|
<el-form-item label="审核状态">
|
||||||
|
<el-select v-model="auditForm.audit_status">
|
||||||
|
<el-option label="待审核" value="pending" />
|
||||||
|
<el-option label="已通过" value="approved" />
|
||||||
|
<el-option label="已驳回" value="rejected" />
|
||||||
|
<el-option label="已删除" value="deleted" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="驳回原因(驳回时必填)">
|
||||||
|
<el-input v-model="auditForm.reject_reason" type="textarea" :rows="4" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="submitAudit">
|
||||||
|
提交
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="formVisible" :title="formMode === 'create' ? '新建取景地' : '编辑取景地'" width="860px">
|
||||||
|
<el-form label-position="top" class="admin-form spot-form">
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col :md="12" :sm="24">
|
||||||
|
<el-form-item label="标题">
|
||||||
|
<el-input v-model="form.title" placeholder="请输入标题" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :md="12" :sm="24">
|
||||||
|
<el-form-item label="城市">
|
||||||
|
<el-input v-model="form.city" placeholder="请输入城市" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col :md="12" :sm="24">
|
||||||
|
<el-form-item label="创建用户">
|
||||||
|
<el-select
|
||||||
|
v-model="form.creator_id"
|
||||||
|
filterable
|
||||||
|
remote
|
||||||
|
reserve-keyword
|
||||||
|
clearable
|
||||||
|
placeholder="搜索昵称/手机号/邮箱"
|
||||||
|
:remote-method="loadCreatorOptions"
|
||||||
|
>
|
||||||
|
<el-option v-for="item in creatorOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :md="12" :sm="24">
|
||||||
|
<el-form-item label="审核状态">
|
||||||
|
<el-select v-model="form.audit_status">
|
||||||
|
<el-option label="待审核" value="pending" />
|
||||||
|
<el-option label="已通过" value="approved" />
|
||||||
|
<el-option label="已驳回" value="rejected" />
|
||||||
|
<el-option label="已删除" value="deleted" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col :md="12" :sm="24">
|
||||||
|
<el-form-item label="经度">
|
||||||
|
<el-input-number v-model="form.longitude" :precision="6" :step="0.000001" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :md="12" :sm="24">
|
||||||
|
<el-form-item label="纬度">
|
||||||
|
<el-input-number v-model="form.latitude" :precision="6" :step="0.000001" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-form-item label="标签(多选)">
|
||||||
|
<el-select
|
||||||
|
v-model="form.tag_ids"
|
||||||
|
multiple
|
||||||
|
filterable
|
||||||
|
remote
|
||||||
|
reserve-keyword
|
||||||
|
clearable
|
||||||
|
placeholder="搜索并选择标签"
|
||||||
|
:remote-method="loadTagOptions"
|
||||||
|
>
|
||||||
|
<el-option v-for="item in tagOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="描述">
|
||||||
|
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col :md="12" :sm="24">
|
||||||
|
<el-form-item label="交通">
|
||||||
|
<el-input v-model="form.transport" placeholder="地铁/公交/停车信息" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :md="12" :sm="24">
|
||||||
|
<el-form-item label="最佳时间">
|
||||||
|
<el-input v-model="form.best_time" placeholder="如:17:30-19:00" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-form-item label="难度">
|
||||||
|
<el-input v-model="form.difficulty" type="textarea" :rows="2" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-row :gutter="12">
|
||||||
|
<el-col :md="8" :sm="24">
|
||||||
|
<el-form-item label="是否免费">
|
||||||
|
<el-switch v-model="form.is_free" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :md="8" :sm="24">
|
||||||
|
<el-form-item label="最低价格">
|
||||||
|
<el-input-number v-model="form.price_min" :disabled="form.is_free" :min="0" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
<el-col :md="8" :sm="24">
|
||||||
|
<el-form-item label="最高价格">
|
||||||
|
<el-input-number v-model="form.price_max" :disabled="form.is_free" :min="0" style="width: 100%" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-col>
|
||||||
|
</el-row>
|
||||||
|
|
||||||
|
<el-form-item v-if="form.audit_status === 'rejected'" label="驳回原因">
|
||||||
|
<el-input v-model="form.reject_reason" type="textarea" :rows="2" />
|
||||||
|
</el-form-item>
|
||||||
|
|
||||||
|
<el-form-item label="图片 URL 列表(JSON数组)">
|
||||||
|
<div class="image-list">
|
||||||
|
<div v-for="(item, idx) in form.image_urls" :key="idx" class="image-row">
|
||||||
|
<el-input v-model="form.image_urls[idx]" placeholder="https://..." />
|
||||||
|
<el-button v-if="form.image_urls.length > 1" @click="removeImageInput(idx)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<el-button class="image-add" @click="addImageInput">
|
||||||
|
新增图片
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="formVisible = false">
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="submitForm">
|
||||||
|
提交
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="batchDialogVisible" title="批量审核设置" width="460px">
|
||||||
|
<el-form label-position="top" class="admin-form">
|
||||||
|
<el-form-item label="审核状态">
|
||||||
|
<el-select v-model="batchAuditForm.audit_status">
|
||||||
|
<el-option label="待审核" value="pending" />
|
||||||
|
<el-option label="已通过" value="approved" />
|
||||||
|
<el-option label="已驳回" value="rejected" />
|
||||||
|
<el-option label="已删除" value="deleted" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="驳回原因(批量驳回时必填)">
|
||||||
|
<el-input v-model="batchAuditForm.reject_reason" type="textarea" :rows="4" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-alert type="info" :closable="false" show-icon :title="`已选择 ${selectedIds.length} 条记录`" />
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="batchDialogVisible = false">
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="submitBatchAudit">
|
||||||
|
提交
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-drawer v-model="detailVisible" title="取景地详情聚合" size="640px">
|
||||||
|
<el-skeleton v-if="detailLoading" :rows="10" animated />
|
||||||
|
<template v-else-if="detailData">
|
||||||
|
<el-descriptions :column="2" border class="detail-desc">
|
||||||
|
<el-descriptions-item label="ID">{{ detailData.id }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="标题">{{ detailData.title }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="城市">{{ detailData.city }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="创建者">#{{ detailData.creator_id }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="审核状态">{{ detailData.audit_status }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="是否免费">{{ detailData.is_free ? '是' : '否' }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="经度">{{ detailData.longitude }}</el-descriptions-item>
|
||||||
|
<el-descriptions-item label="纬度">{{ detailData.latitude }}</el-descriptions-item>
|
||||||
|
</el-descriptions>
|
||||||
|
<div class="detail-block">
|
||||||
|
<h4>标签({{ detailData.tag_ids?.length || 0 }})</h4>
|
||||||
|
<el-tag v-for="tagId in (detailData.tag_ids || [])" :key="tagId" class="tag-item" effect="light">#{{ tagId }}</el-tag>
|
||||||
|
</div>
|
||||||
|
<div class="detail-block">
|
||||||
|
<h4>图片列表({{ detailData.image_urls?.length || 0 }})</h4>
|
||||||
|
<ul class="detail-list">
|
||||||
|
<li v-for="(url, idx) in (detailData.image_urls || [])" :key="`${url}-${idx}`">{{ url }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="detail-block">
|
||||||
|
<h4>描述</h4>
|
||||||
|
<p>{{ detailData.description || '-' }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.page-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.head-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.filter-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.filter-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 8px;
|
||||||
|
}
|
||||||
|
.stat-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.stat {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.stat span {
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.stat strong {
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.stat.warn strong {
|
||||||
|
color: var(--warn);
|
||||||
|
}
|
||||||
|
.stat.ok strong {
|
||||||
|
color: var(--ok);
|
||||||
|
}
|
||||||
|
.spot-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
.spot-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
.spot-card:hover {
|
||||||
|
box-shadow: var(--shadow-card-hover);
|
||||||
|
}
|
||||||
|
.spot-card header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.title-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.spot-card h4 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
.meta {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px 12px;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.reason {
|
||||||
|
min-height: 36px;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
.spot-card footer {
|
||||||
|
margin-top: 12px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.loading-card,
|
||||||
|
.empty {
|
||||||
|
padding: 18px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
.pager {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.spot-form {
|
||||||
|
max-height: 68vh;
|
||||||
|
overflow: auto;
|
||||||
|
padding-right: 6px;
|
||||||
|
}
|
||||||
|
.image-list {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.image-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.image-add {
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
.detail-desc {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.detail-block {
|
||||||
|
margin-top: 16px;
|
||||||
|
}
|
||||||
|
.detail-block h4 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.detail-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 18px;
|
||||||
|
}
|
||||||
|
.tag-item {
|
||||||
|
margin-right: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.spot-grid,
|
||||||
|
.stat-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
|
import {
|
||||||
|
adminCreateUser,
|
||||||
|
adminDeleteUser,
|
||||||
|
adminListUsers,
|
||||||
|
adminUpdateUser,
|
||||||
|
} from '~/composables/admin-api'
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const submitting = ref(false)
|
||||||
|
const total = ref(0)
|
||||||
|
const rows = ref<any[]>([])
|
||||||
|
const dialogVisible = ref(false)
|
||||||
|
const editingId = ref<number | null>(null)
|
||||||
|
const filters = reactive({
|
||||||
|
page: 1,
|
||||||
|
page_size: 20,
|
||||||
|
keyword: '',
|
||||||
|
role: '',
|
||||||
|
is_active: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
nickname: '',
|
||||||
|
phone: '',
|
||||||
|
email: '',
|
||||||
|
city: '',
|
||||||
|
identity: 'both',
|
||||||
|
role: 'user',
|
||||||
|
is_active: true,
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: Record<string, any> = { ...filters }
|
||||||
|
if (filters.is_active !== '')
|
||||||
|
params.is_active = filters.is_active === 'true'
|
||||||
|
const data = await adminListUsers(params)
|
||||||
|
rows.value = data.items || []
|
||||||
|
total.value = data.total || 0
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '用户数据加载失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSearch() {
|
||||||
|
filters.page = 1
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPageChange(page: number) {
|
||||||
|
filters.page = page
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
editingId.value = null
|
||||||
|
form.nickname = ''
|
||||||
|
form.phone = ''
|
||||||
|
form.email = ''
|
||||||
|
form.city = ''
|
||||||
|
form.identity = 'both'
|
||||||
|
form.role = 'user'
|
||||||
|
form.is_active = true
|
||||||
|
form.password = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
resetForm()
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(row: any) {
|
||||||
|
editingId.value = row.id
|
||||||
|
form.nickname = row.nickname || ''
|
||||||
|
form.phone = row.phone || ''
|
||||||
|
form.email = row.email || ''
|
||||||
|
form.city = row.city || ''
|
||||||
|
form.identity = row.identity || 'both'
|
||||||
|
form.role = row.role || 'user'
|
||||||
|
form.is_active = !!row.is_active
|
||||||
|
form.password = ''
|
||||||
|
dialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitForm() {
|
||||||
|
submitting.value = true
|
||||||
|
try {
|
||||||
|
const payload: Record<string, any> = {
|
||||||
|
nickname: form.nickname,
|
||||||
|
phone: form.phone || null,
|
||||||
|
email: form.email || null,
|
||||||
|
city: form.city || null,
|
||||||
|
identity: form.identity,
|
||||||
|
role: form.role,
|
||||||
|
is_active: form.is_active,
|
||||||
|
}
|
||||||
|
if (form.password.trim())
|
||||||
|
payload.password = form.password.trim()
|
||||||
|
|
||||||
|
if (editingId.value) {
|
||||||
|
await adminUpdateUser(editingId.value, payload)
|
||||||
|
ElMessage.success('更新成功')
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
if (!payload.password) {
|
||||||
|
ElMessage.error('新增用户必须填写密码')
|
||||||
|
submitting.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
await adminCreateUser(payload)
|
||||||
|
ElMessage.success('创建成功')
|
||||||
|
}
|
||||||
|
dialogVisible.value = false
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
catch (err: any) {
|
||||||
|
ElMessage.error(err?.message || '保存失败')
|
||||||
|
}
|
||||||
|
finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUser(row: any) {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(`确认停用用户「${row.nickname}」吗?`, '停用确认', { type: 'warning' })
|
||||||
|
await adminDeleteUser(row.id)
|
||||||
|
ElMessage.success('已停用')
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadData)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="page-head">
|
||||||
|
<h2>用户与权限</h2>
|
||||||
|
<p>用户列表、角色权限、账号启停用统一管理</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="filter-card panel-card">
|
||||||
|
<el-form inline class="admin-form">
|
||||||
|
<div class="filter-grid">
|
||||||
|
<el-form-item label="关键词">
|
||||||
|
<el-input v-model="filters.keyword" placeholder="昵称/手机号/邮箱" clearable />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="角色">
|
||||||
|
<el-select v-model="filters.role" clearable placeholder="全部">
|
||||||
|
<el-option label="用户" value="user" />
|
||||||
|
<el-option label="审核员" value="moderator" />
|
||||||
|
<el-option label="管理员" value="admin" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="状态">
|
||||||
|
<el-select v-model="filters.is_active" clearable placeholder="全部">
|
||||||
|
<el-option label="启用" value="true" />
|
||||||
|
<el-option label="停用" value="false" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-button type="primary" :loading="loading" @click="onSearch">
|
||||||
|
查询
|
||||||
|
</el-button>
|
||||||
|
<el-button @click="filters.keyword = ''; filters.role = ''; filters.is_active = ''; onSearch()">
|
||||||
|
重置
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" plain @click="openCreate">
|
||||||
|
新增用户
|
||||||
|
</el-button>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
</el-form>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<el-card shadow="never" class="panel-card">
|
||||||
|
<el-table :data="rows" v-loading="loading">
|
||||||
|
<el-table-column prop="id" label="ID" width="80" />
|
||||||
|
<el-table-column prop="nickname" label="昵称" width="140" />
|
||||||
|
<el-table-column prop="phone" label="手机号" width="140" />
|
||||||
|
<el-table-column prop="email" label="邮箱" min-width="180" />
|
||||||
|
<el-table-column prop="city" label="城市" width="120" />
|
||||||
|
<el-table-column label="身份" width="120">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.identity || '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="角色" width="110">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.role === 'admin' ? 'danger' : row.role === 'moderator' ? 'warning' : 'info'" effect="light" round>
|
||||||
|
{{ row.role }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" width="100">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag :type="row.is_active ? 'success' : 'info'" effect="light" round>
|
||||||
|
{{ row.is_active ? '启用' : '停用' }}
|
||||||
|
</el-tag>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="200" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link @click="openEdit(row)">
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button type="danger" link @click="removeUser(row)">
|
||||||
|
停用
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</el-card>
|
||||||
|
|
||||||
|
<div class="pager">
|
||||||
|
<el-pagination
|
||||||
|
background
|
||||||
|
layout="total, prev, pager, next"
|
||||||
|
:total="total"
|
||||||
|
:page-size="filters.page_size"
|
||||||
|
:current-page="filters.page"
|
||||||
|
@current-change="onPageChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑用户' : '新增用户'" width="620px">
|
||||||
|
<el-form class="admin-form" label-position="top">
|
||||||
|
<div class="form-grid">
|
||||||
|
<el-form-item label="昵称">
|
||||||
|
<el-input v-model="form.nickname" placeholder="请输入昵称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="手机号">
|
||||||
|
<el-input v-model="form.phone" placeholder="可选" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="邮箱">
|
||||||
|
<el-input v-model="form.email" placeholder="可选" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="城市">
|
||||||
|
<el-input v-model="form.city" placeholder="可选" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="身份">
|
||||||
|
<el-select v-model="form.identity">
|
||||||
|
<el-option label="摄影师" value="photographer" />
|
||||||
|
<el-option label="Coser" value="cosplayer" />
|
||||||
|
<el-option label="双身份" value="both" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="角色">
|
||||||
|
<el-select v-model="form.role">
|
||||||
|
<el-option label="用户" value="user" />
|
||||||
|
<el-option label="审核员" value="moderator" />
|
||||||
|
<el-option label="管理员" value="admin" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item :label="editingId ? '重置密码(可选)' : '密码'">
|
||||||
|
<el-input v-model="form.password" type="password" show-password :placeholder="editingId ? '留空则不修改' : '至少6位'" />
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-form-item label="账号状态">
|
||||||
|
<el-switch v-model="form.is_active" active-text="启用" inactive-text="停用" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="dialogVisible = false">
|
||||||
|
取消
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" :loading="submitting" @click="submitForm">
|
||||||
|
保存
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.filter-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.filter-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 8px;
|
||||||
|
}
|
||||||
|
.pager {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0 12px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||||
|
|
||||||
|
import AdminLayout from '~/layouts/AdminLayout.vue'
|
||||||
|
import LoginPage from '~/pages/login.vue'
|
||||||
|
import DashboardPage from '~/pages/dashboard.vue'
|
||||||
|
import SpotsPage from '~/pages/spots.vue'
|
||||||
|
import EventsPage from '~/pages/events.vue'
|
||||||
|
import ShootingPage from '~/pages/shooting.vue'
|
||||||
|
import ModuleDesignPage from '~/pages/module-design.vue'
|
||||||
|
import NavConfigsPage from '~/pages/nav-configs.vue'
|
||||||
|
import PromotionsPage from '~/pages/promotions.vue'
|
||||||
|
import UsersPage from '~/pages/users.vue'
|
||||||
|
import MembershipPage from '~/pages/membership.vue'
|
||||||
|
import OpsPage from '~/pages/ops.vue'
|
||||||
|
import { getAdminToken } from '~/composables/admin-auth'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHashHistory(),
|
||||||
|
routes: [
|
||||||
|
{
|
||||||
|
path: '/',
|
||||||
|
component: AdminLayout,
|
||||||
|
children: [
|
||||||
|
{ path: '', redirect: '/dashboard' },
|
||||||
|
{ path: 'dashboard', component: DashboardPage },
|
||||||
|
{ path: 'spots', component: SpotsPage },
|
||||||
|
{ path: 'events', component: EventsPage },
|
||||||
|
{ path: 'shooting', component: ShootingPage },
|
||||||
|
{ path: 'module-design', component: ModuleDesignPage },
|
||||||
|
{ path: 'nav-configs', component: NavConfigsPage },
|
||||||
|
{ path: 'promotions', component: PromotionsPage },
|
||||||
|
{ path: 'users', component: UsersPage },
|
||||||
|
{ path: 'membership', component: MembershipPage },
|
||||||
|
{ path: 'ops', component: OpsPage },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ path: '/login', component: LoginPage },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
router.beforeEach((to, _from, next) => {
|
||||||
|
if (to.path === '/login') {
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!getAdminToken()) {
|
||||||
|
next('/login')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// only scss variables
|
||||||
|
|
||||||
|
$--colors: (
|
||||||
|
'primary': (
|
||||||
|
'base': #589ef8,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
@forward 'element-plus/theme-chalk/src/dark/var.scss' with (
|
||||||
|
$colors: $--colors
|
||||||
|
);
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
$--colors: (
|
||||||
|
'primary': (
|
||||||
|
'base': green,
|
||||||
|
),
|
||||||
|
'success': (
|
||||||
|
'base': #21ba45,
|
||||||
|
),
|
||||||
|
'warning': (
|
||||||
|
'base': #f2711c,
|
||||||
|
),
|
||||||
|
'danger': (
|
||||||
|
'base': #db2828,
|
||||||
|
),
|
||||||
|
'error': (
|
||||||
|
'base': #db2828,
|
||||||
|
),
|
||||||
|
'info': (
|
||||||
|
'base': #42b8dd,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// we can add this to custom namespace, default is 'el'
|
||||||
|
@forward 'element-plus/theme-chalk/src/mixins/config.scss' with (
|
||||||
|
$namespace: 'ep'
|
||||||
|
);
|
||||||
|
|
||||||
|
// You should use them in scss, because we calculate it by sass.
|
||||||
|
// comment next lines to use default color
|
||||||
|
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
|
||||||
|
// do not use same name, it will override.
|
||||||
|
$colors: $--colors,
|
||||||
|
$button-padding-horizontal: ('default': 50px)
|
||||||
|
);
|
||||||
|
|
||||||
|
// if you want to import all
|
||||||
|
// @use "element-plus/theme-chalk/src/index.scss" as *;
|
||||||
|
|
||||||
|
// You can comment it to hide debug info.
|
||||||
|
// @debug $--colors;
|
||||||
|
|
||||||
|
// custom dark variables
|
||||||
|
@use './dark.scss';
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
@use 'element-plus/theme-chalk/src/dark/css-vars.scss' as *;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--brand-primary: #1677ff;
|
||||||
|
--brand-primary-hover: #4096ff;
|
||||||
|
--brand-primary-active: #0958d9;
|
||||||
|
--bg-page: #f5f7fa;
|
||||||
|
--bg-card: #ffffff;
|
||||||
|
--bg-subtle: #fafbfc;
|
||||||
|
--border-base: #e5eaf0;
|
||||||
|
--border-split: #eef1f4;
|
||||||
|
--text-1: #1f2329;
|
||||||
|
--text-2: #4e5969;
|
||||||
|
--text-3: #86909c;
|
||||||
|
--text-disabled: #c9cdd4;
|
||||||
|
--ok: #00b578;
|
||||||
|
--warn: #faad14;
|
||||||
|
--danger: #f53f3f;
|
||||||
|
--info: #1677ff;
|
||||||
|
--radius-sm: 6px;
|
||||||
|
--radius-md: 8px;
|
||||||
|
--radius-card: 12px;
|
||||||
|
--radius-lg: 16px;
|
||||||
|
--shadow-card: 0 4px 16px rgba(31, 35, 41, 0.04);
|
||||||
|
--shadow-card-hover: 0 8px 24px rgba(22, 119, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||||
|
color: var(--text-1);
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--brand-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-shell {
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 248px 1fr;
|
||||||
|
background: var(--bg-page);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
background: var(--bg-card);
|
||||||
|
border-right: 1px solid var(--border-split);
|
||||||
|
padding: 20px 14px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 10px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
width: 38px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 10px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: linear-gradient(135deg, var(--brand-primary), #36a3ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-text p {
|
||||||
|
margin: 3px 0 0;
|
||||||
|
color: var(--text-3);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item {
|
||||||
|
height: 42px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 0 12px;
|
||||||
|
color: var(--text-2);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item:hover {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
color: var(--text-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active {
|
||||||
|
color: var(--brand-primary);
|
||||||
|
background: rgba(22, 119, 255, 0.08);
|
||||||
|
border-color: rgba(22, 119, 255, 0.24);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-item.active::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -14px;
|
||||||
|
top: 8px;
|
||||||
|
bottom: 8px;
|
||||||
|
width: 3px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--brand-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-icon {
|
||||||
|
width: 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main {
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
height: 64px;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border-bottom: 1px solid var(--border-split);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 0 20px;
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 420px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search {
|
||||||
|
width: 100%;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
background: #fff;
|
||||||
|
padding: 0 12px;
|
||||||
|
outline: none;
|
||||||
|
color: var(--text-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.global-search:focus {
|
||||||
|
border-color: var(--brand-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-btn:hover {
|
||||||
|
background: var(--bg-subtle);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 14px;
|
||||||
|
background: linear-gradient(135deg, #4e9cff, #1668dc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-meta small {
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
background: #fff;
|
||||||
|
color: var(--text-2);
|
||||||
|
border-radius: 8px;
|
||||||
|
height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn:hover {
|
||||||
|
color: var(--danger);
|
||||||
|
border-color: #f8c5c5;
|
||||||
|
background: #fff7f7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-head p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-card {
|
||||||
|
border-radius: var(--radius-card);
|
||||||
|
border: 1px solid var(--border-split);
|
||||||
|
box-shadow: var(--shadow-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-form {
|
||||||
|
.el-form-item {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-form-item__label {
|
||||||
|
color: var(--text-2);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.2;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input__wrapper,
|
||||||
|
.el-textarea__inner,
|
||||||
|
.el-select__wrapper {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border-base);
|
||||||
|
box-shadow: none;
|
||||||
|
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input__wrapper:hover,
|
||||||
|
.el-textarea__inner:hover,
|
||||||
|
.el-select__wrapper:hover {
|
||||||
|
border-color: #cfdae6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-input__wrapper.is-focus,
|
||||||
|
.el-select__wrapper.is-focused,
|
||||||
|
.el-textarea__inner:focus {
|
||||||
|
border-color: var(--brand-primary);
|
||||||
|
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button {
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--primary {
|
||||||
|
background: var(--brand-primary);
|
||||||
|
border-color: var(--brand-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-button--primary:hover {
|
||||||
|
background: var(--brand-primary-hover);
|
||||||
|
border-color: var(--brand-primary-hover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.admin-shell {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.sidebar {
|
||||||
|
position: static;
|
||||||
|
height: auto;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--border-split);
|
||||||
|
}
|
||||||
|
.content {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+26
@@ -0,0 +1,26 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
/* prettier-ignore */
|
||||||
|
// @ts-nocheck
|
||||||
|
// Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️
|
||||||
|
// It's recommended to commit this file.
|
||||||
|
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
|
||||||
|
|
||||||
|
declare module 'vue-router/auto-routes' {
|
||||||
|
import type {
|
||||||
|
RouteRecordInfo,
|
||||||
|
ParamValue,
|
||||||
|
ParamValueOneOrMore,
|
||||||
|
ParamValueZeroOrMore,
|
||||||
|
ParamValueZeroOrOne,
|
||||||
|
} from 'vue-router'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route name map generated by unplugin-vue-router
|
||||||
|
*/
|
||||||
|
export interface RouteNamedMap {
|
||||||
|
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
|
||||||
|
'/nav/1/item-1': RouteRecordInfo<'/nav/1/item-1', '/nav/1/item-1', Record<never, never>, Record<never, never>>,
|
||||||
|
'/nav/2': RouteRecordInfo<'/nav/2', '/nav/2', Record<never, never>, Record<never, never>>,
|
||||||
|
'/nav/4': RouteRecordInfo<'/nav/4', '/nav/4', Record<never, never>, Record<never, never>>,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import type { ViteSSGContext } from 'vite-ssg'
|
||||||
|
|
||||||
|
export type UserModule = (ctx: ViteSSGContext) => void
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "esnext",
|
||||||
|
"jsx": "preserve",
|
||||||
|
"lib": ["esnext", "dom"],
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"paths": {
|
||||||
|
"~/*": ["src/*"]
|
||||||
|
},
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": [
|
||||||
|
"vite/client"
|
||||||
|
],
|
||||||
|
"strict": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"vueCompilerOptions": {
|
||||||
|
"target": 3
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
defineConfig,
|
||||||
|
presetAttributify,
|
||||||
|
presetIcons,
|
||||||
|
presetTypography,
|
||||||
|
presetUno,
|
||||||
|
presetWebFonts,
|
||||||
|
transformerDirectives,
|
||||||
|
transformerVariantGroup,
|
||||||
|
} from 'unocss'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
shortcuts: [
|
||||||
|
['btn', 'px-4 py-1 rounded inline-block bg-teal-700 text-white cursor-pointer !outline-none hover:bg-teal-800 disabled:cursor-default disabled:bg-gray-600 disabled:opacity-50'],
|
||||||
|
['icon-btn', 'inline-block cursor-pointer select-none opacity-75 transition duration-200 ease-in-out hover:opacity-100 hover:text-teal-600'],
|
||||||
|
],
|
||||||
|
presets: [
|
||||||
|
presetUno(),
|
||||||
|
presetAttributify(),
|
||||||
|
presetIcons({
|
||||||
|
scale: 1.2,
|
||||||
|
}),
|
||||||
|
presetTypography(),
|
||||||
|
presetWebFonts({
|
||||||
|
fonts: {
|
||||||
|
sans: 'DM Sans',
|
||||||
|
serif: 'DM Serif Display',
|
||||||
|
mono: 'DM Mono',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
transformers: [
|
||||||
|
transformerDirectives(),
|
||||||
|
transformerVariantGroup(),
|
||||||
|
],
|
||||||
|
safelist: 'prose prose-sm m-auto text-left'.split(' '),
|
||||||
|
})
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import path from 'node:path'
|
||||||
|
import Vue from '@vitejs/plugin-vue'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5174,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://127.0.0.1:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'~/': `${path.resolve(__dirname, 'src')}/`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
scss: {
|
||||||
|
additionalData: `@use "~/styles/element/index.scss" as *;`,
|
||||||
|
api: 'modern-compiler',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
plugins: [
|
||||||
|
Vue(),
|
||||||
|
],
|
||||||
|
})
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
unpackage
|
||||||
|
.npm-cache
|
||||||
|
.env*
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onLaunch, onShow, onHide } from "@dcloudio/uni-app";
|
||||||
|
|
||||||
|
onLaunch(() => {
|
||||||
|
console.log("App Launch");
|
||||||
|
});
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
console.log("App Show");
|
||||||
|
});
|
||||||
|
|
||||||
|
onHide(() => {
|
||||||
|
console.log("App Hide");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
page {
|
||||||
|
background-color: #f5f6fa;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
"Helvetica Neue", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* H5 滚动条 */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
height: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(148, 163, 184, 0.35);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(148, 163, 184, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* scroll-view 内隐藏默认滚动条 */
|
||||||
|
::-webkit-scrollbar-corner {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENV UNI_INPUT_DIR=/app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install --legacy-peer-deps
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build:h5
|
||||||
|
|
||||||
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist/build/h5 /usr/share/nginx/html
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { post } from "@/utils/request";
|
||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
export const register = (data) => post("/auth/register", data);
|
||||||
|
|
||||||
|
export const login = (data) =>
|
||||||
|
request({
|
||||||
|
url: "/auth/login",
|
||||||
|
method: "POST",
|
||||||
|
header: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
data: `username=${encodeURIComponent(data.account)}&password=${encodeURIComponent(data.password)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const refreshToken = (data) => post("/auth/refresh", data);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { get, post, del } from "@/utils/request";
|
||||||
|
|
||||||
|
export const getComments = (spotId, params) => get(`/spots/${spotId}/comments`, params);
|
||||||
|
export const createComment = (spotId, data) => post(`/spots/${spotId}/comments`, data);
|
||||||
|
export const deleteComment = (commentId) => del(`/comments/${commentId}`);
|
||||||
|
export const reportComment = (commentId, data) => post(`/comments/${commentId}/report`, data);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { get, post } from "@/utils/request";
|
||||||
|
|
||||||
|
export const submitCorrection = (spotId, data) =>
|
||||||
|
post(`/spots/${spotId}/corrections`, data);
|
||||||
|
|
||||||
|
export const getCorrections = (spotId, params) =>
|
||||||
|
get(`/spots/${spotId}/corrections`, params);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
export const getEventList = (params = {}) =>
|
||||||
|
request({ url: "/events/", method: "GET", data: params });
|
||||||
|
|
||||||
|
export const getMyEvents = (params = {}) =>
|
||||||
|
request({ url: "/events/mine", method: "GET", data: params });
|
||||||
|
|
||||||
|
export const getMyRegistrations = (params = {}) =>
|
||||||
|
request({ url: "/events/my-registrations", method: "GET", data: params });
|
||||||
|
|
||||||
|
export const getEventDetail = (id) =>
|
||||||
|
request({ url: `/events/${id}`, method: "GET" });
|
||||||
|
|
||||||
|
export const createEvent = (data) =>
|
||||||
|
request({ url: "/events/", method: "POST", data });
|
||||||
|
|
||||||
|
export const updateEvent = (id, data) =>
|
||||||
|
request({ url: `/events/${id}`, method: "PUT", data });
|
||||||
|
|
||||||
|
export const cancelEvent = (id) =>
|
||||||
|
request({ url: `/events/${id}/cancel`, method: "POST" });
|
||||||
|
|
||||||
|
export const registerEvent = (id) =>
|
||||||
|
request({ url: `/events/${id}/register`, method: "POST" });
|
||||||
|
|
||||||
|
export const cancelRegistration = (id) =>
|
||||||
|
request({ url: `/events/${id}/register`, method: "DELETE" });
|
||||||
|
|
||||||
|
export const getRegistrations = (id) =>
|
||||||
|
request({ url: `/events/${id}/registrations`, method: "GET" });
|
||||||
|
|
||||||
|
export const getEventPhotos = (id) =>
|
||||||
|
request({ url: `/events/${id}/photos`, method: "GET" });
|
||||||
|
|
||||||
|
export const addEventPhoto = (id, data) =>
|
||||||
|
request({ url: `/events/${id}/photos`, method: "POST", data });
|
||||||
|
|
||||||
|
export const deleteEventPhoto = (eventId, photoId) =>
|
||||||
|
request({ url: `/events/${eventId}/photos/${photoId}`, method: "DELETE" });
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { get, post, del } from "@/utils/request";
|
||||||
|
|
||||||
|
export const getFavorites = (params) => get("/favorites", params);
|
||||||
|
|
||||||
|
export const addFavorite = (spotId) => post(`/favorites/${spotId}`);
|
||||||
|
|
||||||
|
export const removeFavorite = (spotId) => del(`/favorites/${spotId}`);
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
export const getMembershipPlans = () =>
|
||||||
|
request({ url: "/membership/plans", method: "GET" });
|
||||||
|
|
||||||
|
export const getMyMembership = () =>
|
||||||
|
request({ url: "/membership/me", method: "GET" });
|
||||||
|
|
||||||
|
export const purchaseMembership = (planId) =>
|
||||||
|
request({ url: "/membership/purchase", method: "POST", data: { plan_id: planId } });
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { get, post } from "@/utils/request";
|
||||||
|
|
||||||
|
export const getNotifications = (params) => get("/notifications", params);
|
||||||
|
|
||||||
|
export const getUnreadCount = () => get("/notifications/unread-count");
|
||||||
|
|
||||||
|
export const markAllRead = () => post("/notifications/read-all");
|
||||||
|
|
||||||
|
export const markRead = (id) => post(`/notifications/${id}/read`);
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { get } from "@/utils/request";
|
||||||
|
|
||||||
|
export const getMyPoints = () => get("/points/me");
|
||||||
|
export const getMyPointRecords = (params) => get("/points/me/records", params);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
export const getPromotions = (position = "home_banner") =>
|
||||||
|
request({ url: "/promotions/", method: "GET", data: { position } });
|
||||||
|
|
||||||
|
export const recordClick = (promotionId) =>
|
||||||
|
request({ url: "/promotions/click", method: "POST", data: { promotion_id: promotionId } });
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { get, post } from "@/utils/request";
|
||||||
|
|
||||||
|
export const rateSpot = (spotId, data) => post(`/spots/${spotId}/rate`, data);
|
||||||
|
export const getSpotRatings = (spotId, params) => get(`/spots/${spotId}/ratings`, params);
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { get } from "@/utils/request";
|
||||||
|
|
||||||
|
export const searchSpots = (params) => get("/search", params);
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
export const getShootingList = (params = {}) =>
|
||||||
|
request({ url: "/shooting/", method: "GET", data: params });
|
||||||
|
|
||||||
|
export const getMyShootings = (params = {}) =>
|
||||||
|
request({ url: "/shooting/mine", method: "GET", data: params });
|
||||||
|
|
||||||
|
export const getMyApplications = (params = {}) =>
|
||||||
|
request({ url: "/shooting/my-applications", method: "GET", data: params });
|
||||||
|
|
||||||
|
export const getShootingDetail = (id) =>
|
||||||
|
request({ url: `/shooting/${id}`, method: "GET" });
|
||||||
|
|
||||||
|
export const createShooting = (data) =>
|
||||||
|
request({ url: "/shooting/", method: "POST", data });
|
||||||
|
|
||||||
|
export const updateShooting = (id, data) =>
|
||||||
|
request({ url: `/shooting/${id}`, method: "PUT", data });
|
||||||
|
|
||||||
|
export const closeShooting = (id) =>
|
||||||
|
request({ url: `/shooting/${id}/close`, method: "POST" });
|
||||||
|
|
||||||
|
export const applyToShooting = (id, data = {}) =>
|
||||||
|
request({ url: `/shooting/${id}/apply`, method: "POST", data });
|
||||||
|
|
||||||
|
export const getApplications = (id) =>
|
||||||
|
request({ url: `/shooting/${id}/applications`, method: "GET" });
|
||||||
|
|
||||||
|
export const acceptApplication = (requestId, appId) =>
|
||||||
|
request({
|
||||||
|
url: `/shooting/${requestId}/applications/${appId}/accept`,
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const rejectApplication = (requestId, appId) =>
|
||||||
|
request({
|
||||||
|
url: `/shooting/${requestId}/applications/${appId}/reject`,
|
||||||
|
method: "POST",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const withdrawApplication = (requestId) =>
|
||||||
|
request({
|
||||||
|
url: `/shooting/${requestId}/applications/withdraw`,
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { get, post, put, del } from "@/utils/request";
|
||||||
|
import { API_BASE } from "@/utils/config";
|
||||||
|
|
||||||
|
export const getSpots = (params) => get("/spots", params);
|
||||||
|
|
||||||
|
export const getNearbySpots = (params) => get("/spots/nearby", params);
|
||||||
|
|
||||||
|
export const getSpotDetail = (id) => get(`/spots/${id}`);
|
||||||
|
|
||||||
|
export const createSpot = (data) => post("/spots", data);
|
||||||
|
|
||||||
|
export const updateSpot = (id, data) => put(`/spots/${id}`, data);
|
||||||
|
|
||||||
|
export const deleteSpot = (id) => del(`/spots/${id}`);
|
||||||
|
|
||||||
|
export const getMySpots = (params) => get("/spots/mine", params);
|
||||||
|
|
||||||
|
export const uploadImage = (filePath) => {
|
||||||
|
const token = uni.getStorageSync("access_token");
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.uploadFile({
|
||||||
|
url: API_BASE + "/upload/image",
|
||||||
|
filePath,
|
||||||
|
name: "file",
|
||||||
|
header: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
success: (res) => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
resolve(JSON.parse(res.data));
|
||||||
|
} else {
|
||||||
|
reject(new Error("上传失败"));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: (err) => reject(err),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { get, post, del } from "@/utils/request";
|
||||||
|
|
||||||
|
export const getTags = (params) => get("/tags", params);
|
||||||
|
export const addTagToSpot = (spotId, data) => post(`/spots/${spotId}/tags`, data);
|
||||||
|
export const removeTagFromSpot = (spotId, tagId) => del(`/spots/${spotId}/tags/${tagId}`);
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { get, put, post } from "@/utils/request";
|
||||||
|
|
||||||
|
export const getMyInfo = () => get("/users/me");
|
||||||
|
|
||||||
|
export const getMyStats = () => get("/users/me/stats");
|
||||||
|
|
||||||
|
export const updateMyInfo = (data) => put("/users/me", data);
|
||||||
|
|
||||||
|
export const changePassword = (data) => post("/users/me/change-password", data);
|
||||||
|
|
||||||
|
export const getUserInfo = (userId) => get(`/users/${userId}`);
|
||||||
|
|
||||||
|
export const getUserSpots = (userId, params) =>
|
||||||
|
get("/spots", { creator_id: userId, ...params });
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
<script setup>
|
||||||
|
import cityData from "@/utils/city-data";
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
currentCity: {
|
||||||
|
type: String,
|
||||||
|
default: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["close", "select"]);
|
||||||
|
|
||||||
|
const hotCities = [
|
||||||
|
"北京", "上海", "广州", "深圳",
|
||||||
|
"成都", "杭州", "南京", "武汉",
|
||||||
|
"重庆", "西安", "长沙", "厦门",
|
||||||
|
"青岛", "大连", "苏州", "三亚",
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleSelect = (city) => {
|
||||||
|
emit("select", city);
|
||||||
|
emit("close");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMask = () => {
|
||||||
|
emit("close");
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view v-if="visible" class="city-picker">
|
||||||
|
<view class="mask" @tap="handleMask" @touchmove.stop.prevent />
|
||||||
|
<view class="panel">
|
||||||
|
<view class="panel-header">
|
||||||
|
<text class="panel-title">选择城市</text>
|
||||||
|
<view class="close-btn" @tap="handleMask">
|
||||||
|
<text class="close-icon">✕</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<scroll-view scroll-y class="panel-body">
|
||||||
|
<view
|
||||||
|
class="city-item all-city"
|
||||||
|
:class="{ active: currentCity === '全部城市' }"
|
||||||
|
@tap="handleSelect('全部城市')"
|
||||||
|
>
|
||||||
|
<text class="city-text">全部城市</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section-label">
|
||||||
|
<text class="section-label-text">热门城市</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="city-grid">
|
||||||
|
<view
|
||||||
|
v-for="c in hotCities"
|
||||||
|
:key="c"
|
||||||
|
class="city-tag"
|
||||||
|
:class="{ active: currentCity === c }"
|
||||||
|
@tap="handleSelect(c)"
|
||||||
|
>
|
||||||
|
<text class="tag-text">{{ c }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="section-label all-section">
|
||||||
|
<text class="section-label-text">全部城市</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view
|
||||||
|
v-for="province in cityData"
|
||||||
|
:key="province.province"
|
||||||
|
class="province-section"
|
||||||
|
>
|
||||||
|
<text class="province-title">{{ province.province }}</text>
|
||||||
|
<view class="city-grid">
|
||||||
|
<view
|
||||||
|
v-for="c in province.cities"
|
||||||
|
:key="`${province.province}-${c}`"
|
||||||
|
class="city-tag"
|
||||||
|
:class="{ active: currentCity === c || currentCity === c.replace('市', '') }"
|
||||||
|
@tap="handleSelect(c)"
|
||||||
|
>
|
||||||
|
<text class="tag-text">{{ c }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.city-picker {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mask {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 32rpx 32rpx 0 0;
|
||||||
|
height: 70vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 32rpx 32rpx 24rpx;
|
||||||
|
border-bottom: 1rpx solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
width: 56rpx;
|
||||||
|
height: 56rpx;
|
||||||
|
border-radius: 28rpx;
|
||||||
|
background: #f5f6fa;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-icon {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-body {
|
||||||
|
height: calc(70vh - 114rpx);
|
||||||
|
padding: 24rpx 32rpx 48rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-item {
|
||||||
|
padding: 24rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
background: #f5f6fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-item.active {
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-text {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #1e293b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-item.active .city-text {
|
||||||
|
color: #6366f1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.all-section {
|
||||||
|
margin-top: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.province-section {
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.province-title {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-tag {
|
||||||
|
width: calc(25% - 12rpx);
|
||||||
|
height: 72rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f5f6fa;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-tag.active {
|
||||||
|
background: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-tag.active .tag-text {
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { getComments, createComment, reportComment } from "@/api/comment";
|
||||||
|
import { resolveImageUrl } from "@/utils/image";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
spotId: {
|
||||||
|
type: Number,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
isFavorited: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["toggle-favorite"]);
|
||||||
|
|
||||||
|
const comments = ref([]);
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = 10;
|
||||||
|
const hasMore = ref(true);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const inputText = ref("");
|
||||||
|
const replyTarget = ref(null);
|
||||||
|
const sending = ref(false);
|
||||||
|
|
||||||
|
const showReport = ref(false);
|
||||||
|
const reportTargetId = ref(null);
|
||||||
|
const reportReason = ref("");
|
||||||
|
|
||||||
|
const inputPlaceholder = computed(() =>
|
||||||
|
replyTarget.value ? `回复 @${replyTarget.value.nickname}` : "写下你的评论..."
|
||||||
|
);
|
||||||
|
|
||||||
|
const fetchComments = async (reset = false) => {
|
||||||
|
if (loading.value) return;
|
||||||
|
if (!reset && !hasMore.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
if (reset) {
|
||||||
|
page.value = 1;
|
||||||
|
hasMore.value = true;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await getComments(props.spotId, {
|
||||||
|
page: page.value,
|
||||||
|
page_size: pageSize,
|
||||||
|
});
|
||||||
|
const list = res.items || res.data || res || [];
|
||||||
|
if (reset) {
|
||||||
|
comments.value = list;
|
||||||
|
} else {
|
||||||
|
comments.value.push(...list);
|
||||||
|
}
|
||||||
|
if (list.length < pageSize) {
|
||||||
|
hasMore.value = false;
|
||||||
|
} else {
|
||||||
|
page.value++;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
const text = inputText.value.trim();
|
||||||
|
if (!text || sending.value) return;
|
||||||
|
sending.value = true;
|
||||||
|
try {
|
||||||
|
const data = { content: text };
|
||||||
|
if (replyTarget.value) {
|
||||||
|
data.parent_id = replyTarget.value.id;
|
||||||
|
}
|
||||||
|
await createComment(props.spotId, data);
|
||||||
|
inputText.value = "";
|
||||||
|
replyTarget.value = null;
|
||||||
|
await fetchComments(true);
|
||||||
|
uni.showToast({ title: "评论成功", icon: "none" });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
sending.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setReply = (comment) => {
|
||||||
|
replyTarget.value = {
|
||||||
|
id: comment.id,
|
||||||
|
nickname: comment.user?.nickname || "匿名用户",
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelReply = () => {
|
||||||
|
replyTarget.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openReport = (commentId) => {
|
||||||
|
reportTargetId.value = commentId;
|
||||||
|
reportReason.value = "";
|
||||||
|
showReport.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const goUser = (userId) => {
|
||||||
|
if (userId) uni.navigateTo({ url: `/pages/user/index?id=${userId}` });
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeReport = () => {
|
||||||
|
showReport.value = false;
|
||||||
|
reportTargetId.value = null;
|
||||||
|
reportReason.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitReport = async () => {
|
||||||
|
const reason = reportReason.value.trim();
|
||||||
|
if (!reason) {
|
||||||
|
uni.showToast({ title: "请输入举报原因", icon: "none" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await reportComment(reportTargetId.value, { reason });
|
||||||
|
uni.showToast({ title: "举报已提交", icon: "none" });
|
||||||
|
closeReport();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatTime = (t) => {
|
||||||
|
if (!t) return "";
|
||||||
|
const d = new Date(t);
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(d.getDate()).padStart(2, "0");
|
||||||
|
const hh = String(d.getHours()).padStart(2, "0");
|
||||||
|
const mi = String(d.getMinutes()).padStart(2, "0");
|
||||||
|
return `${mm}-${dd} ${hh}:${mi}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchComments(true);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="comment-section">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">评论</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="comments.length === 0 && !loading" class="empty-tip">
|
||||||
|
<text>暂无评论,快来发表第一条吧</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-for="item in comments" :key="item.id" class="comment-card">
|
||||||
|
<view class="comment-main">
|
||||||
|
<view class="avatar-box" @tap="goUser(item.user?.id)">
|
||||||
|
<image
|
||||||
|
v-if="item.user?.avatar_url"
|
||||||
|
class="avatar-img"
|
||||||
|
:src="resolveImageUrl(item.user.avatar_url)"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<text v-else class="avatar-text">{{ (item.user?.nickname || "匿")[0] }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="comment-body">
|
||||||
|
<text class="nickname" @tap="goUser(item.user?.id)">{{ item.user?.nickname || "匿名用户" }}</text>
|
||||||
|
<text class="comment-content">{{ item.content }}</text>
|
||||||
|
<view class="comment-footer">
|
||||||
|
<text class="comment-time">{{ formatTime(item.created_at) }}</text>
|
||||||
|
<text class="action-btn" @tap="setReply(item)">回复</text>
|
||||||
|
<text class="action-btn report-btn" @tap="openReport(item.id)">举报</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="item.replies && item.replies.length" class="replies-list">
|
||||||
|
<view v-for="reply in item.replies" :key="reply.id" class="reply-item">
|
||||||
|
<view class="reply-avatar-box" @tap="goUser(reply.user?.id)">
|
||||||
|
<image
|
||||||
|
v-if="reply.user?.avatar_url"
|
||||||
|
class="reply-avatar-img"
|
||||||
|
:src="resolveImageUrl(reply.user.avatar_url)"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<text v-else class="reply-avatar-text">{{ (reply.user?.nickname || "匿")[0] }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="reply-body">
|
||||||
|
<text class="reply-nickname" @tap="goUser(reply.user?.id)">{{ reply.user?.nickname || "匿名用户" }}</text>
|
||||||
|
<text class="reply-content">{{ reply.content }}</text>
|
||||||
|
<view class="reply-footer">
|
||||||
|
<text class="comment-time">{{ formatTime(reply.created_at) }}</text>
|
||||||
|
<text class="action-btn" @tap="setReply(reply)">回复</text>
|
||||||
|
<text class="action-btn report-btn" @tap="openReport(reply.id)">举报</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="hasMore && comments.length > 0" class="load-more" @tap="fetchComments()">
|
||||||
|
<text>{{ loading ? "加载中..." : "加载更多" }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="input-bar">
|
||||||
|
<view v-if="replyTarget" class="reply-hint" @tap="cancelReply">
|
||||||
|
<text class="reply-hint-text">回复 @{{ replyTarget.nickname }}</text>
|
||||||
|
<text class="reply-cancel">✕</text>
|
||||||
|
</view>
|
||||||
|
<view class="input-row">
|
||||||
|
<input
|
||||||
|
class="comment-input"
|
||||||
|
v-model="inputText"
|
||||||
|
:placeholder="inputPlaceholder"
|
||||||
|
confirm-type="send"
|
||||||
|
@confirm="handleSend"
|
||||||
|
/>
|
||||||
|
<view class="send-btn" :class="{ disabled: !inputText.trim() }" @tap="handleSend">
|
||||||
|
<text class="send-text">发送</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="fav-quick-btn"
|
||||||
|
:class="{ active: props.isFavorited }"
|
||||||
|
@tap="emit('toggle-favorite')"
|
||||||
|
>
|
||||||
|
<uni-icons
|
||||||
|
:type="props.isFavorited ? 'heart-filled' : 'heart'"
|
||||||
|
size="18"
|
||||||
|
:color="props.isFavorited ? '#6366f1' : '#94a3b8'"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="showReport" class="report-mask" @tap.self="closeReport">
|
||||||
|
<view class="report-popup">
|
||||||
|
<text class="report-title">举报评论</text>
|
||||||
|
<textarea
|
||||||
|
class="report-textarea"
|
||||||
|
v-model="reportReason"
|
||||||
|
placeholder="请输入举报原因"
|
||||||
|
:maxlength="200"
|
||||||
|
/>
|
||||||
|
<view class="report-actions">
|
||||||
|
<view class="report-cancel-btn" @tap="closeReport">
|
||||||
|
<text>取消</text>
|
||||||
|
</view>
|
||||||
|
<view class="report-submit-btn" @tap="submitReport">
|
||||||
|
<text class="report-submit-text">提交</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.comment-section {
|
||||||
|
padding-bottom: 120rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
padding: 24rpx 32rpx 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-tip {
|
||||||
|
text-align: center;
|
||||||
|
padding: 60rpx 0;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-card {
|
||||||
|
background: #ffffff;
|
||||||
|
margin: 0 32rpx 16rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-main {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-box {
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
border-radius: 36rpx;
|
||||||
|
background: #e0e7ff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-img {
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
border-radius: 36rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #6366f1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nickname {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #334155;
|
||||||
|
line-height: 1.6;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-time {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #94a3b8;
|
||||||
|
margin-right: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #6366f1;
|
||||||
|
margin-right: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-btn {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.replies-list {
|
||||||
|
margin-left: 92rpx;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
padding-top: 16rpx;
|
||||||
|
border-top: 1rpx solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-item {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-avatar-box {
|
||||||
|
width: 52rpx;
|
||||||
|
height: 52rpx;
|
||||||
|
border-radius: 26rpx;
|
||||||
|
background: #f1f5f9;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-right: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-avatar-img {
|
||||||
|
width: 52rpx;
|
||||||
|
height: 52rpx;
|
||||||
|
border-radius: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-avatar-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #6366f1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-nickname {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-content {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #475569;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
color: #6366f1;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-bar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
background: #ffffff;
|
||||||
|
padding: 16rpx 24rpx;
|
||||||
|
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
|
||||||
|
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.05);
|
||||||
|
z-index: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-hint {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8rpx 16rpx;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-hint-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reply-cancel {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #94a3b8;
|
||||||
|
padding: 0 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-input {
|
||||||
|
flex: 1;
|
||||||
|
height: 72rpx;
|
||||||
|
background: #f5f6fa;
|
||||||
|
border-radius: 36rpx;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn {
|
||||||
|
margin-left: 16rpx;
|
||||||
|
background: #6366f1;
|
||||||
|
border-radius: 36rpx;
|
||||||
|
padding: 0 32rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-btn.disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.send-text {
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-quick-btn {
|
||||||
|
margin-left: 12rpx;
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
border-radius: 36rpx;
|
||||||
|
background: #f5f6fa;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fav-quick-btn.active {
|
||||||
|
background: rgba(99, 102, 241, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-mask {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-popup {
|
||||||
|
width: 600rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
display: block;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-textarea {
|
||||||
|
width: 100%;
|
||||||
|
height: 200rpx;
|
||||||
|
background: #f5f6fa;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 20rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #334155;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-actions {
|
||||||
|
display: flex;
|
||||||
|
margin-top: 32rpx;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-cancel-btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 80rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f5f6fa;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-submit-btn {
|
||||||
|
flex: 1;
|
||||||
|
height: 80rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #6366f1;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-submit-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
value: {
|
||||||
|
type: Number,
|
||||||
|
default: 0,
|
||||||
|
},
|
||||||
|
readonly: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
type: String,
|
||||||
|
default: "36rpx",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["update:value"]);
|
||||||
|
|
||||||
|
const handleTap = (star) => {
|
||||||
|
if (props.readonly) return;
|
||||||
|
emit("update:value", star);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="rating-star" :class="{ interactive: !readonly }">
|
||||||
|
<text
|
||||||
|
v-for="i in 5"
|
||||||
|
:key="i"
|
||||||
|
class="star"
|
||||||
|
:class="{ filled: i <= value }"
|
||||||
|
:style="{ fontSize: size }"
|
||||||
|
@tap="handleTap(i)"
|
||||||
|
>{{ i <= value ? "★" : "☆" }}</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rating-star {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rating-star.interactive .star {
|
||||||
|
padding: 0 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star {
|
||||||
|
color: #d1d5db;
|
||||||
|
transition: color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.star.filled {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
rows: { type: Number, default: 3 },
|
||||||
|
avatar: { type: Boolean, default: false },
|
||||||
|
card: { type: Boolean, default: false },
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="skeleton" :class="{ 'skeleton-card': card }">
|
||||||
|
<view v-if="avatar" class="sk-avatar shimmer" />
|
||||||
|
<view class="sk-body">
|
||||||
|
<view class="sk-title shimmer" />
|
||||||
|
<view
|
||||||
|
v-for="i in rows"
|
||||||
|
:key="i"
|
||||||
|
class="sk-row shimmer"
|
||||||
|
:style="{ width: i === rows ? '60%' : '100%' }"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.skeleton {
|
||||||
|
display: flex;
|
||||||
|
padding: 24rpx;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
.skeleton-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
.sk-avatar {
|
||||||
|
width: 80rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
border-radius: 40rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #e2e8f0;
|
||||||
|
}
|
||||||
|
.sk-body {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.sk-title {
|
||||||
|
height: 32rpx;
|
||||||
|
width: 45%;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
background: #e2e8f0;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
.sk-row {
|
||||||
|
height: 24rpx;
|
||||||
|
border-radius: 6rpx;
|
||||||
|
background: #e2e8f0;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
.shimmer {
|
||||||
|
background: linear-gradient(90deg, #e2e8f0 25%, #f1f5f9 50%, #e2e8f0 75%);
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 1.5s infinite;
|
||||||
|
}
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
<script setup>
|
||||||
|
import { resolveImageUrl } from "@/utils/image";
|
||||||
|
import { formatSpotPrice } from "@/utils/spot";
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
spot: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["click"]);
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
emit("click");
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="spot-card" @tap="handleClick">
|
||||||
|
<image
|
||||||
|
v-if="spot.cover_image_url"
|
||||||
|
class="cover"
|
||||||
|
:src="resolveImageUrl(spot.cover_image_url)"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<view v-else class="cover cover-placeholder">
|
||||||
|
<uni-icons type="camera" size="40" color="#94a3b8" class="placeholder-icon" />
|
||||||
|
</view>
|
||||||
|
<view class="info">
|
||||||
|
<text class="title">{{ spot.title }}</text>
|
||||||
|
<view class="meta">
|
||||||
|
<view class="city-tag">{{ spot.city || "未知城市" }}</view>
|
||||||
|
<view
|
||||||
|
class="price-tag"
|
||||||
|
:class="{ free: formatSpotPrice(spot).isFree, paid: !formatSpotPrice(spot).isFree }"
|
||||||
|
>
|
||||||
|
{{ formatSpotPrice(spot).label }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<text class="desc" v-if="spot.description">
|
||||||
|
{{ spot.description }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.spot-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 320rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-placeholder {
|
||||||
|
background: #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-icon {
|
||||||
|
font-size: 80rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
padding: 20rpx 24rpx 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10rpx;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city-tag {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #6366f1;
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-tag {
|
||||||
|
font-size: 22rpx;
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-tag.free {
|
||||||
|
color: #16a34a;
|
||||||
|
background: rgba(34, 197, 94, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-tag.paid {
|
||||||
|
color: #d97706;
|
||||||
|
background: rgba(245, 158, 11, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #64748b;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
tags: {
|
||||||
|
type: Array,
|
||||||
|
default: () => [],
|
||||||
|
},
|
||||||
|
activeId: {
|
||||||
|
type: [Number, null],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["select"]);
|
||||||
|
|
||||||
|
const handleSelect = (id) => {
|
||||||
|
emit("select", id);
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<scroll-view class="tag-bar" scroll-x enable-flex>
|
||||||
|
<view
|
||||||
|
class="tag-pill"
|
||||||
|
:class="{ active: activeId === null }"
|
||||||
|
@tap="handleSelect(null)"
|
||||||
|
>
|
||||||
|
<text class="tag-text">全部</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-for="tag in tags"
|
||||||
|
:key="tag.id"
|
||||||
|
class="tag-pill"
|
||||||
|
:class="{ active: activeId === tag.id }"
|
||||||
|
@tap="handleSelect(tag.id)"
|
||||||
|
>
|
||||||
|
<text class="tag-text">{{ tag.name }}</text>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.tag-bar {
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 16rpx 32rpx;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12rpx 28rpx;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
margin-right: 16rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border: 2rpx solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-pill.active {
|
||||||
|
background: #6366f1;
|
||||||
|
border-color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #475569;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-pill.active .tag-text {
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<script>
|
||||||
|
var coverSupport = 'CSS' in window && typeof CSS.supports === 'function' && (CSS.supports('top: env(a)') ||
|
||||||
|
CSS.supports('top: constant(a)'))
|
||||||
|
document.write(
|
||||||
|
'<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0' +
|
||||||
|
(coverSupport ? ', viewport-fit=cover' : '') + '" />')
|
||||||
|
</script>
|
||||||
|
<title>次元取景器</title>
|
||||||
|
<!--preload-links-->
|
||||||
|
<!--app-context-->
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"><!--app-html--></div>
|
||||||
|
<script type="module" src="/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import App from './App'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
// #ifndef VUE3
|
||||||
|
import Vue from 'vue'
|
||||||
|
import './uni.promisify.adaptor'
|
||||||
|
Vue.config.productionTip = false
|
||||||
|
App.mpType = 'app'
|
||||||
|
const app = new Vue({
|
||||||
|
...App
|
||||||
|
})
|
||||||
|
app.$mount()
|
||||||
|
// #endif
|
||||||
|
|
||||||
|
// #ifdef VUE3
|
||||||
|
import { createSSRApp } from 'vue'
|
||||||
|
import UniIcons from '@dcloudio/uni-ui/lib/uni-icons/uni-icons.vue'
|
||||||
|
export function createApp() {
|
||||||
|
const app = createSSRApp(App)
|
||||||
|
const pinia = createPinia()
|
||||||
|
app.use(pinia)
|
||||||
|
app.component('uni-icons', UniIcons)
|
||||||
|
return {
|
||||||
|
app
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
{
|
||||||
|
"name": "次元取景器",
|
||||||
|
"appid": "",
|
||||||
|
"description": "发现最美二次元取景地",
|
||||||
|
"versionName": "0.1.0",
|
||||||
|
"versionCode": "100",
|
||||||
|
"transformPx": false,
|
||||||
|
"app-plus": {
|
||||||
|
"usingComponents": true,
|
||||||
|
"nvueStyleCompiler": "uni-app",
|
||||||
|
"compilerVersion": 3,
|
||||||
|
"splashscreen": {
|
||||||
|
"alwaysShowBeforeRender": true,
|
||||||
|
"waiting": true,
|
||||||
|
"autoclose": true,
|
||||||
|
"delay": 0
|
||||||
|
},
|
||||||
|
"modules": {
|
||||||
|
"Maps": {}
|
||||||
|
},
|
||||||
|
"distribute": {
|
||||||
|
"android": {
|
||||||
|
"permissions": [
|
||||||
|
"<uses-permission android:name=\"android.permission.CHANGE_NETWORK_STATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.MOUNT_UNMOUNT_FILESYSTEMS\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.VIBRATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.READ_LOGS\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.ACCESS_WIFI_STATE\"/>",
|
||||||
|
"<uses-feature android:name=\"android.hardware.camera.autofocus\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.CAMERA\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.GET_ACCOUNTS\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.READ_PHONE_STATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.CHANGE_WIFI_STATE\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.WAKE_LOCK\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.FLASHLIGHT\"/>",
|
||||||
|
"<uses-feature android:name=\"android.hardware.camera\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.WRITE_SETTINGS\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.ACCESS_FINE_LOCATION\"/>",
|
||||||
|
"<uses-permission android:name=\"android.permission.ACCESS_COARSE_LOCATION\"/>"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ios": {},
|
||||||
|
"sdkConfigs": {}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"quickapp": {},
|
||||||
|
"mp-weixin": {
|
||||||
|
"appid": "",
|
||||||
|
"setting": {
|
||||||
|
"urlCheck": false
|
||||||
|
},
|
||||||
|
"usingComponents": true,
|
||||||
|
"permission": {
|
||||||
|
"scope.userLocation": {
|
||||||
|
"desc": "你的位置信息将用于展示附近取景地"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requiredPrivateInfos": ["getLocation", "chooseLocation"]
|
||||||
|
},
|
||||||
|
"mp-alipay": {
|
||||||
|
"usingComponents": true
|
||||||
|
},
|
||||||
|
"mp-baidu": {
|
||||||
|
"usingComponents": true
|
||||||
|
},
|
||||||
|
"mp-toutiao": {
|
||||||
|
"usingComponents": true
|
||||||
|
},
|
||||||
|
"uniStatistics": {
|
||||||
|
"enable": false
|
||||||
|
},
|
||||||
|
"vueVersion": "3",
|
||||||
|
"h5": {
|
||||||
|
"title": "次元取景器",
|
||||||
|
"router": {
|
||||||
|
"mode": "hash"
|
||||||
|
},
|
||||||
|
"sdkConfigs": {
|
||||||
|
"maps": {
|
||||||
|
"qqmap": {
|
||||||
|
"key": "G3HBZ-XP26G-76IQ2-QPH4C-UXPZ5-AQF6Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://server:8000/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /uploads/ {
|
||||||
|
proxy_pass http://server:8000/uploads/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+7469
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "ciyuan-viewfinder-client",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev:h5": "uni -p h5",
|
||||||
|
"build:h5": "uni build -p h5"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@dcloudio/uni-app": "3.0.0-alpha-5000820260420001",
|
||||||
|
"@dcloudio/uni-h5": "3.0.0-alpha-5000820260420001",
|
||||||
|
"@dcloudio/uni-ui": "^1.5.6",
|
||||||
|
"pinia": "^2.2.6",
|
||||||
|
"vue": "^3.5.18"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@dcloudio/vite-plugin-uni": "3.0.0-alpha-5000820260420001",
|
||||||
|
"sass-embedded": "^1.89.2",
|
||||||
|
"vite": "^5.4.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
{
|
||||||
|
"easycom": {
|
||||||
|
"autoscan": true,
|
||||||
|
"custom": {
|
||||||
|
"^uni-(.*)": "@dcloudio/uni-ui/lib/uni-$1/uni-$1.vue"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pages": [
|
||||||
|
{
|
||||||
|
"path": "pages/index/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "发现",
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"enablePullDownRefresh": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/login/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "登录"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/spot/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "地点详情",
|
||||||
|
"app-plus": {
|
||||||
|
"titleNView": {
|
||||||
|
"buttons": [
|
||||||
|
{
|
||||||
|
"type": "menu",
|
||||||
|
"color": "#1e293b"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/spot/create",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "投稿地点"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/spot/pick-location",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "选择位置",
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"enablePullDownRefresh": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/search/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "搜索"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/mine/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "我的"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/mine/favorites",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "我的收藏",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/mine/my-spots",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "我的投稿",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/mine/points",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "积分概览",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/mine/profile",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "编辑资料"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/mine/notifications",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "消息通知",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/mine/settings",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "设置"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/user/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "用户主页"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/spot/edit",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "编辑取景地"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/activity/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "活动",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/shooting/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "约拍广场",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/shooting/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "约拍详情"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/shooting/create",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "发布约拍"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/shooting/mine",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "我的约拍",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/event/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "活动",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/event/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "活动详情"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/event/create",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "发布活动"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/event/mine",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "我的活动",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/mine/membership",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "会员中心"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"globalStyle": {
|
||||||
|
"navigationBarTextStyle": "black",
|
||||||
|
"navigationBarTitleText": "次元取景器",
|
||||||
|
"navigationBarBackgroundColor": "#ffffff",
|
||||||
|
"backgroundColor": "#f5f6fa"
|
||||||
|
},
|
||||||
|
"tabBar": {
|
||||||
|
"color": "#999999",
|
||||||
|
"selectedColor": "#6366f1",
|
||||||
|
"borderStyle": "black",
|
||||||
|
"backgroundColor": "#ffffff",
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"pagePath": "pages/index/index",
|
||||||
|
"text": "发现",
|
||||||
|
"iconPath": "static/tab/discover.png",
|
||||||
|
"selectedIconPath": "static/tab/discover-active.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pagePath": "pages/activity/index",
|
||||||
|
"text": "活动",
|
||||||
|
"iconPath": "static/tab/discover.png",
|
||||||
|
"selectedIconPath": "static/tab/discover-active.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pagePath": "pages/spot/create",
|
||||||
|
"text": "投稿",
|
||||||
|
"iconPath": "static/tab/upload.png",
|
||||||
|
"selectedIconPath": "static/tab/upload-active.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pagePath": "pages/mine/notifications",
|
||||||
|
"text": "消息",
|
||||||
|
"iconPath": "static/tab/mine.png",
|
||||||
|
"selectedIconPath": "static/tab/mine-active.png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pagePath": "pages/mine/index",
|
||||||
|
"text": "我的",
|
||||||
|
"iconPath": "static/tab/mine.png",
|
||||||
|
"selectedIconPath": "static/tab/mine-active.png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"uniIdRouter": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,427 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { onPullDownRefresh, onReachBottom, onShow } from "@dcloudio/uni-app";
|
||||||
|
import {
|
||||||
|
getShootingList,
|
||||||
|
getMyShootings,
|
||||||
|
getMyApplications,
|
||||||
|
} from "@/api/shooting";
|
||||||
|
import { getEventList, getMyEvents, getMyRegistrations } from "@/api/event";
|
||||||
|
import { checkLogin } from "@/utils/auth";
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ key: "shooting_plaza", label: "约拍广场", type: "shooting", mine: false },
|
||||||
|
{ key: "shooting_mine", label: "我的约拍", type: "shooting", mine: true },
|
||||||
|
{ key: "event_plaza", label: "活动广场", type: "event", mine: false },
|
||||||
|
{ key: "event_mine", label: "我的活动", type: "event", mine: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const activeTab = ref("shooting_plaza");
|
||||||
|
|
||||||
|
const shootingPlaza = ref({ list: [], page: 1, total: 0, finished: false, loading: false });
|
||||||
|
const shootingMine = ref({ list: [], page: 1, total: 0, finished: false, loading: false });
|
||||||
|
const eventPlaza = ref({ list: [], page: 1, total: 0, finished: false, loading: false });
|
||||||
|
const eventMine = ref({ list: [], page: 1, total: 0, finished: false, loading: false });
|
||||||
|
|
||||||
|
const stateMap = {
|
||||||
|
shooting_plaza: shootingPlaza,
|
||||||
|
shooting_mine: shootingMine,
|
||||||
|
event_plaza: eventPlaza,
|
||||||
|
event_mine: eventMine,
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentState = computed(() => stateMap[activeTab.value].value);
|
||||||
|
const currentItems = computed(() => currentState.value.list || []);
|
||||||
|
const isCurrentLoading = computed(() => currentState.value.loading);
|
||||||
|
const isCurrentFinished = computed(() => currentState.value.finished);
|
||||||
|
const currentTabMeta = computed(() => tabs.find((t) => t.key === activeTab.value));
|
||||||
|
|
||||||
|
const shootingStatusLabels = {
|
||||||
|
open: "招募中",
|
||||||
|
matched: "已匹配",
|
||||||
|
closed: "已关闭",
|
||||||
|
};
|
||||||
|
const shootingStatusColors = {
|
||||||
|
open: "#22c55e",
|
||||||
|
matched: "#f59e0b",
|
||||||
|
closed: "#9ca3af",
|
||||||
|
};
|
||||||
|
|
||||||
|
const eventStatusLabels = {
|
||||||
|
upcoming: "即将开始",
|
||||||
|
ongoing: "进行中",
|
||||||
|
ended: "已结束",
|
||||||
|
cancelled: "已取消",
|
||||||
|
};
|
||||||
|
const eventStatusColors = {
|
||||||
|
upcoming: "#6366f1",
|
||||||
|
ongoing: "#22c55e",
|
||||||
|
ended: "#9ca3af",
|
||||||
|
cancelled: "#ef4444",
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleLabels = {
|
||||||
|
photographer: "找摄影",
|
||||||
|
cosplayer: "找Coser",
|
||||||
|
both: "不限",
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatDateTime(val) {
|
||||||
|
if (!val) return "时间待定";
|
||||||
|
const dt = new Date(val);
|
||||||
|
const y = dt.getFullYear();
|
||||||
|
const m = String(dt.getMonth() + 1).padStart(2, "0");
|
||||||
|
const d = String(dt.getDate()).padStart(2, "0");
|
||||||
|
const hh = String(dt.getHours()).padStart(2, "0");
|
||||||
|
const mm = String(dt.getMinutes()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${d} ${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBudget(item) {
|
||||||
|
if (item.is_free) return "互免";
|
||||||
|
if (item.budget_min && item.budget_max) return `¥${item.budget_min}-${item.budget_max}`;
|
||||||
|
if (item.budget_min) return `¥${item.budget_min}起`;
|
||||||
|
if (item.budget_max) return `¥${item.budget_max}以内`;
|
||||||
|
return "面议";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureLoginForMineTab() {
|
||||||
|
if (!currentTabMeta.value?.mine) return true;
|
||||||
|
return checkLogin();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTab(tabKey, reset = false) {
|
||||||
|
const stateRef = stateMap[tabKey];
|
||||||
|
const state = stateRef.value;
|
||||||
|
if (state.loading) return;
|
||||||
|
if (!reset && state.finished) return;
|
||||||
|
|
||||||
|
const tabMeta = tabs.find((t) => t.key === tabKey);
|
||||||
|
if (tabMeta?.mine && !checkLogin()) return;
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
|
state.page = 1;
|
||||||
|
state.total = 0;
|
||||||
|
state.finished = false;
|
||||||
|
state.list = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
state.loading = true;
|
||||||
|
try {
|
||||||
|
const params = { page: state.page, page_size: 20 };
|
||||||
|
let res;
|
||||||
|
|
||||||
|
if (tabKey === "shooting_plaza") {
|
||||||
|
params.status = "open";
|
||||||
|
res = await getShootingList(params);
|
||||||
|
} else if (tabKey === "shooting_mine") {
|
||||||
|
const [mineRes, applyRes] = await Promise.all([
|
||||||
|
getMyShootings(params),
|
||||||
|
getMyApplications({ page: 1, page_size: 5 }),
|
||||||
|
]);
|
||||||
|
const mixed = [...(mineRes.items || [])];
|
||||||
|
if (state.page === 1) {
|
||||||
|
(applyRes.items || []).forEach((app) => {
|
||||||
|
mixed.push({
|
||||||
|
id: `applied_${app.id}`,
|
||||||
|
_application: true,
|
||||||
|
request_id: app.request_id,
|
||||||
|
title: `我报名的约拍 #${app.request_id}`,
|
||||||
|
city: "",
|
||||||
|
status: app.status,
|
||||||
|
created_at: app.created_at,
|
||||||
|
message: app.message,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res = { items: mixed, total: (mineRes.total || 0) + (state.page === 1 ? (applyRes.items || []).length : 0) };
|
||||||
|
} else if (tabKey === "event_plaza") {
|
||||||
|
res = await getEventList(params);
|
||||||
|
} else {
|
||||||
|
const [mineRes, regRes] = await Promise.all([
|
||||||
|
getMyEvents(params),
|
||||||
|
getMyRegistrations({ page: 1, page_size: 5 }),
|
||||||
|
]);
|
||||||
|
const mixed = [...(mineRes.items || [])];
|
||||||
|
if (state.page === 1) {
|
||||||
|
(regRes.items || []).forEach((reg) => {
|
||||||
|
mixed.push({
|
||||||
|
id: `joined_${reg.id}`,
|
||||||
|
_registration: true,
|
||||||
|
event_id: reg.event_id,
|
||||||
|
title: `我参加的活动 #${reg.event_id}`,
|
||||||
|
city: "",
|
||||||
|
status: reg.status,
|
||||||
|
created_at: reg.created_at,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res = { items: mixed, total: (mineRes.total || 0) + (state.page === 1 ? (regRes.items || []).length : 0) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = res.items || [];
|
||||||
|
if (reset) state.list = items;
|
||||||
|
else state.list.push(...items);
|
||||||
|
|
||||||
|
state.total = res.total || 0;
|
||||||
|
state.finished = items.length < 20 || state.list.length >= state.total;
|
||||||
|
state.page += 1;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
state.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(key) {
|
||||||
|
activeTab.value = key;
|
||||||
|
if (!ensureLoginForMineTab()) return;
|
||||||
|
if (stateMap[key].value.list.length === 0) {
|
||||||
|
fetchTab(key, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goCreate() {
|
||||||
|
if (currentTabMeta.value?.type === "shooting") {
|
||||||
|
uni.navigateTo({ url: "/pages/shooting/create" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uni.navigateTo({ url: "/pages/event/create" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function goDetail(item) {
|
||||||
|
if (item._application) {
|
||||||
|
uni.navigateTo({ url: `/pages/shooting/detail?id=${item.request_id}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item._registration) {
|
||||||
|
uni.navigateTo({ url: `/pages/event/detail?id=${item.event_id}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (currentTabMeta.value?.type === "shooting") {
|
||||||
|
uni.navigateTo({ url: `/pages/shooting/detail?id=${item.id}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uni.navigateTo({ url: `/pages/event/detail?id=${item.id}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
if (stateMap[activeTab.value].value.list.length === 0) {
|
||||||
|
fetchTab(activeTab.value, true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onPullDownRefresh(async () => {
|
||||||
|
await fetchTab(activeTab.value, true);
|
||||||
|
uni.stopPullDownRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
onReachBottom(() => {
|
||||||
|
fetchTab(activeTab.value);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="activity-page">
|
||||||
|
<view class="page-header">
|
||||||
|
<text class="page-title">活动</text>
|
||||||
|
<view class="create-btn" @tap="goCreate">
|
||||||
|
<uni-icons type="plusempty" size="16" color="#fff" />
|
||||||
|
<text>发布</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="tabs">
|
||||||
|
<view
|
||||||
|
v-for="tab in tabs"
|
||||||
|
:key="tab.key"
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ active: activeTab === tab.key }"
|
||||||
|
@tap="switchTab(tab.key)"
|
||||||
|
>
|
||||||
|
{{ tab.label }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="list-wrap">
|
||||||
|
<view
|
||||||
|
v-for="item in currentItems"
|
||||||
|
:key="item.id"
|
||||||
|
class="card"
|
||||||
|
@tap="goDetail(item)"
|
||||||
|
>
|
||||||
|
<view class="card-top">
|
||||||
|
<text class="card-title">{{ item.title }}</text>
|
||||||
|
<view
|
||||||
|
class="status"
|
||||||
|
:style="{
|
||||||
|
background: currentTabMeta.type === 'shooting'
|
||||||
|
? (shootingStatusColors[item.status] || '#9ca3af')
|
||||||
|
: (eventStatusColors[item.status] || '#9ca3af')
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ currentTabMeta.type === "shooting" ? (shootingStatusLabels[item.status] || item.status) : (eventStatusLabels[item.status] || item.status) }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card-row" v-if="item.city">
|
||||||
|
<uni-icons type="location" size="14" color="#6366f1" />
|
||||||
|
<text>{{ item.city }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card-row" v-if="currentTabMeta.type === 'shooting' && item.role_needed">
|
||||||
|
<uni-icons type="person" size="14" color="#6366f1" />
|
||||||
|
<text>{{ roleLabels[item.role_needed] || item.role_needed }}</text>
|
||||||
|
<text class="dot">·</text>
|
||||||
|
<text>{{ formatBudget(item) }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card-row" v-if="item.message">
|
||||||
|
<uni-icons type="chat" size="14" color="#6366f1" />
|
||||||
|
<text class="line1">{{ item.message }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card-bottom">
|
||||||
|
<text>{{ formatDateTime(item.start_time || item.shoot_date || item.created_at) }}</text>
|
||||||
|
<text v-if="currentTabMeta.type === 'shooting' && !item._application">{{ item.application_count || 0 }}人报名</text>
|
||||||
|
<text v-if="currentTabMeta.type === 'event' && !item._registration">{{ item.registration_count || 0 }}人报名</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isCurrentLoading" class="status-tip">加载中...</view>
|
||||||
|
<view v-else-if="isCurrentFinished && currentItems.length" class="status-tip">没有更多了</view>
|
||||||
|
<view v-else-if="!isCurrentLoading && !currentItems.length" class="empty-tip">
|
||||||
|
<uni-icons type="info" size="40" color="#d1d5db" />
|
||||||
|
<text>暂无内容</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.activity-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f6fa;
|
||||||
|
padding-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
.page-header {
|
||||||
|
background: #fff;
|
||||||
|
padding: 20rpx 28rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e1e2e;
|
||||||
|
}
|
||||||
|
.create-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
padding: 10rpx 20rpx;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
background: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1rpx solid #f1f5f9;
|
||||||
|
border-bottom: 1rpx solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.tab-item {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #6b7280;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.tab-item.active {
|
||||||
|
color: #6366f1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.tab-item.active::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 26%;
|
||||||
|
right: 26%;
|
||||||
|
bottom: 0;
|
||||||
|
height: 4rpx;
|
||||||
|
background: #6366f1;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
}
|
||||||
|
.list-wrap {
|
||||||
|
padding: 0 20rpx;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 24rpx 28rpx;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
.card-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e1e2e;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #fff;
|
||||||
|
padding: 4rpx 14rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.card-row {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
.dot {
|
||||||
|
margin: 0 4rpx;
|
||||||
|
}
|
||||||
|
.line1 {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.card-bottom {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
.status-tip {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #9ca3af;
|
||||||
|
padding: 30rpx 0;
|
||||||
|
}
|
||||||
|
.empty-tip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,297 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
|
import { createEvent, updateEvent, getEventDetail } from "@/api/event";
|
||||||
|
import { uploadImage } from "@/api/spot";
|
||||||
|
import { resolveImageUrl } from "@/utils/image";
|
||||||
|
|
||||||
|
const isEdit = ref(false);
|
||||||
|
const editId = ref(0);
|
||||||
|
const submitting = ref(false);
|
||||||
|
|
||||||
|
const form = ref({
|
||||||
|
title: "",
|
||||||
|
city: "",
|
||||||
|
description: "",
|
||||||
|
cover_url: "",
|
||||||
|
location_name: "",
|
||||||
|
start_date: "",
|
||||||
|
start_time: "10:00",
|
||||||
|
end_date: "",
|
||||||
|
end_time: "18:00",
|
||||||
|
max_participants: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const coverPreview = ref("");
|
||||||
|
|
||||||
|
async function chooseCover() {
|
||||||
|
uni.chooseImage({
|
||||||
|
count: 1,
|
||||||
|
success: async (res) => {
|
||||||
|
const path = res.tempFilePaths[0];
|
||||||
|
try {
|
||||||
|
uni.showLoading({ title: "上传中..." });
|
||||||
|
const uploadRes = await uploadImage(path);
|
||||||
|
form.value.cover_url = uploadRes.url || uploadRes.path;
|
||||||
|
coverPreview.value = resolveImageUrl(form.value.cover_url);
|
||||||
|
uni.showToast({ title: "上传成功", icon: "success" });
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: "上传失败", icon: "none" });
|
||||||
|
} finally {
|
||||||
|
uni.hideLoading();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function validate() {
|
||||||
|
if (!form.value.title.trim()) {
|
||||||
|
uni.showToast({ title: "请输入活动标题", icon: "none" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!form.value.city.trim()) {
|
||||||
|
uni.showToast({ title: "请输入城市", icon: "none" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDateTime(date, time) {
|
||||||
|
if (!date) return null;
|
||||||
|
return new Date(`${date}T${time || "00:00"}:00`).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!validate()) return;
|
||||||
|
submitting.value = true;
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
title: form.value.title.trim(),
|
||||||
|
city: form.value.city.trim(),
|
||||||
|
description: form.value.description.trim() || null,
|
||||||
|
cover_url: form.value.cover_url || null,
|
||||||
|
location_name: form.value.location_name.trim() || null,
|
||||||
|
max_participants: Number(form.value.max_participants) || 0,
|
||||||
|
start_time: buildDateTime(form.value.start_date, form.value.start_time),
|
||||||
|
end_time: buildDateTime(form.value.end_date, form.value.end_time),
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEdit.value) {
|
||||||
|
await updateEvent(editId.value, data);
|
||||||
|
uni.showToast({ title: "更新成功", icon: "success" });
|
||||||
|
} else {
|
||||||
|
await createEvent(data);
|
||||||
|
uni.showToast({ title: "发布成功,等待审核", icon: "success" });
|
||||||
|
}
|
||||||
|
setTimeout(() => uni.navigateBack(), 1200);
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: e.message || "提交失败", icon: "none" });
|
||||||
|
} finally {
|
||||||
|
submitting.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadForEdit(id) {
|
||||||
|
try {
|
||||||
|
const d = await getEventDetail(id);
|
||||||
|
form.value.title = d.title || "";
|
||||||
|
form.value.city = d.city || "";
|
||||||
|
form.value.description = d.description || "";
|
||||||
|
form.value.cover_url = d.cover_url || "";
|
||||||
|
form.value.location_name = d.location_name || "";
|
||||||
|
form.value.max_participants = d.max_participants || 0;
|
||||||
|
if (d.cover_url) coverPreview.value = resolveImageUrl(d.cover_url);
|
||||||
|
if (d.start_time) {
|
||||||
|
const st = new Date(d.start_time);
|
||||||
|
form.value.start_date = st.toISOString().substring(0, 10);
|
||||||
|
form.value.start_time = `${String(st.getHours()).padStart(2, "0")}:${String(st.getMinutes()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
if (d.end_time) {
|
||||||
|
const et = new Date(d.end_time);
|
||||||
|
form.value.end_date = et.toISOString().substring(0, 10);
|
||||||
|
form.value.end_time = `${String(et.getHours()).padStart(2, "0")}:${String(et.getMinutes()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: "加载失败", icon: "none" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
if (query.id) {
|
||||||
|
isEdit.value = true;
|
||||||
|
editId.value = Number(query.id);
|
||||||
|
uni.setNavigationBarTitle({ title: "编辑活动" });
|
||||||
|
loadForEdit(editId.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="create-page">
|
||||||
|
<view class="form-card">
|
||||||
|
<view class="cover-section" @tap="chooseCover">
|
||||||
|
<image v-if="coverPreview" class="cover-preview" :src="coverPreview" mode="aspectFill" />
|
||||||
|
<view v-else class="cover-placeholder">
|
||||||
|
<uni-icons type="plusempty" size="32" color="#9ca3af" />
|
||||||
|
<text class="cover-hint">添加封面图</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-row">
|
||||||
|
<text class="form-label required">活动标题</text>
|
||||||
|
<input v-model="form.title" class="form-input" placeholder="如:外滩夜景约拍团" :maxlength="100" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-row">
|
||||||
|
<text class="form-label required">城市</text>
|
||||||
|
<input v-model="form.city" class="form-input" placeholder="如:上海" :maxlength="50" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-row">
|
||||||
|
<text class="form-label">活动地点</text>
|
||||||
|
<input v-model="form.location_name" class="form-input" placeholder="如:外滩观景平台" :maxlength="100" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-row">
|
||||||
|
<text class="form-label">开始日期</text>
|
||||||
|
<picker mode="date" :value="form.start_date" @change="form.start_date = $event.detail.value">
|
||||||
|
<view class="form-input picker-display">{{ form.start_date || "选择日期" }}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<text class="form-label">开始时间</text>
|
||||||
|
<picker mode="time" :value="form.start_time" @change="form.start_time = $event.detail.value">
|
||||||
|
<view class="form-input picker-display">{{ form.start_time }}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-row">
|
||||||
|
<text class="form-label">结束日期</text>
|
||||||
|
<picker mode="date" :value="form.end_date" @change="form.end_date = $event.detail.value">
|
||||||
|
<view class="form-input picker-display">{{ form.end_date || "选择日期" }}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
<view class="form-row">
|
||||||
|
<text class="form-label">结束时间</text>
|
||||||
|
<picker mode="time" :value="form.end_time" @change="form.end_time = $event.detail.value">
|
||||||
|
<view class="form-input picker-display">{{ form.end_time }}</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-row">
|
||||||
|
<text class="form-label">人数限制</text>
|
||||||
|
<input v-model="form.max_participants" class="form-input" type="number" placeholder="0表示不限" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form-row">
|
||||||
|
<text class="form-label">活动详情</text>
|
||||||
|
<textarea v-model="form.description" class="form-textarea" placeholder="描述活动内容、注意事项等" :maxlength="5000" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="submit-row">
|
||||||
|
<view class="submit-btn" :class="{ disabled: submitting }" @tap="handleSubmit">
|
||||||
|
{{ submitting ? "提交中..." : isEdit ? "保存修改" : "发布活动" }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.create-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f6fa;
|
||||||
|
padding-bottom: 40rpx;
|
||||||
|
}
|
||||||
|
.form-card {
|
||||||
|
background: #fff;
|
||||||
|
margin: 16rpx 20rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 12rpx 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-section {
|
||||||
|
margin: 16rpx 0;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.cover-preview {
|
||||||
|
width: 100%;
|
||||||
|
height: 300rpx;
|
||||||
|
}
|
||||||
|
.cover-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 300rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border: 2rpx dashed #d1d5db;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
gap: 12rpx;
|
||||||
|
}
|
||||||
|
.cover-hint {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
padding: 20rpx 0;
|
||||||
|
border-bottom: 1rpx solid #f3f4f6;
|
||||||
|
}
|
||||||
|
.form-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.form-label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #374151;
|
||||||
|
margin-bottom: 10rpx;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.form-label.required::before {
|
||||||
|
content: "* ";
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
.form-input {
|
||||||
|
width: 100%;
|
||||||
|
height: 72rpx;
|
||||||
|
border: 1rpx solid #e5e7eb;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
line-height: 72rpx;
|
||||||
|
}
|
||||||
|
.picker-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.form-textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 200rpx;
|
||||||
|
border: 1rpx solid #e5e7eb;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
padding: 16rpx 20rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-row {
|
||||||
|
padding: 20rpx 28rpx;
|
||||||
|
}
|
||||||
|
.submit-btn {
|
||||||
|
text-align: center;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
background: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
.submit-btn.disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { onLoad } from "@dcloudio/uni-app";
|
||||||
|
import {
|
||||||
|
getEventDetail,
|
||||||
|
registerEvent,
|
||||||
|
cancelRegistration,
|
||||||
|
cancelEvent,
|
||||||
|
getRegistrations,
|
||||||
|
addEventPhoto,
|
||||||
|
} from "@/api/event";
|
||||||
|
import { uploadImage } from "@/api/spot";
|
||||||
|
import { resolveImageUrl } from "@/utils/image";
|
||||||
|
|
||||||
|
const detail = ref(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
const eventId = ref(0);
|
||||||
|
const isOwner = ref(false);
|
||||||
|
const registrations = ref([]);
|
||||||
|
const showRegistrations = ref(false);
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
upcoming: "即将开始",
|
||||||
|
ongoing: "进行中",
|
||||||
|
ended: "已结束",
|
||||||
|
cancelled: "已取消",
|
||||||
|
};
|
||||||
|
const statusColors = {
|
||||||
|
upcoming: "#6366f1",
|
||||||
|
ongoing: "#22c55e",
|
||||||
|
ended: "#9ca3af",
|
||||||
|
cancelled: "#ef4444",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadDetail() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getEventDetail(eventId.value);
|
||||||
|
detail.value = res;
|
||||||
|
const userStr = uni.getStorageSync("userInfo");
|
||||||
|
if (userStr) {
|
||||||
|
try {
|
||||||
|
const u = typeof userStr === "string" ? JSON.parse(userStr) : userStr;
|
||||||
|
isOwner.value = u.id === res.creator?.id;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: "加载失败", icon: "none" });
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRegister() {
|
||||||
|
try {
|
||||||
|
await registerEvent(eventId.value);
|
||||||
|
uni.showToast({ title: "报名成功", icon: "success" });
|
||||||
|
await loadDetail();
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: e.message || "报名失败", icon: "none" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCancelRegistration() {
|
||||||
|
uni.showModal({
|
||||||
|
title: "提示",
|
||||||
|
content: "确定取消报名?",
|
||||||
|
success: async (r) => {
|
||||||
|
if (!r.confirm) return;
|
||||||
|
try {
|
||||||
|
await cancelRegistration(eventId.value);
|
||||||
|
uni.showToast({ title: "已取消", icon: "success" });
|
||||||
|
await loadDetail();
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: e.message || "操作失败", icon: "none" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCancel() {
|
||||||
|
uni.showModal({
|
||||||
|
title: "提示",
|
||||||
|
content: "确定取消活动?取消后将通知所有报名用户。",
|
||||||
|
success: async (r) => {
|
||||||
|
if (!r.confirm) return;
|
||||||
|
try {
|
||||||
|
await cancelEvent(eventId.value);
|
||||||
|
uni.showToast({ title: "已取消", icon: "success" });
|
||||||
|
await loadDetail();
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: e.message || "操作失败", icon: "none" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRegistrations() {
|
||||||
|
try {
|
||||||
|
const res = await getRegistrations(eventId.value);
|
||||||
|
registrations.value = Array.isArray(res) ? res : res.items || [];
|
||||||
|
showRegistrations.value = true;
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: "无权查看", icon: "none" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUploadPhoto() {
|
||||||
|
uni.chooseImage({
|
||||||
|
count: 1,
|
||||||
|
success: async (chooseRes) => {
|
||||||
|
const tempPath = chooseRes.tempFilePaths[0];
|
||||||
|
try {
|
||||||
|
uni.showLoading({ title: "上传中..." });
|
||||||
|
const uploadRes = await uploadImage(tempPath);
|
||||||
|
const imageUrl = uploadRes.url || uploadRes.path;
|
||||||
|
await addEventPhoto(eventId.value, { image_url: imageUrl });
|
||||||
|
uni.showToast({ title: "上传成功", icon: "success" });
|
||||||
|
await loadDetail();
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: "上传失败", icon: "none" });
|
||||||
|
} finally {
|
||||||
|
uni.hideLoading();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewPhotos(idx) {
|
||||||
|
const urls = (detail.value?.photos || []).map((p) => resolveImageUrl(p.image_url));
|
||||||
|
uni.previewImage({ urls, current: urls[idx] || urls[0] });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(d) {
|
||||||
|
if (!d) return "待定";
|
||||||
|
const dt = new Date(d);
|
||||||
|
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")} ${String(dt.getHours()).padStart(2, "0")}:${String(dt.getMinutes()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canRegister = computed(() => {
|
||||||
|
if (!detail.value) return false;
|
||||||
|
if (detail.value.status !== "upcoming") return false;
|
||||||
|
if (detail.value.has_registered) return false;
|
||||||
|
if (isOwner.value) return false;
|
||||||
|
if (detail.value.max_participants > 0 && detail.value.registration_count >= detail.value.max_participants) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
eventId.value = Number(query.id);
|
||||||
|
loadDetail();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="detail-page">
|
||||||
|
<view v-if="loading" class="loading-tip">加载中...</view>
|
||||||
|
<template v-else-if="detail">
|
||||||
|
<image
|
||||||
|
v-if="detail.cover_url"
|
||||||
|
class="cover-image"
|
||||||
|
:src="resolveImageUrl(detail.cover_url)"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<view class="header-card">
|
||||||
|
<view class="title-row">
|
||||||
|
<text class="title">{{ detail.title }}</text>
|
||||||
|
<view class="status-tag" :style="{ background: statusColors[detail.status] || '#9ca3af' }">
|
||||||
|
{{ statusLabels[detail.status] || detail.status }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="creator-row" v-if="detail.creator">
|
||||||
|
<text class="creator-nick">{{ detail.creator.nickname }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="info-card">
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">城市</text>
|
||||||
|
<text class="info-value">{{ detail.city }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row" v-if="detail.location_name">
|
||||||
|
<text class="info-label">地点</text>
|
||||||
|
<text class="info-value">{{ detail.location_name }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">开始时间</text>
|
||||||
|
<text class="info-value">{{ formatDateTime(detail.start_time) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">结束时间</text>
|
||||||
|
<text class="info-value">{{ formatDateTime(detail.end_time) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">人数限制</text>
|
||||||
|
<text class="info-value">{{ detail.max_participants > 0 ? detail.max_participants + '人' : '不限' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">已报名</text>
|
||||||
|
<text class="info-value">{{ detail.registration_count }}人</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="detail.description" class="desc-card">
|
||||||
|
<text class="desc-title">活动详情</text>
|
||||||
|
<text class="desc-text">{{ detail.description }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="detail.reject_reason" class="reject-card">
|
||||||
|
<uni-icons type="info" size="16" color="#ef4444" />
|
||||||
|
<text class="reject-text">驳回原因:{{ detail.reject_reason }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Registration list for owner -->
|
||||||
|
<view v-if="isOwner" class="section">
|
||||||
|
<view class="section-header" @tap="loadRegistrations">
|
||||||
|
<text class="section-title">报名列表</text>
|
||||||
|
<uni-icons type="right" size="16" color="#6366f1" />
|
||||||
|
</view>
|
||||||
|
<view v-if="showRegistrations" class="reg-list">
|
||||||
|
<view v-if="registrations.length === 0" class="reg-empty">暂无报名</view>
|
||||||
|
<view v-for="reg in registrations" :key="reg.id" class="reg-item">
|
||||||
|
<text class="reg-name">{{ reg.user?.nickname || '匿名' }}</text>
|
||||||
|
<text class="reg-time">{{ formatDateTime(reg.created_at) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Photo album -->
|
||||||
|
<view class="section">
|
||||||
|
<view class="section-header">
|
||||||
|
<text class="section-title">活动相册({{ detail.photos?.length || 0 }})</text>
|
||||||
|
<view
|
||||||
|
v-if="detail.has_registered || isOwner"
|
||||||
|
class="upload-btn"
|
||||||
|
@tap="handleUploadPhoto"
|
||||||
|
>
|
||||||
|
<uni-icons type="plusempty" size="14" color="#6366f1" />
|
||||||
|
<text>上传</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="detail.photos && detail.photos.length > 0" class="photo-grid">
|
||||||
|
<image
|
||||||
|
v-for="(photo, idx) in detail.photos"
|
||||||
|
:key="photo.id"
|
||||||
|
class="photo-item"
|
||||||
|
:src="resolveImageUrl(photo.image_url)"
|
||||||
|
mode="aspectFill"
|
||||||
|
@tap="previewPhotos(idx)"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view v-else class="photo-empty">
|
||||||
|
<text>暂无照片</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Bottom actions -->
|
||||||
|
<view class="bottom-bar">
|
||||||
|
<template v-if="isOwner">
|
||||||
|
<view
|
||||||
|
v-if="detail.status !== 'cancelled' && detail.status !== 'ended'"
|
||||||
|
class="btn btn-cancel"
|
||||||
|
@tap="handleCancel"
|
||||||
|
>取消活动</view>
|
||||||
|
<view
|
||||||
|
class="btn btn-edit"
|
||||||
|
@tap="uni.navigateTo({ url: `/pages/event/create?id=${detail.id}` })"
|
||||||
|
v-if="detail.status === 'upcoming'"
|
||||||
|
>编辑</view>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<view v-if="canRegister" class="btn btn-register" @tap="handleRegister">
|
||||||
|
我要报名
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-else-if="detail.has_registered"
|
||||||
|
class="btn btn-unregister"
|
||||||
|
@tap="handleCancelRegistration"
|
||||||
|
>取消报名</view>
|
||||||
|
<view v-else class="btn btn-disabled">
|
||||||
|
{{ detail.status === 'cancelled' ? '已取消' : detail.status === 'ended' ? '已结束' : '人数已满' }}
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.detail-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f6fa;
|
||||||
|
padding-bottom: 140rpx;
|
||||||
|
}
|
||||||
|
.loading-tip {
|
||||||
|
text-align: center;
|
||||||
|
padding: 100rpx 0;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
.cover-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 360rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-card,
|
||||||
|
.info-card,
|
||||||
|
.desc-card,
|
||||||
|
.reject-card,
|
||||||
|
.section {
|
||||||
|
background: #fff;
|
||||||
|
margin: 16rpx 20rpx 0;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
}
|
||||||
|
.title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e1e2e;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
.status-tag {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #fff;
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 12rpx;
|
||||||
|
}
|
||||||
|
.creator-row {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
.creator-nick {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 14rpx 0;
|
||||||
|
border-bottom: 1rpx solid #f3f4f6;
|
||||||
|
}
|
||||||
|
.info-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.info-label {
|
||||||
|
width: 160rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #9ca3af;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.info-value {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.desc-title,
|
||||||
|
.section-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e1e2e;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
.desc-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #4b5563;
|
||||||
|
line-height: 1.7;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reject-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 8rpx;
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
.reject-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.upload-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
.reg-list {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
.reg-empty {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #9ca3af;
|
||||||
|
padding: 16rpx 0;
|
||||||
|
}
|
||||||
|
.reg-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 12rpx 0;
|
||||||
|
border-top: 1rpx solid #f3f4f6;
|
||||||
|
}
|
||||||
|
.reg-name {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #1e1e2e;
|
||||||
|
}
|
||||||
|
.reg-time {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.photo-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-top: 8rpx;
|
||||||
|
}
|
||||||
|
.photo-item {
|
||||||
|
width: calc(33.33% - 8rpx);
|
||||||
|
height: 200rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
}
|
||||||
|
.photo-empty {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #9ca3af;
|
||||||
|
padding: 20rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-bar {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 20rpx 28rpx;
|
||||||
|
padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
|
||||||
|
background: #fff;
|
||||||
|
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 22rpx 0;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-register {
|
||||||
|
background: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-unregister {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
.btn-cancel {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
.btn-edit {
|
||||||
|
background: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-disabled {
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { onPullDownRefresh, onReachBottom } from "@dcloudio/uni-app";
|
||||||
|
import { getEventList } from "@/api/event";
|
||||||
|
import { resolveImageUrl } from "@/utils/image";
|
||||||
|
|
||||||
|
const list = ref([]);
|
||||||
|
const page = ref(1);
|
||||||
|
const total = ref(0);
|
||||||
|
const loading = ref(false);
|
||||||
|
const finished = ref(false);
|
||||||
|
const city = ref("");
|
||||||
|
const statusFilter = ref("");
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
upcoming: "即将开始",
|
||||||
|
ongoing: "进行中",
|
||||||
|
ended: "已结束",
|
||||||
|
cancelled: "已取消",
|
||||||
|
};
|
||||||
|
const statusColors = {
|
||||||
|
upcoming: "#6366f1",
|
||||||
|
ongoing: "#22c55e",
|
||||||
|
ended: "#9ca3af",
|
||||||
|
cancelled: "#ef4444",
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusTabs = [
|
||||||
|
{ label: "全部", value: "" },
|
||||||
|
{ label: "即将开始", value: "upcoming" },
|
||||||
|
{ label: "进行中", value: "ongoing" },
|
||||||
|
{ label: "已结束", value: "ended" },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function fetchList(reset = false) {
|
||||||
|
if (loading.value) return;
|
||||||
|
if (reset) {
|
||||||
|
page.value = 1;
|
||||||
|
finished.value = false;
|
||||||
|
list.value = [];
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params = { page: page.value, page_size: 20 };
|
||||||
|
if (city.value) params.city = city.value;
|
||||||
|
if (statusFilter.value) params.status = statusFilter.value;
|
||||||
|
|
||||||
|
const res = await getEventList(params);
|
||||||
|
const items = res.items || [];
|
||||||
|
if (reset) list.value = items;
|
||||||
|
else list.value.push(...items);
|
||||||
|
total.value = res.total || 0;
|
||||||
|
if (list.value.length >= total.value) finished.value = true;
|
||||||
|
page.value++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchStatus(val) {
|
||||||
|
statusFilter.value = val;
|
||||||
|
fetchList(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goDetail(id) {
|
||||||
|
uni.navigateTo({ url: `/pages/event/detail?id=${id}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
function goCreate() {
|
||||||
|
uni.navigateTo({ url: "/pages/event/create" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d) {
|
||||||
|
if (!d) return "待定";
|
||||||
|
const dt = new Date(d);
|
||||||
|
return `${dt.getMonth() + 1}月${dt.getDate()}日 ${String(dt.getHours()).padStart(2, "0")}:${String(dt.getMinutes()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function participantText(item) {
|
||||||
|
if (item.max_participants > 0) return `${item.registration_count}/${item.max_participants}人`;
|
||||||
|
return `${item.registration_count}人报名`;
|
||||||
|
}
|
||||||
|
|
||||||
|
onPullDownRefresh(async () => {
|
||||||
|
await fetchList(true);
|
||||||
|
uni.stopPullDownRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
onReachBottom(() => {
|
||||||
|
if (!finished.value) fetchList();
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchList(true);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="event-page">
|
||||||
|
<view class="top-bar">
|
||||||
|
<view class="bar-left">
|
||||||
|
<text class="bar-title">活动</text>
|
||||||
|
</view>
|
||||||
|
<view class="bar-btn primary" @tap="goCreate">
|
||||||
|
<uni-icons type="plusempty" size="16" color="#fff" />
|
||||||
|
<text>发布</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="status-tabs">
|
||||||
|
<view
|
||||||
|
v-for="tab in statusTabs"
|
||||||
|
:key="tab.value"
|
||||||
|
class="status-tab"
|
||||||
|
:class="{ active: statusFilter === tab.value }"
|
||||||
|
@tap="switchStatus(tab.value)"
|
||||||
|
>{{ tab.label }}</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card-list">
|
||||||
|
<view
|
||||||
|
v-for="item in list"
|
||||||
|
:key="item.id"
|
||||||
|
class="event-card"
|
||||||
|
@tap="goDetail(item.id)"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
v-if="item.cover_url"
|
||||||
|
class="card-cover"
|
||||||
|
:src="resolveImageUrl(item.cover_url)"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<view class="card-body">
|
||||||
|
<view class="card-title-row">
|
||||||
|
<text class="card-title">{{ item.title }}</text>
|
||||||
|
<view class="status-tag" :style="{ background: statusColors[item.status] || '#9ca3af' }">
|
||||||
|
{{ statusLabels[item.status] || item.status }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="card-info">
|
||||||
|
<view class="info-item">
|
||||||
|
<uni-icons type="location" size="14" color="#6366f1" />
|
||||||
|
<text>{{ item.city }}</text>
|
||||||
|
<text v-if="item.location_name"> · {{ item.location_name }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-item">
|
||||||
|
<uni-icons type="calendar" size="14" color="#6366f1" />
|
||||||
|
<text>{{ formatDate(item.start_time) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="card-footer">
|
||||||
|
<text class="creator-name" v-if="item.creator">{{ item.creator.nickname }}</text>
|
||||||
|
<text class="participant-count">{{ participantText(item) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="loading" class="loading-tip">加载中...</view>
|
||||||
|
<view v-else-if="finished && list.length > 0" class="loading-tip">没有更多了</view>
|
||||||
|
<view v-else-if="!loading && list.length === 0" class="empty-tip">
|
||||||
|
<uni-icons type="info" size="40" color="#d1d5db" />
|
||||||
|
<text>暂无活动</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.event-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f6fa;
|
||||||
|
padding-bottom: 30rpx;
|
||||||
|
}
|
||||||
|
.top-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20rpx 28rpx;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.bar-title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e1e2e;
|
||||||
|
}
|
||||||
|
.bar-btn.primary {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
padding: 10rpx 20rpx;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
background: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tabs {
|
||||||
|
display: flex;
|
||||||
|
background: #fff;
|
||||||
|
padding: 0 20rpx;
|
||||||
|
border-bottom: 1rpx solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.status-tab {
|
||||||
|
padding: 18rpx 24rpx;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #6b7280;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.status-tab.active {
|
||||||
|
color: #6366f1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.status-tab.active::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 20%;
|
||||||
|
right: 20%;
|
||||||
|
bottom: 0;
|
||||||
|
height: 4rpx;
|
||||||
|
background: #6366f1;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-list {
|
||||||
|
padding: 0 20rpx;
|
||||||
|
}
|
||||||
|
.event-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
.card-cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 280rpx;
|
||||||
|
}
|
||||||
|
.card-body {
|
||||||
|
padding: 24rpx 28rpx;
|
||||||
|
}
|
||||||
|
.card-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e1e2e;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.status-tag {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #fff;
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 12rpx;
|
||||||
|
}
|
||||||
|
.card-info {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
.info-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #6b7280;
|
||||||
|
}
|
||||||
|
.card-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 14rpx;
|
||||||
|
}
|
||||||
|
.creator-name {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
.participant-count {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-tip {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #9ca3af;
|
||||||
|
padding: 30rpx 0;
|
||||||
|
}
|
||||||
|
.empty-tip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { onPullDownRefresh, onReachBottom } from "@dcloudio/uni-app";
|
||||||
|
import { getMyEvents, getMyRegistrations } from "@/api/event";
|
||||||
|
|
||||||
|
const tab = ref("created");
|
||||||
|
const createdList = ref([]);
|
||||||
|
const joinedList = ref([]);
|
||||||
|
const createdPage = ref(1);
|
||||||
|
const joinedPage = ref(1);
|
||||||
|
const createdTotal = ref(0);
|
||||||
|
const joinedTotal = ref(0);
|
||||||
|
const createdFinished = ref(false);
|
||||||
|
const joinedFinished = ref(false);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const statusLabels = {
|
||||||
|
upcoming: "即将开始",
|
||||||
|
ongoing: "进行中",
|
||||||
|
ended: "已结束",
|
||||||
|
cancelled: "已取消",
|
||||||
|
};
|
||||||
|
const statusColors = {
|
||||||
|
upcoming: "#6366f1",
|
||||||
|
ongoing: "#22c55e",
|
||||||
|
ended: "#9ca3af",
|
||||||
|
cancelled: "#ef4444",
|
||||||
|
};
|
||||||
|
const auditLabels = {
|
||||||
|
pending: "待审核",
|
||||||
|
approved: "已通过",
|
||||||
|
rejected: "已驳回",
|
||||||
|
};
|
||||||
|
const auditColors = {
|
||||||
|
pending: "#f59e0b",
|
||||||
|
approved: "#22c55e",
|
||||||
|
rejected: "#ef4444",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchCreated(reset = false) {
|
||||||
|
if (loading.value) return;
|
||||||
|
if (reset) {
|
||||||
|
createdPage.value = 1;
|
||||||
|
createdFinished.value = false;
|
||||||
|
createdList.value = [];
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getMyEvents({ page: createdPage.value, page_size: 20 });
|
||||||
|
const items = res.items || [];
|
||||||
|
if (reset) createdList.value = items;
|
||||||
|
else createdList.value.push(...items);
|
||||||
|
createdTotal.value = res.total || 0;
|
||||||
|
if (createdList.value.length >= createdTotal.value) createdFinished.value = true;
|
||||||
|
createdPage.value++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJoined(reset = false) {
|
||||||
|
if (loading.value) return;
|
||||||
|
if (reset) {
|
||||||
|
joinedPage.value = 1;
|
||||||
|
joinedFinished.value = false;
|
||||||
|
joinedList.value = [];
|
||||||
|
}
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await getMyRegistrations({ page: joinedPage.value, page_size: 20 });
|
||||||
|
const items = res.items || [];
|
||||||
|
if (reset) joinedList.value = items;
|
||||||
|
else joinedList.value.push(...items);
|
||||||
|
joinedTotal.value = res.total || 0;
|
||||||
|
if (joinedList.value.length >= joinedTotal.value) joinedFinished.value = true;
|
||||||
|
joinedPage.value++;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(t) {
|
||||||
|
tab.value = t;
|
||||||
|
if (t === "created" && createdList.value.length === 0) fetchCreated(true);
|
||||||
|
if (t === "joined" && joinedList.value.length === 0) fetchJoined(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goDetail(id) {
|
||||||
|
uni.navigateTo({ url: `/pages/event/detail?id=${id}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d) {
|
||||||
|
if (!d) return "";
|
||||||
|
const dt = new Date(d);
|
||||||
|
return `${dt.getMonth() + 1}月${dt.getDate()}日`;
|
||||||
|
}
|
||||||
|
|
||||||
|
onPullDownRefresh(async () => {
|
||||||
|
if (tab.value === "created") await fetchCreated(true);
|
||||||
|
else await fetchJoined(true);
|
||||||
|
uni.stopPullDownRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
onReachBottom(() => {
|
||||||
|
if (tab.value === "created" && !createdFinished.value) fetchCreated();
|
||||||
|
if (tab.value === "joined" && !joinedFinished.value) fetchJoined();
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchCreated(true);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="mine-event-page">
|
||||||
|
<view class="tabs">
|
||||||
|
<view class="tab-item" :class="{ active: tab === 'created' }" @tap="switchTab('created')">
|
||||||
|
我发布的
|
||||||
|
</view>
|
||||||
|
<view class="tab-item" :class="{ active: tab === 'joined' }" @tap="switchTab('joined')">
|
||||||
|
我参加的
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="tab === 'created'" class="card-list">
|
||||||
|
<view
|
||||||
|
v-for="item in createdList"
|
||||||
|
:key="item.id"
|
||||||
|
class="ev-card"
|
||||||
|
@tap="goDetail(item.id)"
|
||||||
|
>
|
||||||
|
<view class="card-title-row">
|
||||||
|
<text class="card-title">{{ item.title }}</text>
|
||||||
|
<view class="dual-tags">
|
||||||
|
<view class="mini-tag" :style="{ background: auditColors[item.audit_status] || '#9ca3af' }">
|
||||||
|
{{ auditLabels[item.audit_status] || item.audit_status }}
|
||||||
|
</view>
|
||||||
|
<view class="mini-tag" :style="{ background: statusColors[item.status] || '#9ca3af' }">
|
||||||
|
{{ statusLabels[item.status] || item.status }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="card-sub">
|
||||||
|
<text>{{ item.city }}</text>
|
||||||
|
<text v-if="item.location_name"> · {{ item.location_name }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="card-bottom">
|
||||||
|
<text class="card-date">{{ formatDate(item.start_time) }}</text>
|
||||||
|
<text class="reg-count">{{ item.registration_count }}人报名</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="loading" class="loading-tip">加载中...</view>
|
||||||
|
<view v-else-if="createdFinished && createdList.length" class="loading-tip">没有更多了</view>
|
||||||
|
<view v-else-if="!loading && !createdList.length" class="empty-tip">
|
||||||
|
<uni-icons type="info" size="40" color="#d1d5db" />
|
||||||
|
<text>还没有发布活动</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="tab === 'joined'" class="card-list">
|
||||||
|
<view
|
||||||
|
v-for="reg in joinedList"
|
||||||
|
:key="reg.id"
|
||||||
|
class="ev-card"
|
||||||
|
@tap="goDetail(reg.event_id)"
|
||||||
|
>
|
||||||
|
<view class="card-title-row">
|
||||||
|
<text class="card-title">活动 #{{ reg.event_id }}</text>
|
||||||
|
<view class="mini-tag" style="background: #22c55e">{{ reg.status === 'registered' ? '已报名' : reg.status }}</view>
|
||||||
|
</view>
|
||||||
|
<view class="card-bottom">
|
||||||
|
<text class="card-date">{{ formatDate(reg.created_at) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-if="loading" class="loading-tip">加载中...</view>
|
||||||
|
<view v-else-if="joinedFinished && joinedList.length" class="loading-tip">没有更多了</view>
|
||||||
|
<view v-else-if="!loading && !joinedList.length" class="empty-tip">
|
||||||
|
<uni-icons type="info" size="40" color="#d1d5db" />
|
||||||
|
<text>还没有参加活动</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mine-event-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f6fa;
|
||||||
|
}
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
background: #fff;
|
||||||
|
border-bottom: 1rpx solid #e5e7eb;
|
||||||
|
}
|
||||||
|
.tab-item {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24rpx 0;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #6b7280;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.tab-item.active {
|
||||||
|
color: #6366f1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.tab-item.active::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
left: 30%;
|
||||||
|
right: 30%;
|
||||||
|
bottom: 0;
|
||||||
|
height: 4rpx;
|
||||||
|
background: #6366f1;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-list {
|
||||||
|
padding: 0 20rpx;
|
||||||
|
}
|
||||||
|
.ev-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 24rpx 28rpx;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
.card-title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.card-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e1e2e;
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dual-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 8rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 12rpx;
|
||||||
|
}
|
||||||
|
.mini-tag {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #fff;
|
||||||
|
padding: 2rpx 12rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
.card-sub {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-top: 10rpx;
|
||||||
|
}
|
||||||
|
.card-bottom {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
.card-date {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
.reg-count {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-tip {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #9ca3af;
|
||||||
|
padding: 30rpx 0;
|
||||||
|
}
|
||||||
|
.empty-tip {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
padding: 120rpx 0;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,301 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { login, register } from "@/api/auth";
|
||||||
|
import { useUserStore } from "@/store/user";
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
|
||||||
|
const isRegister = ref(false);
|
||||||
|
const activeTab = ref("phone");
|
||||||
|
const form = ref({
|
||||||
|
phone: "",
|
||||||
|
email: "",
|
||||||
|
password: "",
|
||||||
|
nickname: "",
|
||||||
|
});
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
const account =
|
||||||
|
activeTab.value === "phone" ? form.value.phone : form.value.email;
|
||||||
|
if (!account) {
|
||||||
|
uni.showToast({
|
||||||
|
title: activeTab.value === "phone" ? "请输入手机号" : "请输入邮箱",
|
||||||
|
icon: "none",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!form.value.password) {
|
||||||
|
uni.showToast({ title: "请输入密码", icon: "none" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (form.value.password.length < 6) {
|
||||||
|
uni.showToast({ title: "密码至少6位", icon: "none" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (isRegister.value && !form.value.nickname) {
|
||||||
|
uni.showToast({ title: "请输入昵称", icon: "none" });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!validate()) return;
|
||||||
|
loading.value = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let res;
|
||||||
|
if (isRegister.value) {
|
||||||
|
res = await register({
|
||||||
|
password: form.value.password,
|
||||||
|
nickname: form.value.nickname,
|
||||||
|
...(activeTab.value === "phone"
|
||||||
|
? { phone: form.value.phone }
|
||||||
|
: { email: form.value.email }),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const account =
|
||||||
|
activeTab.value === "phone" ? form.value.phone : form.value.email;
|
||||||
|
res = await login({ account, password: form.value.password });
|
||||||
|
}
|
||||||
|
|
||||||
|
userStore.setTokens(res.access_token, res.refresh_token);
|
||||||
|
await userStore.fetchUserInfo();
|
||||||
|
uni.showToast({
|
||||||
|
title: isRegister.value ? "注册成功" : "登录成功",
|
||||||
|
icon: "success",
|
||||||
|
});
|
||||||
|
setTimeout(() => {
|
||||||
|
uni.switchTab({ url: "/pages/index/index" });
|
||||||
|
}, 500);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleMode = () => {
|
||||||
|
isRegister.value = !isRegister.value;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="login-page">
|
||||||
|
<view class="header">
|
||||||
|
<view class="logo-circle">
|
||||||
|
<text class="logo-text">取</text>
|
||||||
|
</view>
|
||||||
|
<text class="app-title">次元取景器</text>
|
||||||
|
<text class="app-subtitle">发现最美二次元取景地</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="card">
|
||||||
|
<view class="tabs">
|
||||||
|
<view
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ active: activeTab === 'phone' }"
|
||||||
|
@tap="activeTab = 'phone'"
|
||||||
|
>
|
||||||
|
手机号登录
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ active: activeTab === 'email' }"
|
||||||
|
@tap="activeTab = 'email'"
|
||||||
|
>
|
||||||
|
邮箱登录
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="form">
|
||||||
|
<view v-if="isRegister" class="field">
|
||||||
|
<input
|
||||||
|
v-model="form.nickname"
|
||||||
|
class="input"
|
||||||
|
placeholder="请输入昵称"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="activeTab === 'phone'" class="field">
|
||||||
|
<input
|
||||||
|
v-model="form.phone"
|
||||||
|
class="input"
|
||||||
|
type="number"
|
||||||
|
maxlength="11"
|
||||||
|
placeholder="请输入手机号"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
<view v-else class="field">
|
||||||
|
<input
|
||||||
|
v-model="form.email"
|
||||||
|
class="input"
|
||||||
|
placeholder="请输入邮箱"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="field">
|
||||||
|
<input
|
||||||
|
v-model="form.password"
|
||||||
|
class="input"
|
||||||
|
password
|
||||||
|
placeholder="请输入密码"
|
||||||
|
placeholder-class="placeholder"
|
||||||
|
/>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<button class="submit-btn" :loading="loading" @tap="handleSubmit">
|
||||||
|
{{ isRegister ? "注册" : "登录" }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="toggle" @tap="toggleMode">
|
||||||
|
<text class="toggle-text">
|
||||||
|
{{ isRegister ? "已有账号?立即登录" : "没有账号?立即注册" }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.login-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #a78bfa 100%);
|
||||||
|
padding: 0 40rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 160rpx;
|
||||||
|
padding-bottom: 60rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-circle {
|
||||||
|
width: 140rpx;
|
||||||
|
height: 140rpx;
|
||||||
|
border-radius: 70rpx;
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
font-size: 64rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
font-size: 48rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-subtitle {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
width: 100%;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
padding: 48rpx 40rpx;
|
||||||
|
box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 48rpx;
|
||||||
|
border-bottom: 2rpx solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
padding-bottom: 20rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #64748b;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.active {
|
||||||
|
color: #6366f1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-item.active::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: -2rpx;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 80rpx;
|
||||||
|
height: 4rpx;
|
||||||
|
background: #6366f1;
|
||||||
|
border-radius: 2rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form {
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
height: 88rpx;
|
||||||
|
background: #f5f6fa;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 0 28rpx;
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #1e293b;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 88rpx;
|
||||||
|
line-height: 88rpx;
|
||||||
|
background: #6366f1;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
border: none;
|
||||||
|
margin-top: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle {
|
||||||
|
text-align: center;
|
||||||
|
padding-top: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { onPullDownRefresh, onReachBottom, onShow } from "@dcloudio/uni-app";
|
||||||
|
import { getFavorites } from "@/api/favorite";
|
||||||
|
import { extractList } from "@/utils/request";
|
||||||
|
import { checkLogin } from "@/utils/auth";
|
||||||
|
import SpotCard from "@/components/spot-card/spot-card.vue";
|
||||||
|
|
||||||
|
onShow(() => { checkLogin(); });
|
||||||
|
|
||||||
|
const favorites = ref([]);
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = 10;
|
||||||
|
const hasMore = ref(true);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const fetchFavorites = async (reset = false) => {
|
||||||
|
if (loading.value) return;
|
||||||
|
if (!reset && !hasMore.value) return;
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
if (reset) {
|
||||||
|
page.value = 1;
|
||||||
|
hasMore.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getFavorites({ page: page.value, page_size: pageSize });
|
||||||
|
const list = extractList(res);
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
|
favorites.value = list;
|
||||||
|
} else {
|
||||||
|
favorites.value.push(...list);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (list.length < pageSize) {
|
||||||
|
hasMore.value = false;
|
||||||
|
} else {
|
||||||
|
page.value++;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goDetail = (item) => {
|
||||||
|
const id = item.spot_id || item.spot?.id || item.id;
|
||||||
|
if (id) {
|
||||||
|
uni.navigateTo({ url: `/pages/spot/detail?id=${id}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onPullDownRefresh(async () => {
|
||||||
|
await fetchFavorites(true);
|
||||||
|
uni.stopPullDownRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
onReachBottom(() => {
|
||||||
|
fetchFavorites();
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchFavorites(true);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="favorites-page">
|
||||||
|
<view class="list">
|
||||||
|
<SpotCard
|
||||||
|
v-for="item in favorites"
|
||||||
|
:key="item.id"
|
||||||
|
:spot="item.spot || item"
|
||||||
|
@click="goDetail(item)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<view v-if="loading" class="status-tip">
|
||||||
|
<text>加载中...</text>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="!hasMore && favorites.length > 0" class="status-tip">
|
||||||
|
<text>没有更多了</text>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="!loading && favorites.length === 0" class="empty-state">
|
||||||
|
<uni-icons type="heart-filled" size="48" color="#6366f1" class="empty-icon" />
|
||||||
|
<text class="empty-title">暂无收藏</text>
|
||||||
|
<text class="empty-desc">去发现页浏览并收藏喜欢的取景地吧</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.favorites-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f6fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tip {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40rpx 0;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 160rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 96rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-desc {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed } from "vue";
|
||||||
|
import { useUserStore } from "@/store/user";
|
||||||
|
import { resolveImageUrl } from "@/utils/image";
|
||||||
|
import { getMyStats } from "@/api/user";
|
||||||
|
import { getUnreadCount } from "@/api/notification";
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const isLoggedIn = computed(() => userStore.isLoggedIn);
|
||||||
|
const user = computed(() => userStore.userInfo);
|
||||||
|
const stats = ref({ spot_count: 0, approved_count: 0, favorite_count: 0, rating_received: 0 });
|
||||||
|
const unreadCount = ref(0);
|
||||||
|
|
||||||
|
const goLogin = () => {
|
||||||
|
uni.navigateTo({ url: "/pages/login/index" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLogout = () => {
|
||||||
|
uni.showModal({
|
||||||
|
title: "提示",
|
||||||
|
content: "确定要退出登录吗?",
|
||||||
|
success: (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
userStore.logout();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{ label: "编辑资料", icon: "gear-filled", action: "profile" },
|
||||||
|
{ label: "我的收藏", icon: "heart-filled", action: "favorites" },
|
||||||
|
{ label: "我的投稿", icon: "location-filled", action: "my-spots" },
|
||||||
|
{ label: "约拍/活动", icon: "calendar-filled", action: "activity-hub" },
|
||||||
|
{ label: "会员中心", icon: "vip-filled", action: "membership" },
|
||||||
|
{ label: "积分概览", icon: "star-filled", action: "points" },
|
||||||
|
{ label: "消息通知", icon: "chat-filled", action: "notifications" },
|
||||||
|
{ label: "设置", icon: "gear", action: "settings" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const handleMenu = (action) => {
|
||||||
|
switch (action) {
|
||||||
|
case "profile":
|
||||||
|
uni.navigateTo({ url: "/pages/mine/profile" });
|
||||||
|
break;
|
||||||
|
case "favorites":
|
||||||
|
uni.navigateTo({ url: "/pages/mine/favorites" });
|
||||||
|
break;
|
||||||
|
case "my-spots":
|
||||||
|
uni.navigateTo({ url: "/pages/mine/my-spots" });
|
||||||
|
break;
|
||||||
|
case "points":
|
||||||
|
uni.navigateTo({ url: "/pages/mine/points" });
|
||||||
|
break;
|
||||||
|
case "activity-hub":
|
||||||
|
uni.switchTab({ url: "/pages/activity/index" });
|
||||||
|
break;
|
||||||
|
case "membership":
|
||||||
|
uni.navigateTo({ url: "/pages/mine/membership" });
|
||||||
|
break;
|
||||||
|
case "notifications":
|
||||||
|
uni.switchTab({ url: "/pages/mine/notifications" });
|
||||||
|
break;
|
||||||
|
case "settings":
|
||||||
|
uni.navigateTo({ url: "/pages/mine/settings" });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
import { onShow } from "@dcloudio/uni-app";
|
||||||
|
|
||||||
|
onShow(async () => {
|
||||||
|
if (isLoggedIn.value) {
|
||||||
|
userStore.fetchUserInfo();
|
||||||
|
try {
|
||||||
|
const s = await getMyStats();
|
||||||
|
stats.value = s;
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
try {
|
||||||
|
const r = await getUnreadCount();
|
||||||
|
unreadCount.value = r.count || 0;
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="mine-page">
|
||||||
|
<view v-if="isLoggedIn" class="user-card">
|
||||||
|
<view class="avatar-area">
|
||||||
|
<image
|
||||||
|
v-if="user?.avatar_url"
|
||||||
|
class="avatar"
|
||||||
|
:src="resolveImageUrl(user.avatar_url)"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<view v-else class="avatar avatar-default">
|
||||||
|
<uni-icons type="person" size="28" color="#ffffff" class="avatar-icon" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="user-info">
|
||||||
|
<text class="nickname">{{ user?.nickname || "加载中..." }}</text>
|
||||||
|
<text v-if="user?.bio" class="user-bio">{{ user.bio }}</text>
|
||||||
|
<view class="user-meta">
|
||||||
|
<view v-if="user?.city" class="meta-item"><uni-icons type="location" size="14" color="rgba(255,255,255,0.8)" /> {{ user.city }}</view>
|
||||||
|
<view v-if="user?.identity" class="identity-badge">
|
||||||
|
<text class="identity-text">{{ user.identity }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="login-card">
|
||||||
|
<view class="login-avatar">
|
||||||
|
<uni-icons type="person" size="28" color="#6366f1" class="login-avatar-icon" />
|
||||||
|
</view>
|
||||||
|
<text class="login-hint">请先登录</text>
|
||||||
|
<button class="login-btn" @tap="goLogin">立即登录</button>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isLoggedIn" class="stats-card">
|
||||||
|
<view class="stat-item" @tap="handleMenu('my-spots')">
|
||||||
|
<text class="stat-num">{{ stats.spot_count }}</text>
|
||||||
|
<text class="stat-label">投稿</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-item" @tap="handleMenu('favorites')">
|
||||||
|
<text class="stat-num">{{ stats.favorite_count }}</text>
|
||||||
|
<text class="stat-label">收藏</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-num">{{ stats.rating_received }}</text>
|
||||||
|
<text class="stat-label">获赞</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-num">{{ stats.approved_count }}</text>
|
||||||
|
<text class="stat-label">通过</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="menu-card">
|
||||||
|
<view
|
||||||
|
v-for="(item, idx) in menuItems"
|
||||||
|
:key="idx"
|
||||||
|
class="menu-item"
|
||||||
|
@tap="handleMenu(item.action)"
|
||||||
|
>
|
||||||
|
<view class="menu-left">
|
||||||
|
<uni-icons :type="item.icon" size="22" color="#6366f1" class="menu-icon" />
|
||||||
|
<text class="menu-label">{{ item.label }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="menu-right">
|
||||||
|
<view v-if="item.action === 'notifications' && unreadCount > 0" class="badge">
|
||||||
|
<text class="badge-text">{{ unreadCount > 99 ? '99+' : unreadCount }}</text>
|
||||||
|
</view>
|
||||||
|
<uni-icons type="right" size="16" color="#cbd5e1" class="menu-arrow" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="isLoggedIn" class="logout-area">
|
||||||
|
<button class="logout-btn" @tap="handleLogout">退出登录</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mine-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f6fa;
|
||||||
|
padding: 0 32rpx;
|
||||||
|
padding-top: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-card {
|
||||||
|
background: linear-gradient(135deg, #6366f1, #818cf8);
|
||||||
|
border-radius: 24rpx;
|
||||||
|
padding: 40rpx 32rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-area {
|
||||||
|
margin-right: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 120rpx;
|
||||||
|
height: 120rpx;
|
||||||
|
border-radius: 60rpx;
|
||||||
|
border: 4rpx solid rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-default {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-icon {
|
||||||
|
font-size: 56rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nickname {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-bio {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
max-width: 400rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-item {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-badge {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.identity-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
padding: 60rpx 40rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-avatar {
|
||||||
|
width: 120rpx;
|
||||||
|
height: 120rpx;
|
||||||
|
border-radius: 60rpx;
|
||||||
|
background: #e0e7ff;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-avatar-icon {
|
||||||
|
font-size: 56rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-hint {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #64748b;
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn {
|
||||||
|
width: 320rpx;
|
||||||
|
height: 80rpx;
|
||||||
|
line-height: 80rpx;
|
||||||
|
background: #6366f1;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 40rpx;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-btn::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-card {
|
||||||
|
display: flex;
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
padding: 28rpx 0;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
.stat-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
.stat-num {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
background: #ef4444;
|
||||||
|
min-width: 32rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 8rpx;
|
||||||
|
}
|
||||||
|
.badge-text {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
|
||||||
|
margin-bottom: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 32rpx;
|
||||||
|
border-bottom: 1rpx solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-item:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-icon {
|
||||||
|
font-size: 36rpx;
|
||||||
|
margin-right: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-label {
|
||||||
|
font-size: 30rpx;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-arrow {
|
||||||
|
font-size: 36rpx;
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-area {
|
||||||
|
padding: 16rpx 0 48rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 84rpx;
|
||||||
|
line-height: 84rpx;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: 30rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.logout-btn::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { getMembershipPlans, getMyMembership, purchaseMembership } from "@/api/membership";
|
||||||
|
|
||||||
|
const plans = ref([]);
|
||||||
|
const myMembership = ref(null);
|
||||||
|
const loading = ref(true);
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const [plansRes, myRes] = await Promise.all([
|
||||||
|
getMembershipPlans(),
|
||||||
|
getMyMembership(),
|
||||||
|
]);
|
||||||
|
plans.value = Array.isArray(plansRes) ? plansRes : [];
|
||||||
|
myMembership.value = myRes || null;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handlePurchase(planId) {
|
||||||
|
uni.showModal({
|
||||||
|
title: "确认开通",
|
||||||
|
content: "确认购买该会员方案?",
|
||||||
|
success: async (r) => {
|
||||||
|
if (!r.confirm) return;
|
||||||
|
try {
|
||||||
|
const res = await purchaseMembership(planId);
|
||||||
|
myMembership.value = res;
|
||||||
|
uni.showToast({ title: "开通成功", icon: "success" });
|
||||||
|
} catch (e) {
|
||||||
|
uni.showToast({ title: e.message || "购买失败", icon: "none" });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d) {
|
||||||
|
if (!d) return "";
|
||||||
|
const dt = new Date(d);
|
||||||
|
return `${dt.getFullYear()}-${String(dt.getMonth() + 1).padStart(2, "0")}-${String(dt.getDate()).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysLeft(endDate) {
|
||||||
|
if (!endDate) return 0;
|
||||||
|
const diff = new Date(endDate).getTime() - Date.now();
|
||||||
|
return Math.max(0, Math.ceil(diff / (1000 * 60 * 60 * 24)));
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadData);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="membership-page">
|
||||||
|
<view v-if="loading" class="loading-tip">加载中...</view>
|
||||||
|
<template v-else>
|
||||||
|
<!-- Current membership -->
|
||||||
|
<view v-if="myMembership" class="current-card">
|
||||||
|
<view class="current-header">
|
||||||
|
<uni-icons type="star-filled" size="24" color="#f59e0b" />
|
||||||
|
<text class="current-title">{{ myMembership.plan?.name || '会员' }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="current-info">
|
||||||
|
<view class="current-row">
|
||||||
|
<text class="current-label">到期时间</text>
|
||||||
|
<text class="current-value">{{ formatDate(myMembership.end_date) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="current-row">
|
||||||
|
<text class="current-label">剩余天数</text>
|
||||||
|
<text class="current-value highlight">{{ daysLeft(myMembership.end_date) }}天</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else class="no-member-card">
|
||||||
|
<uni-icons type="star" size="32" color="#d1d5db" />
|
||||||
|
<text class="no-member-text">您还不是会员</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Plans -->
|
||||||
|
<view class="section-title">会员方案</view>
|
||||||
|
<view v-if="plans.length === 0" class="empty-tip">暂无可用方案</view>
|
||||||
|
<view v-for="plan in plans" :key="plan.id" class="plan-card">
|
||||||
|
<view class="plan-header">
|
||||||
|
<text class="plan-name">{{ plan.name }}</text>
|
||||||
|
<text class="plan-price">¥{{ plan.price }}</text>
|
||||||
|
</view>
|
||||||
|
<text v-if="plan.description" class="plan-desc">{{ plan.description }}</text>
|
||||||
|
<view class="plan-meta">
|
||||||
|
<text class="plan-duration">{{ plan.duration_days }}天</text>
|
||||||
|
<text v-if="plan.extra_uploads" class="plan-benefit">+{{ plan.extra_uploads }}上传额度</text>
|
||||||
|
<text v-if="plan.extra_top_count" class="plan-benefit">+{{ plan.extra_top_count }}置顶次数</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="plan.benefits" class="plan-benefits">
|
||||||
|
<text class="benefits-text">{{ plan.benefits }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="plan-action">
|
||||||
|
<view class="purchase-btn" @tap="handlePurchase(plan.id)">
|
||||||
|
{{ myMembership ? '续费' : '立即开通' }}
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.membership-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f6fa;
|
||||||
|
padding: 20rpx;
|
||||||
|
}
|
||||||
|
.loading-tip {
|
||||||
|
text-align: center;
|
||||||
|
padding: 100rpx 0;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.current-card {
|
||||||
|
background: linear-gradient(135deg, #f59e0b, #fbbf24);
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 32rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
.current-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
.current-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.current-info {
|
||||||
|
display: flex;
|
||||||
|
gap: 40rpx;
|
||||||
|
}
|
||||||
|
.current-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
.current-label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
}
|
||||||
|
.current-value {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.current-value.highlight {
|
||||||
|
font-size: 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-member-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 48rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
.no-member-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #9ca3af;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e1e2e;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
padding-left: 8rpx;
|
||||||
|
}
|
||||||
|
.empty-tip {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #9ca3af;
|
||||||
|
padding: 40rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.plan-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
padding: 28rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
.plan-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
.plan-name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e1e2e;
|
||||||
|
}
|
||||||
|
.plan-price {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
.plan-desc {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #6b7280;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
.plan-meta {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
.plan-duration {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #6366f1;
|
||||||
|
background: #eef2ff;
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
.plan-benefit {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #d97706;
|
||||||
|
background: #fef3c7;
|
||||||
|
padding: 4rpx 16rpx;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
}
|
||||||
|
.plan-benefits {
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
}
|
||||||
|
.benefits-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #4b5563;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.plan-action {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.purchase-btn {
|
||||||
|
padding: 14rpx 40rpx;
|
||||||
|
background: #6366f1;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 32rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { onPullDownRefresh, onReachBottom, onShow } from "@dcloudio/uni-app";
|
||||||
|
import { getMySpots, deleteSpot } from "@/api/spot";
|
||||||
|
import { extractList } from "@/utils/request";
|
||||||
|
import { resolveImageUrl } from "@/utils/image";
|
||||||
|
import { checkLogin } from "@/utils/auth";
|
||||||
|
import { formatSpotPrice } from "@/utils/spot";
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
if (checkLogin()) fetchSpots(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
const spots = ref([]);
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = 10;
|
||||||
|
const hasMore = ref(true);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const statusMap = {
|
||||||
|
pending: { text: "待审核", color: "#f59e0b", bg: "rgba(245,158,11,0.1)" },
|
||||||
|
approved: { text: "已通过", color: "#22c55e", bg: "rgba(34,197,94,0.1)" },
|
||||||
|
rejected: { text: "已拒绝", color: "#ef4444", bg: "rgba(239,68,68,0.1)" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatus = (s) => statusMap[s] || { text: s, color: "#94a3b8", bg: "#f5f6fa" };
|
||||||
|
|
||||||
|
const fetchSpots = async (reset = false) => {
|
||||||
|
if (loading.value) return;
|
||||||
|
if (!reset && !hasMore.value) return;
|
||||||
|
|
||||||
|
loading.value = true;
|
||||||
|
if (reset) {
|
||||||
|
page.value = 1;
|
||||||
|
hasMore.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await getMySpots({ page: page.value, page_size: pageSize });
|
||||||
|
const list = extractList(res);
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
|
spots.value = list;
|
||||||
|
} else {
|
||||||
|
spots.value.push(...list);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (list.length < pageSize) {
|
||||||
|
hasMore.value = false;
|
||||||
|
} else {
|
||||||
|
page.value++;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const goDetail = (id) => {
|
||||||
|
uni.navigateTo({ url: `/pages/spot/detail?id=${id}` });
|
||||||
|
};
|
||||||
|
|
||||||
|
const goEdit = (id) => {
|
||||||
|
uni.navigateTo({ url: `/pages/spot/edit?id=${id}` });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (item) => {
|
||||||
|
uni.showModal({
|
||||||
|
title: "确认删除",
|
||||||
|
content: `确定要删除「${item.title}」吗?此操作不可撤销。`,
|
||||||
|
confirmColor: "#ef4444",
|
||||||
|
success: async (res) => {
|
||||||
|
if (res.confirm) {
|
||||||
|
try {
|
||||||
|
await deleteSpot(item.id);
|
||||||
|
uni.showToast({ title: "已删除", icon: "success" });
|
||||||
|
spots.value = spots.value.filter((s) => s.id !== item.id);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onPullDownRefresh(async () => {
|
||||||
|
await fetchSpots(true);
|
||||||
|
uni.stopPullDownRefresh();
|
||||||
|
});
|
||||||
|
|
||||||
|
onReachBottom(() => {
|
||||||
|
fetchSpots();
|
||||||
|
});
|
||||||
|
|
||||||
|
fetchSpots(true);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="my-spots-page">
|
||||||
|
<view class="list">
|
||||||
|
<view
|
||||||
|
v-for="item in spots"
|
||||||
|
:key="item.id"
|
||||||
|
class="spot-card"
|
||||||
|
@tap="goDetail(item.id)"
|
||||||
|
>
|
||||||
|
<image
|
||||||
|
v-if="item.cover_image_url"
|
||||||
|
class="cover"
|
||||||
|
:src="resolveImageUrl(item.cover_image_url)"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
<view v-else class="cover cover-placeholder">
|
||||||
|
<uni-icons type="camera" size="32" color="#94a3b8" class="placeholder-icon" />
|
||||||
|
</view>
|
||||||
|
<view class="info">
|
||||||
|
<view class="title-row">
|
||||||
|
<text class="title">{{ item.title }}</text>
|
||||||
|
<view
|
||||||
|
class="status-badge"
|
||||||
|
:style="{ background: getStatus(item.audit_status).bg }"
|
||||||
|
>
|
||||||
|
<text
|
||||||
|
class="status-text"
|
||||||
|
:style="{ color: getStatus(item.audit_status).color }"
|
||||||
|
>
|
||||||
|
{{ getStatus(item.audit_status).text }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="meta-row">
|
||||||
|
<view class="city"><uni-icons type="location" size="14" color="#64748b" /> {{ item.city || "未知城市" }}</view>
|
||||||
|
<text
|
||||||
|
class="price-text"
|
||||||
|
:class="{ free: formatSpotPrice(item).isFree, paid: !formatSpotPrice(item).isFree }"
|
||||||
|
>
|
||||||
|
{{ formatSpotPrice(item).label }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
v-if="item.audit_status === 'rejected' && item.reject_reason"
|
||||||
|
class="reject-row"
|
||||||
|
>
|
||||||
|
<text class="reject-label">拒绝原因:</text>
|
||||||
|
<text class="reject-reason">{{ item.reject_reason }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="action-row">
|
||||||
|
<view class="action-btn edit-btn" @tap.stop="goEdit(item.id)">
|
||||||
|
<uni-icons type="compose" size="16" color="#6366f1" />
|
||||||
|
<text class="action-text edit-text">编辑</text>
|
||||||
|
</view>
|
||||||
|
<view class="action-btn delete-btn" @tap.stop="handleDelete(item)">
|
||||||
|
<uni-icons type="trash" size="16" color="#ef4444" />
|
||||||
|
<text class="action-text delete-text">删除</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="loading" class="status-tip">
|
||||||
|
<text>加载中...</text>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="!hasMore && spots.length > 0" class="status-tip">
|
||||||
|
<text>没有更多了</text>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="!loading && spots.length === 0" class="empty-state">
|
||||||
|
<uni-icons type="location" size="48" color="#6366f1" class="empty-icon" />
|
||||||
|
<text class="empty-title">还没有投稿地点</text>
|
||||||
|
<text class="empty-desc">去投稿你发现的取景地吧</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.my-spots-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f6fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spot-card {
|
||||||
|
background: #ffffff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover {
|
||||||
|
width: 100%;
|
||||||
|
height: 280rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cover-placeholder {
|
||||||
|
background: #e2e8f0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.placeholder-icon {
|
||||||
|
font-size: 64rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info {
|
||||||
|
padding: 20rpx 24rpx 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
flex: 1;
|
||||||
|
margin-right: 16rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 6rpx 16rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.city {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-text.free {
|
||||||
|
color: #16a34a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-text.paid {
|
||||||
|
color: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reject-row {
|
||||||
|
margin-top: 12rpx;
|
||||||
|
background: rgba(239, 68, 68, 0.06);
|
||||||
|
padding: 16rpx 20rpx;
|
||||||
|
border-radius: 10rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reject-label {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #ef4444;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reject-reason {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #64748b;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 16rpx;
|
||||||
|
margin-top: 16rpx;
|
||||||
|
padding-top: 16rpx;
|
||||||
|
border-top: 1rpx solid #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
padding: 10rpx 24rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-btn {
|
||||||
|
background: rgba(99, 102, 241, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-btn {
|
||||||
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-text {
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-text {
|
||||||
|
color: #ef4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-tip {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40rpx 0;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 160rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 96rpx;
|
||||||
|
margin-bottom: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-desc {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from "vue";
|
||||||
|
import { onPullDownRefresh, onReachBottom, onShow } from "@dcloudio/uni-app";
|
||||||
|
import { getNotifications, markAllRead, markRead } from "@/api/notification";
|
||||||
|
import { extractList } from "@/utils/request";
|
||||||
|
import { checkLogin } from "@/utils/auth";
|
||||||
|
|
||||||
|
onShow(() => { checkLogin(); });
|
||||||
|
|
||||||
|
const items = ref([]);
|
||||||
|
const page = ref(1);
|
||||||
|
const pageSize = 20;
|
||||||
|
const hasMore = ref(true);
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
const typeIcon = {
|
||||||
|
audit: "checkbox-filled",
|
||||||
|
comment: "chat",
|
||||||
|
system: "info",
|
||||||
|
};
|
||||||
|
const typeColor = {
|
||||||
|
audit: "#6366f1",
|
||||||
|
comment: "#3b82f6",
|
||||||
|
system: "#94a3b8",
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchList = async (reset = false) => {
|
||||||
|
if (loading.value) return;
|
||||||
|
if (!reset && !hasMore.value) return;
|
||||||
|
loading.value = true;
|
||||||
|
if (reset) { page.value = 1; hasMore.value = true; }
|
||||||
|
try {
|
||||||
|
const res = await getNotifications({ page: page.value, page_size: pageSize });
|
||||||
|
const list = extractList(res);
|
||||||
|
if (reset) items.value = list; else items.value.push(...list);
|
||||||
|
if (list.length < pageSize) hasMore.value = false; else page.value++;
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
finally { loading.value = false; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTap = async (item) => {
|
||||||
|
if (!item.is_read) {
|
||||||
|
try { await markRead(item.id); item.is_read = true; } catch (e) { /* */ }
|
||||||
|
}
|
||||||
|
if (item.ref_type === "spot" && item.ref_id) {
|
||||||
|
uni.navigateTo({ url: `/pages/spot/detail?id=${item.ref_id}` });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReadAll = async () => {
|
||||||
|
try {
|
||||||
|
await markAllRead();
|
||||||
|
items.value.forEach((n) => (n.is_read = true));
|
||||||
|
uni.showToast({ title: "已全部标记已读", icon: "success" });
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
onPullDownRefresh(async () => { await fetchList(true); uni.stopPullDownRefresh(); });
|
||||||
|
onReachBottom(() => { fetchList(); });
|
||||||
|
|
||||||
|
fetchList(true);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<view class="notification-page">
|
||||||
|
<view class="header-bar">
|
||||||
|
<text class="header-title">消息通知</text>
|
||||||
|
<text class="read-all" @tap="handleReadAll">全部已读</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="list">
|
||||||
|
<view
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.id"
|
||||||
|
class="noti-item"
|
||||||
|
:class="{ unread: !item.is_read }"
|
||||||
|
@tap="handleTap(item)"
|
||||||
|
>
|
||||||
|
<view class="noti-icon" :style="{ background: (typeColor[item.type] || '#94a3b8') + '18' }">
|
||||||
|
<uni-icons :type="typeIcon[item.type] || 'info'" size="20" :color="typeColor[item.type] || '#94a3b8'" />
|
||||||
|
</view>
|
||||||
|
<view class="noti-body">
|
||||||
|
<text class="noti-title">{{ item.title }}</text>
|
||||||
|
<text v-if="item.content" class="noti-content">{{ item.content }}</text>
|
||||||
|
<text class="noti-time">{{ item.created_at?.slice(0, 16).replace('T', ' ') }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="!item.is_read" class="noti-dot" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="loading" class="status-tip"><text>加载中...</text></view>
|
||||||
|
<view v-else-if="!hasMore && items.length > 0" class="status-tip"><text>没有更多了</text></view>
|
||||||
|
<view v-else-if="!loading && items.length === 0" class="empty-state">
|
||||||
|
<uni-icons type="chat" size="48" color="#cbd5e1" />
|
||||||
|
<text class="empty-text">暂无消息</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notification-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f5f6fa;
|
||||||
|
}
|
||||||
|
.header-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 24rpx 32rpx;
|
||||||
|
}
|
||||||
|
.header-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
.read-all {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #6366f1;
|
||||||
|
}
|
||||||
|
.list {
|
||||||
|
padding: 0 32rpx;
|
||||||
|
}
|
||||||
|
.noti-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 20rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 16rpx;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.noti-item.unread {
|
||||||
|
background: #f0f0ff;
|
||||||
|
}
|
||||||
|
.noti-icon {
|
||||||
|
width: 72rpx;
|
||||||
|
height: 72rpx;
|
||||||
|
border-radius: 36rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.noti-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.noti-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6rpx;
|
||||||
|
}
|
||||||
|
.noti-content {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #64748b;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.noti-time {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
.noti-dot {
|
||||||
|
width: 16rpx;
|
||||||
|
height: 16rpx;
|
||||||
|
background: #ef4444;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
position: absolute;
|
||||||
|
top: 28rpx;
|
||||||
|
right: 24rpx;
|
||||||
|
}
|
||||||
|
.status-tip {
|
||||||
|
text-align: center;
|
||||||
|
padding: 40rpx 0;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 26rpx;
|
||||||
|
}
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 160rpx 0;
|
||||||
|
}
|
||||||
|
.empty-text {
|
||||||
|
margin-top: 16rpx;
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user