Initial project commit

This commit is contained in:
2026-05-09 16:40:29 +08:00
commit 02b0259a9e
267 changed files with 54891 additions and 0 deletions
+13
View File
@@ -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
View File
@@ -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
+130
View File
@@ -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 |
| 存储 | MinIOS3 兼容) |
| 容器 | Docker ComposeNginx / PostgreSQL / Redis / MinIO |
## Docker Compose 一次部署
项目已支持一次启动后端、管理端、用户端和基础设施:
```bash
docker-compose up -d --build
```
默认访问地址:
- 用户端 H5http://localhost:5173
- 管理前端:http://localhost:5174
- 后端 APIhttp://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、统一错误处理
- 管理端使用 RBACadmin / moderator / user
+4
View File
@@ -0,0 +1,4 @@
node_modules
.npm-cache
dist
.env*
+43
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
shamefully-hoist=true
strict-peer-dependencies=false
+19
View File
@@ -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
+47
View File
@@ -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`.
+7
View File
@@ -0,0 +1,7 @@
import antfu from '@antfu/eslint-config'
export default antfu({
formatters: true,
unocss: true,
vue: true,
})
+18
View File
@@ -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>
+26
View File
@@ -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;
}
}
+6505
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -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"
}
}
+5416
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -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

+1
View File
@@ -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

+1
View File
@@ -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

+5
View File
@@ -0,0 +1,5 @@
<template>
<el-config-provider>
<RouterView />
</el-config-provider>
</template>
+1
View File
@@ -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

+30
View File
@@ -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']
}
}
+127
View File
@@ -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>
+31
View File
@@ -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>
+595
View File
@@ -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',
})
}
+41
View File
@@ -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
}
}
+4
View File
@@ -0,0 +1,4 @@
import { useDark, useToggle } from '@vueuse/core'
export const isDark = useDark()
export const toggleDark = useToggle(isDark)
+1
View File
@@ -0,0 +1 @@
export * from './dark'
+8
View File
@@ -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
}
+96
View File
@@ -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>
+12
View File
@@ -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')
+283
View File
@@ -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
+19
View File
@@ -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>
+84
View File
@@ -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>
+677
View File
@@ -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>
+203
View File
@@ -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>
+280
View File
@@ -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>
+5
View File
@@ -0,0 +1,5 @@
<template>
<div>
Item One
</div>
</template>
+5
View File
@@ -0,0 +1,5 @@
<template>
<div>
Navigation 2
</div>
</template>
+5
View File
@@ -0,0 +1,5 @@
<template>
<div>
Navigation 4
</div>
</template>
+739
View File
@@ -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>
+311
View File
@@ -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>
+989
View File
@@ -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>
+880
View File
@@ -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>
+308
View File
@@ -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>
+53
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
// only scss variables
$--colors: (
'primary': (
'base': #589ef8,
),
);
@forward 'element-plus/theme-chalk/src/dark/var.scss' with (
$colors: $--colors
);
+42
View File
@@ -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';
+342
View File
@@ -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;
}
}
+26
View File
@@ -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>>,
}
}
+3
View File
@@ -0,0 +1,3 @@
import type { ViteSSGContext } from 'vite-ssg'
export type UserModule = (ctx: ViteSSGContext) => void
+26
View File
@@ -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"]
}
+37
View File
@@ -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(' '),
})
+37
View File
@@ -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(),
],
})
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
unpackage
.npm-cache
.env*
+44
View File
@@ -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>
+17
View File
@@ -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
+14
View File
@@ -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);
+6
View File
@@ -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);
+7
View File
@@ -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);
+40
View File
@@ -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" });
+7
View File
@@ -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}`);
+10
View File
@@ -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 } });
+9
View File
@@ -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`);
+4
View File
@@ -0,0 +1,4 @@
import { get } from "@/utils/request";
export const getMyPoints = () => get("/points/me");
export const getMyPointRecords = (params) => get("/points/me/records", params);
+7
View File
@@ -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 } });
+4
View File
@@ -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);
+3
View File
@@ -0,0 +1,3 @@
import { get } from "@/utils/request";
export const searchSpots = (params) => get("/search", params);
+46
View File
@@ -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",
});
+36
View File
@@ -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),
});
});
};
+5
View File
@@ -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}`);
+14
View File
@@ -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>
+68
View File
@@ -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>
+129
View File
@@ -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>
+75
View File
@@ -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>
+20
View File
@@ -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>
+27
View File
@@ -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
+87
View File
@@ -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"
}
}
}
}
}
+26
View File
@@ -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;
}
}
+7469
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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"
}
}
+228
View File
@@ -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": {}
}
+427
View File
@@ -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>
+297
View File
@@ -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>
+485
View File
@@ -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>
+306
View File
@@ -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>
+291
View File
@@ -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
+301
View File
@@ -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>
+133
View File
@@ -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>
+403
View File
@@ -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>
+260
View File
@@ -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>
+355
View File
@@ -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>
+196
View File
@@ -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