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
+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(),
],
})