Initial project commit
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
node_modules
|
||||
.npm-cache
|
||||
dist
|
||||
.env*
|
||||
@@ -0,0 +1,43 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnp/
|
||||
.pnp.*
|
||||
|
||||
# Package manager caches
|
||||
.npm-cache/
|
||||
.pnpm-store/
|
||||
.yarn/*
|
||||
!.yarn/releases/
|
||||
!.yarn/plugins/
|
||||
!.yarn/patches/
|
||||
!.yarn/sdks/
|
||||
|
||||
# Build output
|
||||
dist/
|
||||
dist-ssr/
|
||||
.vite/
|
||||
.vite-ssg-temp/
|
||||
.temp/
|
||||
.tmp/
|
||||
.cache/
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.*.example
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# IDE / OS
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
Desktop.ini
|
||||
@@ -0,0 +1,2 @@
|
||||
shamefully-hoist=true
|
||||
strict-peer-dependencies=false
|
||||
@@ -0,0 +1,19 @@
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ARG VITE_API_BASE=/api/v1
|
||||
ENV VITE_API_BASE=${VITE_API_BASE}
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.27-alpine
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
@@ -0,0 +1,47 @@
|
||||
# element-plus-vite-starter
|
||||
|
||||
> A starter kit for Element Plus with Vite
|
||||
|
||||
- Preview: <https://vite-starter.element-plus.org>
|
||||
|
||||
This is an example of on-demand element-plus with [unplugin-vue-components](https://github.com/antfu/unplugin-vue-components).
|
||||
|
||||
> If you want to import all, it may be so simple that no examples are needed. Just follow [quickstart | Docs](https://element-plus.org/zh-CN/guide/quickstart.html) and import them.
|
||||
|
||||
If you just want an on-demand import example `manually`, you can check [unplugin-element-plus/examples/vite](https://github.com/element-plus/unplugin-element-plus/tree/main/examples/vite).
|
||||
|
||||
If you want to a nuxt starter, see [element-plus-nuxt-starter](https://github.com/element-plus/element-plus-nuxt-starter/).
|
||||
|
||||
## Project setup
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
|
||||
# npm install
|
||||
# yarn install
|
||||
```
|
||||
|
||||
### Compiles and hot-reloads for development
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Compiles and minifies for production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
git clone https://github.com/element-plus/element-plus-vite-starter
|
||||
cd element-plus-vite-starter
|
||||
npm i
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Custom theme
|
||||
|
||||
See `src/styles/element/index.scss`.
|
||||
@@ -0,0 +1,7 @@
|
||||
import antfu from '@antfu/eslint-config'
|
||||
|
||||
export default antfu({
|
||||
formatters: true,
|
||||
unocss: true,
|
||||
vue: true,
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Element Plus Vite Starter</title>
|
||||
<!-- element css cdn, if you use custom theme, remove it. -->
|
||||
<!-- <link
|
||||
rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/npm/element-plus/dist/index.css"
|
||||
/> -->
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,26 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://server:8000/api/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location /uploads/ {
|
||||
proxy_pass http://server:8000/uploads/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+6505
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"name": "element-plus-vite-starter",
|
||||
"type": "module",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.14.0",
|
||||
"license": "MIT",
|
||||
"homepage": "https://vite-starter.element-plus.org",
|
||||
"repository": {
|
||||
"url": "https://github.com/element-plus/element-plus-vite-starter"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/icons-vue": "^2.3.1",
|
||||
"@vueuse/core": "^13.6.0",
|
||||
"element-plus": "^2.10.5",
|
||||
"vue": "^3.5.18",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@antfu/eslint-config": "^5.1.0",
|
||||
"@types/node": "^24.1.0",
|
||||
"@vitejs/plugin-vue": "^5.2.4",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-plugin-format": "^1.0.1",
|
||||
"sass": "^1.89.2",
|
||||
"typescript": "^5.9.2",
|
||||
"vite": "^5.4.11",
|
||||
"vue-tsc": "^3.0.5"
|
||||
}
|
||||
}
|
||||
Generated
+5416
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
vite-starter.element-plus.org
|
||||
@@ -0,0 +1 @@
|
||||
<svg id="图层_1" data-name="图层 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44"><defs><style>.cls-1{fill:#409eff;fill-rule:evenodd;}</style></defs><title>element plus-logo-small 副本</title><path id="element_plus-logo-small" data-name="element plus-logo-small" class="cls-1" d="M37.41,32.37c0,1.57-.83,1.93-.83,1.93L21.51,43A1.69,1.69,0,0,1,20,43S5.2,34.4,4.66,34a1.29,1.29,0,0,1-.55-1V15.24c0-.78,1-1.33,1-1.33L19.86,5.36a2,2,0,0,1,1.79,0l14.46,8.41a2.06,2.06,0,0,1,1.25,2.06V32.37Zm-5.9-17L21.35,9.5a1.59,1.59,0,0,0-1.41,0L8.33,16.15s-.77.46-.76,1.08,0,13.92,0,13.92A1,1,0,0,0,8,31.9c.43.3,12,7,12,7a1.31,1.31,0,0,0,1.19,0C21.91,38.5,33,32.11,33,32.11s.65-.28.65-1.51V27.13l-13,7.9V32a3.05,3.05,0,0,1,1-2.07L33.2,23a2.44,2.44,0,0,0,.55-1.46V18.43L20.64,26.35v-3.2a2.22,2.22,0,0,1,.83-1.79ZM41.07,4.22a.39.39,0,0,0-.37-.42H38V1.06c0-.16-.26-.22-.53-.22L36,1.08c-.18,0-.31.12-.31.23V3.8H33a.4.4,0,0,0-.36.37v2h3V9c0,.16.26.27.54.23l1.51-.25c.18,0,.29-.13.29-.23V6.14h3Z"/></svg>
|
||||
|
After Width: | Height: | Size: 995 B |
@@ -0,0 +1 @@
|
||||
<svg id="图层_1" data-name="图层 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 44 44"><defs><style>.cls-1{fill:#409eff;fill-rule:evenodd;}</style></defs><title>element plus-logo-small 副本</title><path id="element_plus-logo-small" data-name="element plus-logo-small" class="cls-1" d="M37.41,32.37c0,1.57-.83,1.93-.83,1.93L21.51,43A1.69,1.69,0,0,1,20,43S5.2,34.4,4.66,34a1.29,1.29,0,0,1-.55-1V15.24c0-.78,1-1.33,1-1.33L19.86,5.36a2,2,0,0,1,1.79,0l14.46,8.41a2.06,2.06,0,0,1,1.25,2.06V32.37Zm-5.9-17L21.35,9.5a1.59,1.59,0,0,0-1.41,0L8.33,16.15s-.77.46-.76,1.08,0,13.92,0,13.92A1,1,0,0,0,8,31.9c.43.3,12,7,12,7a1.31,1.31,0,0,0,1.19,0C21.91,38.5,33,32.11,33,32.11s.65-.28.65-1.51V27.13l-13,7.9V32a3.05,3.05,0,0,1,1-2.07L33.2,23a2.44,2.44,0,0,0,.55-1.46V18.43L20.64,26.35v-3.2a2.22,2.22,0,0,1,.83-1.79ZM41.07,4.22a.39.39,0,0,0-.37-.42H38V1.06c0-.16-.26-.22-.53-.22L36,1.08c-.18,0-.31.12-.31.23V3.8H33a.4.4,0,0,0-.36.37v2h3V9c0,.16.26.27.54.23l1.51-.25c.18,0,.29-.13.29-.23V6.14h3Z"/></svg>
|
||||
|
After Width: | Height: | Size: 995 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<el-config-provider>
|
||||
<RouterView />
|
||||
</el-config-provider>
|
||||
</template>
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 497 B |
Vendored
+30
@@ -0,0 +1,30 @@
|
||||
/* eslint-disable */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-components
|
||||
// Read more: https://github.com/vuejs/core/pull/3399
|
||||
// biome-ignore lint: disable
|
||||
export {}
|
||||
|
||||
/* prettier-ignore */
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
BaseHeader: typeof import('./components/layouts/BaseHeader.vue')['default']
|
||||
BaseSide: typeof import('./components/layouts/BaseSide.vue')['default']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElConfigProvider: typeof import('element-plus/es')['ElConfigProvider']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
ElIcon: typeof import('element-plus/es')['ElIcon']
|
||||
ElInput: typeof import('element-plus/es')['ElInput']
|
||||
ElMenu: typeof import('element-plus/es')['ElMenu']
|
||||
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
|
||||
ElMenuItemGroup: typeof import('element-plus/es')['ElMenuItemGroup']
|
||||
ElSubMenu: typeof import('element-plus/es')['ElSubMenu']
|
||||
ElSwitch: typeof import('element-plus/es')['ElSwitch']
|
||||
ElTag: typeof import('element-plus/es')['ElTag']
|
||||
HelloWorld: typeof import('./components/HelloWorld.vue')['default']
|
||||
Logos: typeof import('./components/Logos.vue')['default']
|
||||
MessageBoxDemo: typeof import('./components/MessageBoxDemo.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
RouterView: typeof import('vue-router')['RouterView']
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { ref } from 'vue'
|
||||
|
||||
defineProps<{ msg: string }>()
|
||||
|
||||
const count = ref(0)
|
||||
const input = ref('element-plus')
|
||||
|
||||
const curDate = ref('')
|
||||
|
||||
function toast() {
|
||||
ElMessage.success('Hello')
|
||||
}
|
||||
|
||||
const value1 = ref(true)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1 color="$ep-color-primary">
|
||||
{{ msg }}
|
||||
</h1>
|
||||
|
||||
<p>
|
||||
See
|
||||
<a href="https://element-plus.org" target="_blank">element-plus</a> for more
|
||||
information.
|
||||
</p>
|
||||
|
||||
<!-- example components -->
|
||||
<div class="mb-4">
|
||||
<el-button size="large" @click="toast">
|
||||
El Message
|
||||
</el-button>
|
||||
|
||||
<MessageBoxDemo />
|
||||
</div>
|
||||
|
||||
<div class="my-2 flex flex-wrap items-center justify-center text-center">
|
||||
<el-button @click="count++">
|
||||
count is: {{ count }}
|
||||
</el-button>
|
||||
<el-button type="primary" @click="count++">
|
||||
count is: {{ count }}
|
||||
</el-button>
|
||||
<el-button type="success" @click="count++">
|
||||
count is: {{ count }}
|
||||
</el-button>
|
||||
<el-button type="warning" @click="count++">
|
||||
count is: {{ count }}
|
||||
</el-button>
|
||||
<el-button type="danger" @click="count++">
|
||||
count is: {{ count }}
|
||||
</el-button>
|
||||
<el-button type="info" @click="count++">
|
||||
count is: {{ count }}
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<el-tag type="success" class="m-1">
|
||||
Tag 1
|
||||
</el-tag>
|
||||
<el-tag type="warning" class="m-1">
|
||||
Tag 1
|
||||
</el-tag>
|
||||
<el-tag type="danger" class="m-1">
|
||||
Tag 1
|
||||
</el-tag>
|
||||
<el-tag type="info" class="m-1">
|
||||
Tag 1
|
||||
</el-tag>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<el-switch v-model="value1" />
|
||||
<el-switch
|
||||
v-model="value1"
|
||||
class="m-2"
|
||||
style="--ep-switch-on-color: black; --ep-switch-off-color: gray;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="my-2">
|
||||
<el-input v-model="input" class="m-2" style="width: 200px" />
|
||||
<el-date-picker
|
||||
v-model="curDate"
|
||||
class="m-2"
|
||||
type="date"
|
||||
placeholder="Pick a day"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p>For example, we can custom primary color to 'green'.</p>
|
||||
|
||||
<p>
|
||||
Edit
|
||||
<code>components/HelloWorld.vue</code> to test components.
|
||||
</p>
|
||||
<p>
|
||||
Edit
|
||||
<code>styles/element/var.scss</code> to test scss variables.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
Full Example:
|
||||
<a
|
||||
href="https://github.com/element-plus/element-plus-vite-starter"
|
||||
target="_blank"
|
||||
>element-plus-vite-starter</a>
|
||||
| On demand Example:
|
||||
<a
|
||||
href="https://github.com/element-plus/unplugin-element-plus"
|
||||
target="_blank"
|
||||
>unplugin-element-plus/examples/vite</a>
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.ep-button {
|
||||
margin: 4px;
|
||||
}
|
||||
.ep-button + .ep-button {
|
||||
margin-left: 0;
|
||||
margin: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<template>
|
||||
<div>
|
||||
<a href="https://vitejs.dev" target="_blank">
|
||||
<img src="/vite.svg" class="logo" alt="Vite logo">
|
||||
</a>
|
||||
<a href="https://vuejs.org/" target="_blank">
|
||||
<img src="../assets/vue.svg" class="logo vue" alt="Vue logo">
|
||||
</a>
|
||||
<a href="https://element-plus.org/" target="_blank">
|
||||
<img src="/element-plus-logo-small.svg" class="logo element-plus" alt="Element Plus logo">
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.logo {
|
||||
height: 6em;
|
||||
padding: 1.5em;
|
||||
will-change: filter;
|
||||
transition: filter 300ms;
|
||||
}
|
||||
.logo:hover {
|
||||
filter: drop-shadow(0 0 2em #646cffaa);
|
||||
}
|
||||
.logo.vue:hover {
|
||||
filter: drop-shadow(0 0 2em #42b883aa);
|
||||
}
|
||||
.logo.element-plus:hover {
|
||||
filter: drop-shadow(0 0 2em #409effaa);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts" setup>
|
||||
import type { Action } from 'element-plus'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
function open() {
|
||||
ElMessageBox.alert('This is a message', 'Title', {
|
||||
// if you want to disable its autofocus
|
||||
// autofocus: false,
|
||||
confirmButtonText: 'OK',
|
||||
callback: (action: Action) => {
|
||||
ElMessage({
|
||||
type: 'info',
|
||||
message: `action: ${action}`,
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-button plain @click="open">
|
||||
Click to open the Message Box
|
||||
</el-button>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts" setup>
|
||||
import { repository } from '~/../package.json'
|
||||
|
||||
import { toggleDark } from '~/composables'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-menu class="el-menu-demo" mode="horizontal" :ellipsis="false" router>
|
||||
<el-menu-item index="/">
|
||||
<div class="flex items-center justify-center gap-2">
|
||||
<div class="text-xl" i-ep-element-plus />
|
||||
<span>Element Plus</span>
|
||||
</div>
|
||||
</el-menu-item>
|
||||
<el-sub-menu index="2">
|
||||
<template #title>
|
||||
Workspace
|
||||
</template>
|
||||
<el-menu-item index="2-1">
|
||||
item one
|
||||
</el-menu-item>
|
||||
<el-menu-item index="2-2">
|
||||
item two
|
||||
</el-menu-item>
|
||||
<el-menu-item index="2-3">
|
||||
item three
|
||||
</el-menu-item>
|
||||
<el-sub-menu index="2-4">
|
||||
<template #title>
|
||||
item four
|
||||
</template>
|
||||
<el-menu-item index="2-4-1">
|
||||
item one
|
||||
</el-menu-item>
|
||||
<el-menu-item index="2-4-2">
|
||||
item two
|
||||
</el-menu-item>
|
||||
<el-menu-item index="2-4-3">
|
||||
item three
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</el-sub-menu>
|
||||
<el-menu-item index="3" disabled>
|
||||
Info
|
||||
</el-menu-item>
|
||||
<el-menu-item index="4">
|
||||
Orders
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item h="full" @click="toggleDark()">
|
||||
<button
|
||||
class="w-full cursor-pointer border-none bg-transparent"
|
||||
style="height: var(--ep-menu-item-height)"
|
||||
>
|
||||
<i inline-flex i="dark:ep-moon ep-sunny" />
|
||||
</button>
|
||||
</el-menu-item>
|
||||
|
||||
<el-menu-item h="full">
|
||||
<a class="size-full flex items-center justify-center" :href="repository.url" target="_blank">
|
||||
<div i-ri-github-fill />
|
||||
</a>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
.el-menu-demo {
|
||||
&.ep-menu--horizontal > .ep-menu-item:nth-child(1) {
|
||||
margin-right: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,85 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
Document,
|
||||
Menu as IconMenu,
|
||||
Location,
|
||||
Setting,
|
||||
} from '@element-plus/icons-vue'
|
||||
|
||||
// const isCollapse = ref(true)
|
||||
function handleOpen(key: string, keyPath: string[]) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(key, keyPath)
|
||||
}
|
||||
function handleClose(key: string, keyPath: string[]) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(key, keyPath)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-menu
|
||||
router
|
||||
default-active="1"
|
||||
class="el-menu-vertical-demo"
|
||||
@open="handleOpen"
|
||||
@close="handleClose"
|
||||
>
|
||||
<el-sub-menu index="1">
|
||||
<template #title>
|
||||
<el-icon>
|
||||
<Location />
|
||||
</el-icon>
|
||||
<span>Navigator One</span>
|
||||
</template>
|
||||
<el-menu-item-group>
|
||||
<template #title>
|
||||
<span>Group One</span>
|
||||
</template>
|
||||
<el-menu-item index="/nav/1/item-1">
|
||||
item one
|
||||
</el-menu-item>
|
||||
<el-menu-item index="1-2">
|
||||
item two
|
||||
</el-menu-item>
|
||||
</el-menu-item-group>
|
||||
<el-menu-item-group title="Group Two">
|
||||
<el-menu-item index="1-3">
|
||||
item three
|
||||
</el-menu-item>
|
||||
</el-menu-item-group>
|
||||
<el-sub-menu index="1-4">
|
||||
<template #title>
|
||||
<span>item four</span>
|
||||
</template>
|
||||
<el-menu-item index="1-4-1">
|
||||
item one
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
</el-sub-menu>
|
||||
<el-menu-item index="/nav/2">
|
||||
<el-icon>
|
||||
<IconMenu />
|
||||
</el-icon>
|
||||
<template #title>
|
||||
Navigator Two
|
||||
</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="3" disabled>
|
||||
<el-icon>
|
||||
<Document />
|
||||
</el-icon>
|
||||
<template #title>
|
||||
Navigator Three
|
||||
</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/nav/4">
|
||||
<el-icon>
|
||||
<Setting />
|
||||
</el-icon>
|
||||
<template #title>
|
||||
Navigator Four
|
||||
</template>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</template>
|
||||
@@ -0,0 +1,595 @@
|
||||
import { clearAdminToken, getAdminToken, setAdminToken, setAdminUser } from './admin-auth'
|
||||
|
||||
const API_BASE
|
||||
= import.meta.env.VITE_API_BASE
|
||||
|| (import.meta.env.DEV ? 'http://127.0.0.1:8000/api/v1' : '/api/v1')
|
||||
|
||||
type RequestError = Error & { status?: number }
|
||||
|
||||
async function request<T = any>(path: string, init: RequestInit = {}) {
|
||||
const token = getAdminToken()
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...(init.headers as Record<string, string> || {}),
|
||||
}
|
||||
if (token)
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
|
||||
let resp: Response
|
||||
try {
|
||||
resp = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
})
|
||||
}
|
||||
catch {
|
||||
throw new Error(`接口连接失败:${API_BASE}${path}`)
|
||||
}
|
||||
|
||||
if (resp.status === 401) {
|
||||
clearAdminToken()
|
||||
throw new Error('登录已失效,请重新登录')
|
||||
}
|
||||
|
||||
const data = await resp.json().catch(() => ({}))
|
||||
if (!resp.ok) {
|
||||
const message = data?.detail || data?.message || `请求失败(${resp.status})`
|
||||
const error = new Error(message) as RequestError
|
||||
error.status = resp.status
|
||||
throw error
|
||||
}
|
||||
return data as T
|
||||
}
|
||||
|
||||
export function isRequestNotFound(error: unknown) {
|
||||
return (error as RequestError)?.status === 404
|
||||
}
|
||||
|
||||
export async function adminLogin(account: string, password: string) {
|
||||
const data = await request<{
|
||||
access_token: string
|
||||
refresh_token: string
|
||||
user: Record<string, any>
|
||||
}>('/admin/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ account, password }),
|
||||
})
|
||||
setAdminToken(data.access_token)
|
||||
setAdminUser(data.user)
|
||||
return data
|
||||
}
|
||||
|
||||
export function adminMe() {
|
||||
return request('/admin/auth/me')
|
||||
}
|
||||
|
||||
export function adminDashboard() {
|
||||
return request('/admin/dashboard')
|
||||
}
|
||||
|
||||
export function adminModuleDesign() {
|
||||
return request('/admin/module-design')
|
||||
}
|
||||
|
||||
export function adminListUsers(params: Record<string, any>) {
|
||||
const q = new URLSearchParams()
|
||||
Object.entries(params || {}).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||
q.set(k, String(v))
|
||||
})
|
||||
return request(`/admin/users?${q.toString()}`)
|
||||
}
|
||||
|
||||
export function adminCreateUser(payload: Record<string, any>) {
|
||||
return request('/admin/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminUpdateUser(userId: number, payload: Record<string, any>) {
|
||||
return request(`/admin/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminDeleteUser(userId: number) {
|
||||
return request(`/admin/users/${userId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function adminUserOptions(keyword = '') {
|
||||
const q = new URLSearchParams()
|
||||
if (keyword.trim())
|
||||
q.set('keyword', keyword.trim())
|
||||
return request(`/admin/user-options?${q.toString()}`)
|
||||
}
|
||||
|
||||
export function adminListSpots(params: Record<string, any>) {
|
||||
const q = new URLSearchParams()
|
||||
Object.entries(params || {}).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||
q.set(k, String(v))
|
||||
})
|
||||
return request(`/admin/spots?${q.toString()}`)
|
||||
}
|
||||
|
||||
export function adminGetSpot(spotId: number) {
|
||||
return request(`/spots/${spotId}`).then((detail: any) => ({
|
||||
id: detail.id,
|
||||
title: detail.title || '',
|
||||
city: detail.city || '',
|
||||
creator_id: detail.creator?.id || detail.creator_id || 0,
|
||||
longitude: detail.longitude ?? 0,
|
||||
latitude: detail.latitude ?? 0,
|
||||
description: detail.description || '',
|
||||
transport: detail.transport || '',
|
||||
best_time: detail.best_time || '',
|
||||
difficulty: detail.difficulty || '',
|
||||
is_free: detail.is_free ?? true,
|
||||
price_min: detail.price_min ?? null,
|
||||
price_max: detail.price_max ?? null,
|
||||
audit_status: detail.audit_status || 'pending',
|
||||
reject_reason: detail.reject_reason || '',
|
||||
tag_ids: (detail.tags || []).map((t: any) => t.id),
|
||||
image_urls: (detail.images || []).map((img: any) => img.image_url),
|
||||
images: detail.images || [],
|
||||
}))
|
||||
}
|
||||
|
||||
export function adminCreateSpot(payload: Record<string, any>) {
|
||||
return request('/admin/spots', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminUpdateSpot(spotId: number, payload: Record<string, any>) {
|
||||
return request(`/admin/spots/${spotId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminDeleteSpot(spotId: number) {
|
||||
return request(`/admin/spots/${spotId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function adminSpotTagOptions(keyword = '') {
|
||||
return request('/tags?sort=hot').then((items: any[]) => {
|
||||
const key = keyword.trim().toLowerCase()
|
||||
const normalized = (items || []).map((item: any) => ({
|
||||
id: item.id,
|
||||
title: item.name || item.title || '',
|
||||
}))
|
||||
if (!key)
|
||||
return normalized
|
||||
return normalized.filter(item => String(item.title).toLowerCase().includes(key))
|
||||
})
|
||||
}
|
||||
|
||||
export function adminAuditSpot(spotId: number, payload: { audit_status: string, reject_reason?: string }) {
|
||||
return request(`/admin/spots/${spotId}/audit`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminBatchAuditSpots(payload: { ids: number[], audit_status: string, reject_reason?: string }) {
|
||||
return request('/admin/spots/batch-audit', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminListEvents(params: Record<string, any>) {
|
||||
const q = new URLSearchParams()
|
||||
Object.entries(params || {}).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||
q.set(k, String(v))
|
||||
})
|
||||
return request(`/admin/events?${q.toString()}`)
|
||||
}
|
||||
|
||||
export function adminGetEvent(eventId: number) {
|
||||
return request(`/admin/events/${eventId}`)
|
||||
}
|
||||
|
||||
export function adminCreateEvent(payload: Record<string, any>) {
|
||||
return request('/admin/events', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminUpdateEvent(eventId: number, payload: Record<string, any>) {
|
||||
return request(`/admin/events/${eventId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminDeleteEvent(eventId: number) {
|
||||
return request(`/admin/events/${eventId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function adminListEventRegistrations(eventId: number) {
|
||||
return request(`/admin/events/${eventId}/registrations`)
|
||||
}
|
||||
|
||||
export function adminCreateEventRegistration(eventId: number, payload: Record<string, any>) {
|
||||
return request(`/admin/events/${eventId}/registrations`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminUpdateEventRegistration(eventId: number, registrationId: number, payload: Record<string, any>) {
|
||||
return request(`/admin/events/${eventId}/registrations/${registrationId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminDeleteEventRegistration(eventId: number, registrationId: number) {
|
||||
return request(`/admin/events/${eventId}/registrations/${registrationId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function adminListEventPhotos(eventId: number) {
|
||||
return request(`/admin/events/${eventId}/photos`)
|
||||
}
|
||||
|
||||
export function adminCreateEventPhoto(eventId: number, payload: Record<string, any>) {
|
||||
return request(`/admin/events/${eventId}/photos`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminUpdateEventPhoto(eventId: number, photoId: number, payload: Record<string, any>) {
|
||||
return request(`/admin/events/${eventId}/photos/${photoId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminDeleteEventPhoto(eventId: number, photoId: number) {
|
||||
return request(`/admin/events/${eventId}/photos/${photoId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function adminAuditEvent(eventId: number, payload: { audit_status: string, reject_reason?: string }) {
|
||||
return request(`/admin/events/${eventId}/audit`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminBatchAuditEvents(payload: { ids: number[], audit_status: string, reject_reason?: string }) {
|
||||
return request('/admin/events/batch-audit', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminListShooting(params: Record<string, any>) {
|
||||
const q = new URLSearchParams()
|
||||
Object.entries(params || {}).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||
q.set(k, String(v))
|
||||
})
|
||||
return request(`/admin/shooting?${q.toString()}`)
|
||||
}
|
||||
|
||||
export function adminGetShooting(requestId: number) {
|
||||
return request(`/admin/shooting/${requestId}`)
|
||||
}
|
||||
|
||||
export function adminCreateShooting(payload: Record<string, any>) {
|
||||
return request('/admin/shooting', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminUpdateShooting(requestId: number, payload: Record<string, any>) {
|
||||
return request(`/admin/shooting/${requestId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminDeleteShooting(requestId: number) {
|
||||
return request(`/admin/shooting/${requestId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function adminListShootingApplications(requestId: number) {
|
||||
return request(`/admin/shooting/${requestId}/applications`)
|
||||
}
|
||||
|
||||
export function adminCreateShootingApplication(requestId: number, payload: Record<string, any>) {
|
||||
return request(`/admin/shooting/${requestId}/applications`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminUpdateShootingApplication(requestId: number, applicationId: number, payload: Record<string, any>) {
|
||||
return request(`/admin/shooting/${requestId}/applications/${applicationId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminDeleteShootingApplication(requestId: number, applicationId: number) {
|
||||
return request(`/admin/shooting/${requestId}/applications/${applicationId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function adminAuditShooting(requestId: number, payload: { audit_status: string, reject_reason?: string }) {
|
||||
return request(`/admin/shooting/${requestId}/audit`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminBatchAuditShooting(payload: { ids: number[], audit_status: string, reject_reason?: string }) {
|
||||
return request('/admin/shooting/batch-audit', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminListAppNavConfigs(params: Record<string, any>) {
|
||||
const q = new URLSearchParams()
|
||||
Object.entries(params || {}).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||
q.set(k, String(v))
|
||||
})
|
||||
return request(`/admin/app-nav-configs?${q.toString()}`)
|
||||
}
|
||||
|
||||
export function adminCreateAppNavConfig(payload: Record<string, any>) {
|
||||
return request('/admin/app-nav-configs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminUpdateAppNavConfig(id: number, payload: Record<string, any>) {
|
||||
return request(`/admin/app-nav-configs/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminDeleteAppNavConfig(id: number) {
|
||||
return request(`/admin/app-nav-configs/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function adminListPromotions(params: Record<string, any>) {
|
||||
const q = new URLSearchParams()
|
||||
Object.entries(params || {}).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||
q.set(k, String(v))
|
||||
})
|
||||
return request(`/admin/promotions?${q.toString()}`)
|
||||
}
|
||||
|
||||
export function adminCreatePromotion(payload: Record<string, any>) {
|
||||
return request('/admin/promotions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminUpdatePromotion(id: number, payload: Record<string, any>) {
|
||||
return request(`/admin/promotions/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminDeletePromotion(id: number) {
|
||||
return request(`/admin/promotions/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function adminPromotionLinkOptions(linkType: 'spot' | 'event' | 'shooting', keyword = '') {
|
||||
const q = new URLSearchParams()
|
||||
q.set('link_type', linkType)
|
||||
if (keyword.trim())
|
||||
q.set('keyword', keyword.trim())
|
||||
return request(`/admin/promotion-link-options?${q.toString()}`)
|
||||
}
|
||||
|
||||
export function adminListMembershipPlans(params: Record<string, any>) {
|
||||
const q = new URLSearchParams()
|
||||
Object.entries(params || {}).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||
q.set(k, String(v))
|
||||
})
|
||||
return request(`/admin/membership/plans?${q.toString()}`)
|
||||
}
|
||||
|
||||
export function adminCreateMembershipPlan(payload: Record<string, any>) {
|
||||
return request('/admin/membership/plans', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminUpdateMembershipPlan(id: number, payload: Record<string, any>) {
|
||||
return request(`/admin/membership/plans/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminDeleteMembershipPlan(id: number) {
|
||||
return request(`/admin/membership/plans/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function adminListUserMemberships(params: Record<string, any>) {
|
||||
const q = new URLSearchParams()
|
||||
Object.entries(params || {}).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||
q.set(k, String(v))
|
||||
})
|
||||
return request(`/admin/membership/user-memberships?${q.toString()}`)
|
||||
}
|
||||
|
||||
export function adminCreateUserMembership(payload: Record<string, any>) {
|
||||
return request('/admin/membership/user-memberships', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminUpdateUserMembership(id: number, payload: Record<string, any>) {
|
||||
return request(`/admin/membership/user-memberships/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminDeleteUserMembership(id: number) {
|
||||
return request(`/admin/membership/user-memberships/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function adminListPointLedger(params: Record<string, any>) {
|
||||
const q = new URLSearchParams()
|
||||
Object.entries(params || {}).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||
q.set(k, String(v))
|
||||
})
|
||||
return request(`/admin/points/ledger?${q.toString()}`)
|
||||
}
|
||||
|
||||
export function adminAdjustPoints(payload: Record<string, any>) {
|
||||
return request('/admin/points/adjust', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminRollbackPointLedger(ledgerId: number) {
|
||||
return request(`/admin/points/ledger/${ledgerId}/rollback`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
export function adminListNotifications(params: Record<string, any>) {
|
||||
const q = new URLSearchParams()
|
||||
Object.entries(params || {}).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||
q.set(k, String(v))
|
||||
})
|
||||
return request(`/admin/notifications?${q.toString()}`)
|
||||
}
|
||||
|
||||
export function adminCreateNotification(payload: Record<string, any>) {
|
||||
return request('/admin/notifications', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminUpdateNotification(id: number, payload: Record<string, any>) {
|
||||
return request(`/admin/notifications/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminDeleteNotification(id: number) {
|
||||
return request(`/admin/notifications/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function adminListAuditLogs(params: Record<string, any>) {
|
||||
const q = new URLSearchParams()
|
||||
Object.entries(params || {}).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||
q.set(k, String(v))
|
||||
})
|
||||
return request(`/admin/audit-logs?${q.toString()}`)
|
||||
}
|
||||
|
||||
export function adminCreateAuditLog(payload: Record<string, any>) {
|
||||
return request('/admin/audit-logs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminListReports(params: Record<string, any>) {
|
||||
const q = new URLSearchParams()
|
||||
Object.entries(params || {}).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||
q.set(k, String(v))
|
||||
})
|
||||
return request(`/admin/reports?${q.toString()}`)
|
||||
}
|
||||
|
||||
export function adminUpdateReport(id: number, payload: Record<string, any>) {
|
||||
return request(`/admin/reports/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminDeleteReport(id: number) {
|
||||
return request(`/admin/reports/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
export function adminListSystemConfigs(params: Record<string, any>) {
|
||||
const q = new URLSearchParams()
|
||||
Object.entries(params || {}).forEach(([k, v]) => {
|
||||
if (v !== undefined && v !== null && String(v).trim() !== '')
|
||||
q.set(k, String(v))
|
||||
})
|
||||
return request(`/admin/system-configs?${q.toString()}`)
|
||||
}
|
||||
|
||||
export function adminCreateSystemConfig(payload: Record<string, any>) {
|
||||
return request('/admin/system-configs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminUpdateSystemConfig(id: number, payload: Record<string, any>) {
|
||||
return request(`/admin/system-configs/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export function adminDeleteSystemConfig(id: number) {
|
||||
return request(`/admin/system-configs/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
const ADMIN_TOKEN_KEY = 'admin_access_token'
|
||||
const ADMIN_USER_KEY = 'admin_user'
|
||||
|
||||
export function getAdminToken() {
|
||||
if (typeof window === 'undefined')
|
||||
return ''
|
||||
return localStorage.getItem(ADMIN_TOKEN_KEY) || ''
|
||||
}
|
||||
|
||||
export function setAdminToken(token: string) {
|
||||
if (typeof window === 'undefined')
|
||||
return
|
||||
localStorage.setItem(ADMIN_TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
export function clearAdminToken() {
|
||||
if (typeof window === 'undefined')
|
||||
return
|
||||
localStorage.removeItem(ADMIN_TOKEN_KEY)
|
||||
localStorage.removeItem(ADMIN_USER_KEY)
|
||||
}
|
||||
|
||||
export function setAdminUser(user: Record<string, any>) {
|
||||
if (typeof window === 'undefined')
|
||||
return
|
||||
localStorage.setItem(ADMIN_USER_KEY, JSON.stringify(user || {}))
|
||||
}
|
||||
|
||||
export function getAdminUser() {
|
||||
if (typeof window === 'undefined')
|
||||
return null
|
||||
const raw = localStorage.getItem(ADMIN_USER_KEY)
|
||||
if (!raw)
|
||||
return null
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { useDark, useToggle } from '@vueuse/core'
|
||||
|
||||
export const isDark = useDark()
|
||||
export const toggleDark = useToggle(isDark)
|
||||
@@ -0,0 +1 @@
|
||||
export * from './dark'
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
|
||||
const component: DefineComponent<object, object, any>
|
||||
export default component
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Bell, DataAnalysis, HelpFilled, Location, Promotion, SetUp, Star, Tickets, User, Warning } from '@element-plus/icons-vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { clearAdminToken, getAdminUser } from '~/composables/admin-auth'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const adminUser = computed(() => getAdminUser() || {})
|
||||
|
||||
const menuItems = [
|
||||
{ key: '/dashboard', label: 'Dashboard', icon: DataAnalysis },
|
||||
{ key: '/spots', label: '取景地管理', icon: Location },
|
||||
{ key: '/events', label: '活动管理', icon: Promotion },
|
||||
{ key: '/shooting', label: '约拍管理', icon: User },
|
||||
{ key: '/users', label: '用户与权限', icon: User },
|
||||
{ key: '/membership', label: '会员体系', icon: Star },
|
||||
{ key: '/ops', label: '消息与风控', icon: Warning },
|
||||
{ key: '/promotions', label: '推广位管理', icon: Promotion },
|
||||
{ key: '/nav-configs', label: '导航配置', icon: SetUp },
|
||||
{ key: '/module-design', label: '模块整合', icon: Tickets },
|
||||
]
|
||||
|
||||
function go(path: string) {
|
||||
if (route.path === path)
|
||||
return
|
||||
router.push(path)
|
||||
}
|
||||
|
||||
function logout() {
|
||||
clearAdminToken()
|
||||
router.replace('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<div class="brand-logo">
|
||||
CV
|
||||
</div>
|
||||
<div class="brand-text">
|
||||
<h1>次元取景器</h1>
|
||||
<p>Admin Console</p>
|
||||
</div>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<button
|
||||
v-for="item in menuItems"
|
||||
:key="item.key"
|
||||
class="nav-item"
|
||||
:class="{ active: route.path === item.key }"
|
||||
@click="go(item.key)"
|
||||
>
|
||||
<span class="nav-icon">
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
</span>
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div class="main">
|
||||
<header class="topbar">
|
||||
<div class="search-wrap">
|
||||
<input class="global-search" placeholder="搜索功能、页面或数据..." />
|
||||
</div>
|
||||
<div class="top-actions">
|
||||
<button class="icon-btn" title="通知">
|
||||
<el-icon><Bell /></el-icon>
|
||||
</button>
|
||||
<button class="icon-btn" title="帮助">
|
||||
<el-icon><HelpFilled /></el-icon>
|
||||
</button>
|
||||
<div class="user-box">
|
||||
<div class="avatar">
|
||||
{{ (adminUser.nickname || 'A').slice(0, 1) }}
|
||||
</div>
|
||||
<div class="user-meta">
|
||||
<strong>{{ adminUser.nickname || '管理员' }}</strong>
|
||||
<small>{{ adminUser.role || 'admin' }}</small>
|
||||
</div>
|
||||
<button class="logout-btn" @click="logout">
|
||||
退出
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="content">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
import '~/styles/index.scss'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,283 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { adminDashboard } from '~/composables/admin-api'
|
||||
|
||||
const loading = ref(false)
|
||||
const stats = ref({
|
||||
spots_total: 0,
|
||||
spots_pending: 0,
|
||||
spots_approved: 0,
|
||||
spots_rejected: 0,
|
||||
users_total: 0,
|
||||
events_total: 0,
|
||||
shooting_total: 0,
|
||||
})
|
||||
|
||||
const trends = ref([
|
||||
{ day: 'Mon', value: 12 },
|
||||
{ day: 'Tue', value: 18 },
|
||||
{ day: 'Wed', value: 15 },
|
||||
{ day: 'Thu', value: 24 },
|
||||
{ day: 'Fri', value: 21 },
|
||||
{ day: 'Sat', value: 17 },
|
||||
{ day: 'Sun', value: 26 },
|
||||
])
|
||||
|
||||
const activities = ref([
|
||||
{ id: 1, text: '审核员通过了 3 条取景地投稿', time: '5 分钟前' },
|
||||
{ id: 2, text: '用户提交了新的活动内容', time: '16 分钟前' },
|
||||
{ id: 3, text: '推广位配置已更新', time: '32 分钟前' },
|
||||
{ id: 4, text: '约拍申请增加 12 条', time: '1 小时前' },
|
||||
])
|
||||
|
||||
const teams = ref([
|
||||
{ id: 1, name: '内容审核组', count: 6 },
|
||||
{ id: 2, name: '运营增长组', count: 4 },
|
||||
{ id: 3, name: '社区支持组', count: 3 },
|
||||
])
|
||||
|
||||
async function loadDashboard() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await adminDashboard()
|
||||
stats.value = data
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '加载 Dashboard 失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadDashboard)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-head">
|
||||
<h2>Dashboard</h2>
|
||||
<p>管理核心指标、审核进度、动态流与团队协作入口</p>
|
||||
</section>
|
||||
|
||||
<section class="kpi-grid">
|
||||
<article class="kpi panel-card">
|
||||
<small>总取景地</small>
|
||||
<h3>{{ stats.spots_total }}</h3>
|
||||
<span>全部内容资产</span>
|
||||
</article>
|
||||
<article class="kpi panel-card">
|
||||
<small>待审核</small>
|
||||
<h3 class="warn">{{ stats.spots_pending }}</h3>
|
||||
<span>需优先处理</span>
|
||||
</article>
|
||||
<article class="kpi panel-card">
|
||||
<small>用户规模</small>
|
||||
<h3>{{ stats.users_total }}</h3>
|
||||
<span>累计注册用户</span>
|
||||
</article>
|
||||
<article class="kpi panel-card">
|
||||
<small>活动 / 约拍</small>
|
||||
<h3>{{ stats.events_total }} / {{ stats.shooting_total }}</h3>
|
||||
<span>社区供给情况</span>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="dash-grid">
|
||||
<article class="chart panel-card">
|
||||
<header>
|
||||
<h4>审核趋势(7天)</h4>
|
||||
<span>单位:条</span>
|
||||
</header>
|
||||
<div class="chart-bars">
|
||||
<div v-for="item in trends" :key="item.day" class="bar-col">
|
||||
<div class="bar" :style="{ height: `${item.value * 3}px` }" />
|
||||
<small>{{ item.day }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article class="activity panel-card">
|
||||
<header>
|
||||
<h4>动态流</h4>
|
||||
</header>
|
||||
<ul>
|
||||
<li v-for="a in activities" :key="a.id">
|
||||
<span>{{ a.text }}</span>
|
||||
<small>{{ a.time }}</small>
|
||||
</li>
|
||||
</ul>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section class="team panel-card">
|
||||
<header>
|
||||
<h4>团队入口</h4>
|
||||
</header>
|
||||
<div class="team-list">
|
||||
<button v-for="t in teams" :key="t.id" class="team-item">
|
||||
<strong>{{ t.name }}</strong>
|
||||
<small>{{ t.count }} 人</small>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kpi-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.kpi {
|
||||
padding: 16px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.kpi small {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.kpi h3 {
|
||||
margin: 10px 0 8px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.kpi h3.warn {
|
||||
color: var(--warn);
|
||||
}
|
||||
|
||||
.kpi span {
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.dash-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.chart,
|
||||
.activity,
|
||||
.team {
|
||||
background: #fff;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
header h4 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
header span {
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.chart-bars {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
.bar-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bar {
|
||||
width: 100%;
|
||||
max-width: 32px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(180deg, #53a5ff, #1677ff);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.bar:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.bar-col small {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.activity ul {
|
||||
margin: 12px 0 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.activity li {
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--border-split);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.activity li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.activity small {
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.team-list {
|
||||
margin-top: 12px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.team-item {
|
||||
height: 78px;
|
||||
border: 1px solid var(--border-base);
|
||||
background: var(--bg-subtle);
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.team-item:hover {
|
||||
border-color: rgba(22, 119, 255, 0.35);
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
}
|
||||
|
||||
.team-item small {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.kpi-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.dash-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.kpi-grid,
|
||||
.team-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getAdminToken } from '~/composables/admin-auth'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
onMounted(() => {
|
||||
if (!getAdminToken()) {
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
router.replace('/spots')
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div />
|
||||
</template>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { adminLogin } from '~/composables/admin-api'
|
||||
import { getAdminToken } from '~/composables/admin-auth'
|
||||
|
||||
const router = useRouter()
|
||||
if (getAdminToken())
|
||||
router.replace('/spots')
|
||||
|
||||
const loading = ref(false)
|
||||
const form = reactive({
|
||||
account: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
async function handleLogin() {
|
||||
if (!form.account.trim() || !form.password.trim()) {
|
||||
ElMessage.error('请输入账号和密码')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
await adminLogin(form.account.trim(), form.password)
|
||||
ElMessage.success('登录成功')
|
||||
router.replace('/spots')
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '登录失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login-page">
|
||||
<el-card class="login-card" shadow="hover">
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>管理后台登录</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-form label-position="top" class="admin-form">
|
||||
<el-form-item label="账号(手机号或邮箱)">
|
||||
<el-input v-model="form.account" clearable placeholder="请输入管理员账号" />
|
||||
</el-form-item>
|
||||
<el-form-item label="密码">
|
||||
<el-input v-model="form.password" type="password" show-password placeholder="请输入密码" @keyup.enter="handleLogin" />
|
||||
</el-form-item>
|
||||
<el-button type="primary" :loading="loading" class="login-btn" @click="handleLogin">
|
||||
登录
|
||||
</el-button>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f3f6fb;
|
||||
}
|
||||
.login-card {
|
||||
width: 420px;
|
||||
max-width: calc(100vw - 32px);
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-split);
|
||||
box-shadow: 0 12px 34px rgba(22, 119, 255, 0.08);
|
||||
}
|
||||
.card-header {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.login-btn {
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,677 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
adminAdjustPoints,
|
||||
adminCreateMembershipPlan,
|
||||
adminCreateUserMembership,
|
||||
adminDeleteMembershipPlan,
|
||||
adminDeleteUserMembership,
|
||||
adminListMembershipPlans,
|
||||
adminListPointLedger,
|
||||
adminRollbackPointLedger,
|
||||
adminListUserMemberships,
|
||||
adminUpdateMembershipPlan,
|
||||
adminUpdateUserMembership,
|
||||
adminUserOptions,
|
||||
} from '~/composables/admin-api'
|
||||
|
||||
const activeTab = ref('plans')
|
||||
|
||||
const plansLoading = ref(false)
|
||||
const plans = ref<any[]>([])
|
||||
const planTotal = ref(0)
|
||||
const planFilters = reactive({ page: 1, page_size: 20, keyword: '', is_active: '' })
|
||||
const planDialogVisible = ref(false)
|
||||
const planSubmitting = ref(false)
|
||||
const editingPlanId = ref<number | null>(null)
|
||||
const planForm = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
duration_days: 30,
|
||||
price: 0,
|
||||
benefits: '',
|
||||
extra_uploads: 0,
|
||||
extra_top_count: 0,
|
||||
is_active: true,
|
||||
sort_order: 0,
|
||||
})
|
||||
|
||||
const userMembershipsLoading = ref(false)
|
||||
const userMemberships = ref<any[]>([])
|
||||
const userMembershipTotal = ref(0)
|
||||
const userMembershipFilters = reactive({ page: 1, page_size: 20, user_id: null as number | null, plan_id: null as number | null, is_active: '' })
|
||||
const userMembershipDialogVisible = ref(false)
|
||||
const userMembershipSubmitting = ref(false)
|
||||
const editingUserMembershipId = ref<number | null>(null)
|
||||
const userMembershipForm = reactive({
|
||||
user_id: null as number | null,
|
||||
plan_id: null as number | null,
|
||||
start_date: '',
|
||||
end_date: '',
|
||||
is_active: true,
|
||||
})
|
||||
const userOptions = ref<Array<{ id: number, title: string }>>([])
|
||||
|
||||
const ledgerLoading = ref(false)
|
||||
const ledgerRows = ref<any[]>([])
|
||||
const ledgerTotal = ref(0)
|
||||
const ledgerFilters = reactive({ page: 1, page_size: 20, user_id: null as number | null, reason: '' })
|
||||
const adjustDialogVisible = ref(false)
|
||||
const adjustSubmitting = ref(false)
|
||||
const adjustForm = reactive({
|
||||
user_id: null as number | null,
|
||||
change: 0,
|
||||
reason: '',
|
||||
})
|
||||
|
||||
async function loadPlans() {
|
||||
plansLoading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = { ...planFilters }
|
||||
if (planFilters.is_active !== '')
|
||||
params.is_active = planFilters.is_active === 'true'
|
||||
const data = await adminListMembershipPlans(params)
|
||||
plans.value = data.items || []
|
||||
planTotal.value = data.total || 0
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '会员套餐加载失败')
|
||||
}
|
||||
finally {
|
||||
plansLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserMemberships() {
|
||||
userMembershipsLoading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = { ...userMembershipFilters }
|
||||
if (!params.user_id)
|
||||
delete params.user_id
|
||||
if (!params.plan_id)
|
||||
delete params.plan_id
|
||||
if (params.is_active !== '')
|
||||
params.is_active = params.is_active === 'true'
|
||||
const data = await adminListUserMemberships(params)
|
||||
userMemberships.value = data.items || []
|
||||
userMembershipTotal.value = data.total || 0
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '用户会员加载失败')
|
||||
}
|
||||
finally {
|
||||
userMembershipsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLedger() {
|
||||
ledgerLoading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = { ...ledgerFilters }
|
||||
if (!params.user_id)
|
||||
delete params.user_id
|
||||
const data = await adminListPointLedger(params)
|
||||
ledgerRows.value = data.items || []
|
||||
ledgerTotal.value = data.total || 0
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '积分流水加载失败')
|
||||
}
|
||||
finally {
|
||||
ledgerLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetPlanForm() {
|
||||
editingPlanId.value = null
|
||||
planForm.name = ''
|
||||
planForm.description = ''
|
||||
planForm.duration_days = 30
|
||||
planForm.price = 0
|
||||
planForm.benefits = ''
|
||||
planForm.extra_uploads = 0
|
||||
planForm.extra_top_count = 0
|
||||
planForm.is_active = true
|
||||
planForm.sort_order = 0
|
||||
}
|
||||
|
||||
function openCreatePlan() {
|
||||
resetPlanForm()
|
||||
planDialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditPlan(row: any) {
|
||||
editingPlanId.value = row.id
|
||||
planForm.name = row.name
|
||||
planForm.description = row.description || ''
|
||||
planForm.duration_days = row.duration_days
|
||||
planForm.price = row.price
|
||||
planForm.benefits = row.benefits || ''
|
||||
planForm.extra_uploads = row.extra_uploads || 0
|
||||
planForm.extra_top_count = row.extra_top_count || 0
|
||||
planForm.is_active = row.is_active
|
||||
planForm.sort_order = row.sort_order || 0
|
||||
planDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitPlan() {
|
||||
planSubmitting.value = true
|
||||
try {
|
||||
const payload = { ...planForm }
|
||||
if (editingPlanId.value) {
|
||||
await adminUpdateMembershipPlan(editingPlanId.value, payload)
|
||||
ElMessage.success('更新成功')
|
||||
}
|
||||
else {
|
||||
await adminCreateMembershipPlan(payload)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
planDialogVisible.value = false
|
||||
loadPlans()
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '保存失败')
|
||||
}
|
||||
finally {
|
||||
planSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function disablePlan(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认停用套餐「${row.name}」吗?`, '停用确认', { type: 'warning' })
|
||||
await adminDeleteMembershipPlan(row.id)
|
||||
ElMessage.success('已停用')
|
||||
loadPlans()
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
async function searchUsers(keyword = '') {
|
||||
userOptions.value = await adminUserOptions(keyword)
|
||||
}
|
||||
|
||||
function resetUserMembershipForm() {
|
||||
editingUserMembershipId.value = null
|
||||
userMembershipForm.user_id = null
|
||||
userMembershipForm.plan_id = null
|
||||
userMembershipForm.start_date = ''
|
||||
userMembershipForm.end_date = ''
|
||||
userMembershipForm.is_active = true
|
||||
}
|
||||
|
||||
async function openCreateUserMembership() {
|
||||
resetUserMembershipForm()
|
||||
userMembershipDialogVisible.value = true
|
||||
await searchUsers()
|
||||
}
|
||||
|
||||
async function openEditUserMembership(row: any) {
|
||||
editingUserMembershipId.value = row.id
|
||||
userMembershipForm.user_id = row.user_id
|
||||
userMembershipForm.plan_id = row.plan_id
|
||||
userMembershipForm.start_date = row.start_date
|
||||
userMembershipForm.end_date = row.end_date
|
||||
userMembershipForm.is_active = row.is_active
|
||||
userMembershipDialogVisible.value = true
|
||||
await searchUsers()
|
||||
}
|
||||
|
||||
async function submitUserMembership() {
|
||||
userMembershipSubmitting.value = true
|
||||
try {
|
||||
const payload = { ...userMembershipForm }
|
||||
if (editingUserMembershipId.value) {
|
||||
await adminUpdateUserMembership(editingUserMembershipId.value, payload)
|
||||
ElMessage.success('更新成功')
|
||||
}
|
||||
else {
|
||||
await adminCreateUserMembership(payload)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
userMembershipDialogVisible.value = false
|
||||
loadUserMemberships()
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '保存失败')
|
||||
}
|
||||
finally {
|
||||
userMembershipSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function disableUserMembership(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认停用用户会员记录 #${row.id} 吗?`, '停用确认', { type: 'warning' })
|
||||
await adminDeleteUserMembership(row.id)
|
||||
ElMessage.success('已停用')
|
||||
loadUserMemberships()
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
async function openAdjustDialog() {
|
||||
adjustForm.user_id = null
|
||||
adjustForm.change = 0
|
||||
adjustForm.reason = ''
|
||||
adjustDialogVisible.value = true
|
||||
await searchUsers()
|
||||
}
|
||||
|
||||
async function submitAdjust() {
|
||||
adjustSubmitting.value = true
|
||||
try {
|
||||
await adminAdjustPoints(adjustForm)
|
||||
ElMessage.success('调账成功')
|
||||
adjustDialogVisible.value = false
|
||||
loadLedger()
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '调账失败')
|
||||
}
|
||||
finally {
|
||||
adjustSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function rollbackLedger(row: any) {
|
||||
if (row.ref_type === 'points_rollback') {
|
||||
ElMessage.error('回滚流水不可再次回滚')
|
||||
return
|
||||
}
|
||||
if (row.rolled_back) {
|
||||
ElMessage.error('该流水已回滚')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认回滚积分流水 #${row.id} 吗?`, '回滚确认', { type: 'warning' })
|
||||
await adminRollbackPointLedger(row.id)
|
||||
ElMessage.success('回滚成功')
|
||||
loadLedger()
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadPlans(), loadUserMemberships(), loadLedger()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-head">
|
||||
<h2>会员体系</h2>
|
||||
<p>套餐管理、用户会员管理、积分流水与调账管理</p>
|
||||
</section>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="会员套餐" name="plans">
|
||||
<el-card shadow="never" class="filter-card panel-card">
|
||||
<el-form inline class="admin-form">
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="planFilters.keyword" placeholder="套餐名称/描述" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="planFilters.is_active" clearable placeholder="全部">
|
||||
<el-option label="启用" value="true" />
|
||||
<el-option label="停用" value="false" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="planFilters.page = 1; loadPlans()">
|
||||
查询
|
||||
</el-button>
|
||||
<el-button @click="planFilters.keyword = ''; planFilters.is_active = ''; planFilters.page = 1; loadPlans()">
|
||||
重置
|
||||
</el-button>
|
||||
<el-button type="primary" plain @click="openCreatePlan">
|
||||
新增套餐
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="panel-card">
|
||||
<el-table :data="plans" v-loading="plansLoading">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="套餐名称" width="160" />
|
||||
<el-table-column prop="duration_days" label="时长(天)" width="100" />
|
||||
<el-table-column prop="price" label="价格" width="100" />
|
||||
<el-table-column prop="extra_uploads" label="额外上传" width="100" />
|
||||
<el-table-column prop="extra_top_count" label="额外置顶" width="100" />
|
||||
<el-table-column prop="sort_order" label="排序" width="80" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" effect="light" round>
|
||||
{{ row.is_active ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="openEditPlan(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link @click="disablePlan(row)">
|
||||
停用
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
:total="planTotal"
|
||||
:page-size="planFilters.page_size"
|
||||
:current-page="planFilters.page"
|
||||
@current-change="(page) => { planFilters.page = page; loadPlans() }"
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="用户会员" name="user-memberships">
|
||||
<el-card shadow="never" class="filter-card panel-card">
|
||||
<el-form inline class="admin-form">
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="用户(筛选)">
|
||||
<el-select
|
||||
v-model="userMembershipFilters.user_id"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
clearable
|
||||
:remote-method="searchUsers"
|
||||
placeholder="输入昵称/手机号搜索"
|
||||
>
|
||||
<el-option v-for="u in userOptions" :key="u.id" :label="u.title" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="套餐(筛选)">
|
||||
<el-select v-model="userMembershipFilters.plan_id" clearable filterable>
|
||||
<el-option v-for="p in plans" :key="p.id" :label="p.name" :value="p.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="userMembershipFilters.is_active" clearable placeholder="全部">
|
||||
<el-option label="启用" value="true" />
|
||||
<el-option label="停用" value="false" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="userMembershipFilters.page = 1; loadUserMemberships()">
|
||||
查询
|
||||
</el-button>
|
||||
<el-button @click="userMembershipFilters.user_id = null; userMembershipFilters.plan_id = null; userMembershipFilters.is_active = ''; userMembershipFilters.page = 1; loadUserMemberships()">
|
||||
重置
|
||||
</el-button>
|
||||
<el-button type="primary" plain @click="openCreateUserMembership">
|
||||
新增记录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="panel-card">
|
||||
<el-table :data="userMemberships" v-loading="userMembershipsLoading">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="user_nickname" label="用户" width="140" />
|
||||
<el-table-column prop="plan_name" label="套餐" width="160" />
|
||||
<el-table-column prop="start_date" label="开始时间" min-width="170" />
|
||||
<el-table-column prop="end_date" label="结束时间" min-width="170" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" effect="light" round>
|
||||
{{ row.is_active ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="openEditUserMembership(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link @click="disableUserMembership(row)">
|
||||
停用
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
:total="userMembershipTotal"
|
||||
:page-size="userMembershipFilters.page_size"
|
||||
:current-page="userMembershipFilters.page"
|
||||
@current-change="(page) => { userMembershipFilters.page = page; loadUserMemberships() }"
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="积分流水" name="points">
|
||||
<el-card shadow="never" class="filter-card panel-card">
|
||||
<el-form inline class="admin-form">
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="用户(筛选)">
|
||||
<el-select
|
||||
v-model="ledgerFilters.user_id"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
clearable
|
||||
:remote-method="searchUsers"
|
||||
placeholder="输入昵称/手机号搜索"
|
||||
>
|
||||
<el-option v-for="u in userOptions" :key="u.id" :label="u.title" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="原因关键词">
|
||||
<el-input v-model="ledgerFilters.reason" placeholder="原因" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="ledgerFilters.page = 1; loadLedger()">
|
||||
查询
|
||||
</el-button>
|
||||
<el-button @click="ledgerFilters.user_id = null; ledgerFilters.reason = ''; ledgerFilters.page = 1; loadLedger()">
|
||||
重置
|
||||
</el-button>
|
||||
<el-button type="primary" plain @click="openAdjustDialog">
|
||||
人工调账
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="panel-card">
|
||||
<el-table :data="ledgerRows" v-loading="ledgerLoading">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="user_nickname" label="用户" width="140" />
|
||||
<el-table-column prop="change" label="变动值" width="100" />
|
||||
<el-table-column prop="balance" label="余额" width="100" />
|
||||
<el-table-column prop="reason" label="原因" min-width="180" />
|
||||
<el-table-column label="回滚状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag
|
||||
:type="row.ref_type === 'points_rollback' ? 'info' : row.rolled_back ? 'warning' : 'success'"
|
||||
effect="light"
|
||||
round
|
||||
>
|
||||
{{ row.ref_type === 'points_rollback' ? '回滚记录' : row.rolled_back ? '已回滚' : '可回滚' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="时间" min-width="170" />
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
type="danger"
|
||||
link
|
||||
:disabled="row.ref_type === 'points_rollback' || row.rolled_back"
|
||||
@click="rollbackLedger(row)"
|
||||
>
|
||||
回滚
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
:total="ledgerTotal"
|
||||
:page-size="ledgerFilters.page_size"
|
||||
:current-page="ledgerFilters.page"
|
||||
@current-change="(page) => { ledgerFilters.page = page; loadLedger() }"
|
||||
/>
|
||||
</div>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog v-model="planDialogVisible" :title="editingPlanId ? '编辑套餐' : '新增套餐'" width="620px">
|
||||
<el-form class="admin-form" label-position="top">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="planForm.name" />
|
||||
</el-form-item>
|
||||
<el-form-item label="价格">
|
||||
<el-input-number v-model="planForm.price" :min="0" :step="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="时长(天)">
|
||||
<el-input-number v-model="planForm.duration_days" :min="1" :max="3650" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="planForm.sort_order" :min="0" :max="999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="额外上传数">
|
||||
<el-input-number v-model="planForm.extra_uploads" :min="0" :max="9999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="额外置顶数">
|
||||
<el-input-number v-model="planForm.extra_top_count" :min="0" :max="9999" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="planForm.description" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item label="权益说明">
|
||||
<el-input v-model="planForm.benefits" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="planForm.is_active" active-text="启用" inactive-text="停用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="planDialogVisible = false">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="planSubmitting" @click="submitPlan">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="userMembershipDialogVisible" :title="editingUserMembershipId ? '编辑用户会员' : '新增用户会员'" width="620px">
|
||||
<el-form class="admin-form" label-position="top">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="用户(筛选选择)">
|
||||
<el-select
|
||||
v-model="userMembershipForm.user_id"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
:remote-method="searchUsers"
|
||||
placeholder="输入昵称/手机号搜索"
|
||||
>
|
||||
<el-option v-for="u in userOptions" :key="u.id" :label="u.title" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="套餐">
|
||||
<el-select v-model="userMembershipForm.plan_id" filterable>
|
||||
<el-option v-for="p in plans" :key="p.id" :label="p.name" :value="p.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="开始时间">
|
||||
<el-date-picker v-model="userMembershipForm.start_date" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" />
|
||||
</el-form-item>
|
||||
<el-form-item label="结束时间">
|
||||
<el-date-picker v-model="userMembershipForm.end_date" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="userMembershipForm.is_active" active-text="启用" inactive-text="停用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="userMembershipDialogVisible = false">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="userMembershipSubmitting" @click="submitUserMembership">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="adjustDialogVisible" title="人工调账" width="520px">
|
||||
<el-form class="admin-form" label-position="top">
|
||||
<el-form-item label="用户(筛选选择)">
|
||||
<el-select
|
||||
v-model="adjustForm.user_id"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
:remote-method="searchUsers"
|
||||
placeholder="输入昵称/手机号搜索"
|
||||
>
|
||||
<el-option v-for="u in userOptions" :key="u.id" :label="u.title" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="积分变动值(可正可负,不能为0)">
|
||||
<el-input-number v-model="adjustForm.change" :min="-100000" :max="100000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="原因">
|
||||
<el-input v-model="adjustForm.reason" placeholder="请输入调整原因" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="adjustDialogVisible = false">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="adjustSubmitting" @click="submitAdjust">
|
||||
确认调账
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.filter-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 12px;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,203 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { adminModuleDesign } from '~/composables/admin-api'
|
||||
|
||||
const loading = ref(false)
|
||||
const data = ref<any>({
|
||||
total_modules: 0,
|
||||
full_covered: 0,
|
||||
partial_covered: 0,
|
||||
missing_covered: 0,
|
||||
items: [],
|
||||
})
|
||||
|
||||
const modules = computed(() => data.value.items || [])
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await adminModuleDesign()
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '模块设计数据加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-head">
|
||||
<h2>模块整合设计</h2>
|
||||
<p>后端模块梳理 + 管理后台 CRUD 覆盖状态,作为迭代开发清单</p>
|
||||
</section>
|
||||
|
||||
<section class="stat-row">
|
||||
<article class="stat panel-card">
|
||||
<span>模块总数</span>
|
||||
<strong>{{ data.total_modules }}</strong>
|
||||
</article>
|
||||
<article class="stat panel-card ok">
|
||||
<span>完整覆盖</span>
|
||||
<strong>{{ data.full_covered }}</strong>
|
||||
</article>
|
||||
<article class="stat panel-card warn">
|
||||
<span>部分覆盖</span>
|
||||
<strong>{{ data.partial_covered }}</strong>
|
||||
</article>
|
||||
<article class="stat panel-card danger">
|
||||
<span>未覆盖</span>
|
||||
<strong>{{ data.missing_covered }}</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<el-skeleton v-if="loading" :rows="8" animated class="panel-card loading-card" />
|
||||
<section v-else class="module-grid">
|
||||
<article v-for="item in modules" :key="item.module_key" class="module-card panel-card">
|
||||
<header>
|
||||
<h4>{{ item.module_name }}</h4>
|
||||
<el-tag :type="item.status === 'full' ? 'success' : item.status === 'partial' ? 'warning' : 'danger'" effect="light" round>
|
||||
{{ item.status }}
|
||||
</el-tag>
|
||||
</header>
|
||||
|
||||
<div class="meta-block">
|
||||
<span class="label">模型</span>
|
||||
<div class="chips">
|
||||
<el-tag v-for="name in item.models" :key="name" size="small" effect="plain">
|
||||
{{ name }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="meta-block">
|
||||
<span class="label">接口前缀</span>
|
||||
<div class="chips">
|
||||
<el-tag v-for="prefix in item.api_endpoint_prefixes" :key="prefix" size="small" type="info" effect="plain">
|
||||
{{ prefix }}
|
||||
</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="crud-line">
|
||||
<span :class="{ on: item.coverage.create }">C</span>
|
||||
<span :class="{ on: item.coverage.read }">R</span>
|
||||
<span :class="{ on: item.coverage.update }">U</span>
|
||||
<span :class="{ on: item.coverage.delete }">D</span>
|
||||
</div>
|
||||
|
||||
<p class="note">
|
||||
{{ item.notes }}
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.stat-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.stat {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.stat span {
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.stat strong {
|
||||
font-size: 24px;
|
||||
}
|
||||
.stat.ok strong {
|
||||
color: var(--ok);
|
||||
}
|
||||
.stat.warn strong {
|
||||
color: var(--warn);
|
||||
}
|
||||
.stat.danger strong {
|
||||
color: var(--danger);
|
||||
}
|
||||
.module-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.module-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
}
|
||||
.module-card header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.module-card h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
.meta-block {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.label {
|
||||
display: inline-block;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.crud-line {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.crud-line span {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-base);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-3);
|
||||
background: var(--bg-subtle);
|
||||
font-weight: 600;
|
||||
}
|
||||
.crud-line span.on {
|
||||
color: #fff;
|
||||
border-color: var(--brand-primary);
|
||||
background: var(--brand-primary);
|
||||
}
|
||||
.note {
|
||||
margin: 12px 0 0;
|
||||
color: var(--text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.loading-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 18px;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.stat-row,
|
||||
.module-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,280 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
adminCreateAppNavConfig,
|
||||
adminDeleteAppNavConfig,
|
||||
adminListAppNavConfigs,
|
||||
adminUpdateAppNavConfig,
|
||||
} from '~/composables/admin-api'
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const total = ref(0)
|
||||
const rows = ref<any[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const filters = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
keyword: '',
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
key: '',
|
||||
label: '',
|
||||
page_path: '',
|
||||
icon: '',
|
||||
active_icon: '',
|
||||
color: '#8a919f',
|
||||
active_color: '#1677ff',
|
||||
is_active: true,
|
||||
sort_order: 0,
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await adminListAppNavConfigs(filters)
|
||||
rows.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '导航配置加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
filters.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
filters.page = page
|
||||
loadData()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
editingId.value = null
|
||||
form.key = ''
|
||||
form.label = ''
|
||||
form.page_path = ''
|
||||
form.icon = ''
|
||||
form.active_icon = ''
|
||||
form.color = '#8a919f'
|
||||
form.active_color = '#1677ff'
|
||||
form.is_active = true
|
||||
form.sort_order = 0
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: any) {
|
||||
editingId.value = row.id
|
||||
form.key = row.key
|
||||
form.label = row.label
|
||||
form.page_path = row.page_path
|
||||
form.icon = row.icon
|
||||
form.active_icon = row.active_icon
|
||||
form.color = row.color
|
||||
form.active_color = row.active_color
|
||||
form.is_active = row.is_active
|
||||
form.sort_order = row.sort_order
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = { ...form }
|
||||
if (editingId.value) {
|
||||
const { key: _key, ...updatePayload } = payload
|
||||
await adminUpdateAppNavConfig(editingId.value, updatePayload)
|
||||
ElMessage.success('更新成功')
|
||||
}
|
||||
else {
|
||||
await adminCreateAppNavConfig(payload)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeItem(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除导航配置「${row.label}」吗?`, '删除确认', { type: 'warning' })
|
||||
await adminDeleteAppNavConfig(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-head">
|
||||
<h2>前端导航配置</h2>
|
||||
<p>管理 uniapp 底栏导航文案、路径、uni-icons 图标和颜色配置</p>
|
||||
</section>
|
||||
|
||||
<el-card shadow="never" class="filter-card panel-card">
|
||||
<el-form inline class="admin-form">
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="filters.keyword" placeholder="key/标题" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="onSearch">
|
||||
查询
|
||||
</el-button>
|
||||
<el-button @click="filters.keyword = ''; onSearch()">
|
||||
重置
|
||||
</el-button>
|
||||
<el-button type="primary" plain @click="openCreate">
|
||||
新增导航
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="panel-card">
|
||||
<el-table :data="rows" v-loading="loading">
|
||||
<el-table-column prop="sort_order" label="排序" width="80" />
|
||||
<el-table-column prop="key" label="Key" width="120" />
|
||||
<el-table-column prop="label" label="标题" width="140" />
|
||||
<el-table-column prop="page_path" label="页面路径" min-width="160" />
|
||||
<el-table-column prop="icon" label="图标" width="140" />
|
||||
<el-table-column prop="active_icon" label="激活图标" width="140" />
|
||||
<el-table-column label="颜色" width="180">
|
||||
<template #default="{ row }">
|
||||
<div class="color-row">
|
||||
<span class="dot" :style="{ background: row.color }" />
|
||||
<span class="dot" :style="{ background: row.active_color }" />
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" effect="light" round>
|
||||
{{ row.is_active ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="openEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link @click="removeItem(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
:total="total"
|
||||
:page-size="filters.page_size"
|
||||
:current-page="filters.page"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑导航配置' : '新增导航配置'" width="560px">
|
||||
<el-form class="admin-form" label-position="top">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="Key(唯一)">
|
||||
<el-input v-model="form.key" :disabled="!!editingId" placeholder="如 home / shooting_square" />
|
||||
</el-form-item>
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="form.label" placeholder="如 首页 / 活动" />
|
||||
</el-form-item>
|
||||
<el-form-item label="页面路径">
|
||||
<el-input v-model="form.page_path" placeholder="/pages/index/index" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort_order" :min="0" :max="999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="默认图标(uni-icons)">
|
||||
<el-input v-model="form.icon" placeholder="home / calendar / chat / location" />
|
||||
</el-form-item>
|
||||
<el-form-item label="激活图标(uni-icons)">
|
||||
<el-input v-model="form.active_icon" placeholder="home-filled / calendar-filled" />
|
||||
</el-form-item>
|
||||
<el-form-item label="默认颜色">
|
||||
<el-input v-model="form.color" placeholder="#8a919f" />
|
||||
</el-form-item>
|
||||
<el-form-item label="激活颜色">
|
||||
<el-input v-model="form.active_color" placeholder="#1677ff" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="form.is_active" active-text="启用" inactive-text="停用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitForm">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.filter-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.color-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.dot {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid #dbe3ec;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 12px;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
Item One
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
Navigation 2
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
Navigation 4
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,739 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
adminCreateSystemConfig,
|
||||
adminCreateAuditLog,
|
||||
adminCreateNotification,
|
||||
adminDeleteSystemConfig,
|
||||
adminDeleteNotification,
|
||||
adminDeleteReport,
|
||||
adminListAuditLogs,
|
||||
adminListNotifications,
|
||||
adminListReports,
|
||||
adminListSystemConfigs,
|
||||
adminUpdateSystemConfig,
|
||||
adminUpdateNotification,
|
||||
adminUpdateReport,
|
||||
adminUserOptions,
|
||||
adminPromotionLinkOptions,
|
||||
} from '~/composables/admin-api'
|
||||
|
||||
const activeTab = ref('notifications')
|
||||
const userOptions = ref<Array<{ id: number, title: string }>>([])
|
||||
const notificationRefOptions = ref<Array<{ id: number, title: string }>>([])
|
||||
|
||||
const notificationsLoading = ref(false)
|
||||
const notifications = ref<any[]>([])
|
||||
const notificationTotal = ref(0)
|
||||
const notificationFilters = reactive({ page: 1, page_size: 20, user_id: null as number | null, type: '', is_read: '' })
|
||||
const notificationDialogVisible = ref(false)
|
||||
const notificationSubmitting = ref(false)
|
||||
const editingNotificationId = ref<number | null>(null)
|
||||
const notificationForm = reactive({
|
||||
user_id: null as number | null,
|
||||
type: 'system',
|
||||
title: '',
|
||||
content: '',
|
||||
ref_type: '',
|
||||
ref_id: null as number | null,
|
||||
is_read: false,
|
||||
})
|
||||
const notificationRefLinkType = ref<'spot' | 'event' | 'shooting' | ''>('')
|
||||
const notificationRefLinkId = ref<number | null>(null)
|
||||
|
||||
const reportsLoading = ref(false)
|
||||
const reports = ref<any[]>([])
|
||||
const reportTotal = ref(0)
|
||||
const reportFilters = reactive({ page: 1, page_size: 20, status: '', target_type: '', reporter_id: null as number | null })
|
||||
const reportDialogVisible = ref(false)
|
||||
const reportSubmitting = ref(false)
|
||||
const editingReportId = ref<number | null>(null)
|
||||
const reportForm = reactive({
|
||||
status: 'processing',
|
||||
handler_id: null as number | null,
|
||||
conclusion: '',
|
||||
})
|
||||
|
||||
const logsLoading = ref(false)
|
||||
const logs = ref<any[]>([])
|
||||
const logTotal = ref(0)
|
||||
const logFilters = reactive({ page: 1, page_size: 20, operator_id: null as number | null, action: '', target_type: '' })
|
||||
const logDialogVisible = ref(false)
|
||||
const logSubmitting = ref(false)
|
||||
const logForm = reactive({
|
||||
action: '',
|
||||
target_type: '',
|
||||
target_id: null as number | null,
|
||||
detail: '',
|
||||
})
|
||||
|
||||
const systemConfigsLoading = ref(false)
|
||||
const systemConfigs = ref<any[]>([])
|
||||
const systemConfigTotal = ref(0)
|
||||
const systemConfigFilters = reactive({ page: 1, page_size: 20, category: '', keyword: '' })
|
||||
const systemConfigDialogVisible = ref(false)
|
||||
const systemConfigSubmitting = ref(false)
|
||||
const editingSystemConfigId = ref<number | null>(null)
|
||||
const systemConfigForm = reactive({
|
||||
config_key: '',
|
||||
category: 'notification_template',
|
||||
title: '',
|
||||
config_json: '{}',
|
||||
description: '',
|
||||
is_active: true,
|
||||
sort_order: 0,
|
||||
})
|
||||
|
||||
async function searchUsers(keyword = '') {
|
||||
userOptions.value = await adminUserOptions(keyword)
|
||||
}
|
||||
|
||||
async function searchNotificationRefOptions(keyword = '') {
|
||||
if (!notificationRefLinkType.value) {
|
||||
notificationRefOptions.value = []
|
||||
return
|
||||
}
|
||||
notificationRefOptions.value = await adminPromotionLinkOptions(notificationRefLinkType.value, keyword)
|
||||
}
|
||||
|
||||
async function loadNotifications() {
|
||||
notificationsLoading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = { ...notificationFilters }
|
||||
if (!params.user_id) delete params.user_id
|
||||
if (params.is_read !== '') params.is_read = params.is_read === 'true'
|
||||
const data = await adminListNotifications(params)
|
||||
notifications.value = data.items || []
|
||||
notificationTotal.value = data.total || 0
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '通知数据加载失败')
|
||||
}
|
||||
finally {
|
||||
notificationsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadReports() {
|
||||
reportsLoading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = { ...reportFilters }
|
||||
if (!params.reporter_id) delete params.reporter_id
|
||||
const data = await adminListReports(params)
|
||||
reports.value = data.items || []
|
||||
reportTotal.value = data.total || 0
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '举报数据加载失败')
|
||||
}
|
||||
finally {
|
||||
reportsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLogs() {
|
||||
logsLoading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = { ...logFilters }
|
||||
if (!params.operator_id) delete params.operator_id
|
||||
const data = await adminListAuditLogs(params)
|
||||
logs.value = data.items || []
|
||||
logTotal.value = data.total || 0
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '审计日志加载失败')
|
||||
}
|
||||
finally {
|
||||
logsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSystemConfigs() {
|
||||
systemConfigsLoading.value = true
|
||||
try {
|
||||
const data = await adminListSystemConfigs(systemConfigFilters)
|
||||
systemConfigs.value = data.items || []
|
||||
systemConfigTotal.value = data.total || 0
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '规则配置加载失败')
|
||||
}
|
||||
finally {
|
||||
systemConfigsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateNotification() {
|
||||
editingNotificationId.value = null
|
||||
notificationForm.user_id = null
|
||||
notificationForm.type = 'system'
|
||||
notificationForm.title = ''
|
||||
notificationForm.content = ''
|
||||
notificationForm.ref_type = ''
|
||||
notificationForm.ref_id = null
|
||||
notificationForm.is_read = false
|
||||
notificationRefLinkType.value = ''
|
||||
notificationRefLinkId.value = null
|
||||
notificationRefOptions.value = []
|
||||
notificationDialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditNotification(row: any) {
|
||||
editingNotificationId.value = row.id
|
||||
notificationForm.user_id = row.user_id
|
||||
notificationForm.type = row.type
|
||||
notificationForm.title = row.title
|
||||
notificationForm.content = row.content || ''
|
||||
notificationForm.ref_type = row.ref_type || ''
|
||||
notificationForm.ref_id = row.ref_id || null
|
||||
notificationForm.is_read = !!row.is_read
|
||||
if (row.ref_type === 'spot' || row.ref_type === 'event' || row.ref_type === 'shooting') {
|
||||
notificationRefLinkType.value = row.ref_type
|
||||
notificationRefLinkId.value = row.ref_id || null
|
||||
searchNotificationRefOptions()
|
||||
}
|
||||
else {
|
||||
notificationRefLinkType.value = ''
|
||||
notificationRefLinkId.value = null
|
||||
notificationRefOptions.value = []
|
||||
}
|
||||
notificationDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitNotification() {
|
||||
notificationSubmitting.value = true
|
||||
try {
|
||||
const payload: Record<string, any> = { ...notificationForm }
|
||||
if (notificationRefLinkType.value) {
|
||||
payload.ref_type = notificationRefLinkType.value
|
||||
payload.ref_id = notificationRefLinkId.value || null
|
||||
}
|
||||
if (!payload.ref_type) payload.ref_type = null
|
||||
if (!payload.ref_id) payload.ref_id = null
|
||||
if (editingNotificationId.value) {
|
||||
await adminUpdateNotification(editingNotificationId.value, payload)
|
||||
ElMessage.success('更新成功')
|
||||
}
|
||||
else {
|
||||
await adminCreateNotification(payload)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
notificationDialogVisible.value = false
|
||||
loadNotifications()
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '保存失败')
|
||||
}
|
||||
finally {
|
||||
notificationSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeNotification(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除通知 #${row.id} 吗?`, '删除确认', { type: 'warning' })
|
||||
await adminDeleteNotification(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadNotifications()
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
function openReportProcess(row: any) {
|
||||
editingReportId.value = row.id
|
||||
reportForm.status = row.status || 'processing'
|
||||
reportForm.handler_id = row.handler_id || null
|
||||
reportForm.conclusion = row.conclusion || ''
|
||||
reportDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitReport() {
|
||||
if (!editingReportId.value) return
|
||||
reportSubmitting.value = true
|
||||
try {
|
||||
await adminUpdateReport(editingReportId.value, { ...reportForm })
|
||||
ElMessage.success('处理成功')
|
||||
reportDialogVisible.value = false
|
||||
loadReports()
|
||||
loadLogs()
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '处理失败')
|
||||
}
|
||||
finally {
|
||||
reportSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeReport(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除举报记录 #${row.id} 吗?`, '删除确认', { type: 'warning' })
|
||||
await adminDeleteReport(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadReports()
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
function openCreateLog() {
|
||||
logForm.action = ''
|
||||
logForm.target_type = ''
|
||||
logForm.target_id = null
|
||||
logForm.detail = ''
|
||||
logDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitLog() {
|
||||
logSubmitting.value = true
|
||||
try {
|
||||
await adminCreateAuditLog({ ...logForm })
|
||||
ElMessage.success('写入成功')
|
||||
logDialogVisible.value = false
|
||||
loadLogs()
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '写入失败')
|
||||
}
|
||||
finally {
|
||||
logSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreateSystemConfig() {
|
||||
editingSystemConfigId.value = null
|
||||
systemConfigForm.config_key = ''
|
||||
systemConfigForm.category = 'notification_template'
|
||||
systemConfigForm.title = ''
|
||||
systemConfigForm.config_json = '{}'
|
||||
systemConfigForm.description = ''
|
||||
systemConfigForm.is_active = true
|
||||
systemConfigForm.sort_order = 0
|
||||
systemConfigDialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEditSystemConfig(row: any) {
|
||||
editingSystemConfigId.value = row.id
|
||||
systemConfigForm.config_key = row.config_key
|
||||
systemConfigForm.category = row.category
|
||||
systemConfigForm.title = row.title
|
||||
systemConfigForm.config_json = row.config_json
|
||||
systemConfigForm.description = row.description || ''
|
||||
systemConfigForm.is_active = row.is_active
|
||||
systemConfigForm.sort_order = row.sort_order || 0
|
||||
systemConfigDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitSystemConfig() {
|
||||
systemConfigSubmitting.value = true
|
||||
try {
|
||||
const payload = { ...systemConfigForm }
|
||||
if (editingSystemConfigId.value) {
|
||||
const { config_key: _config_key, ...updatePayload } = payload
|
||||
await adminUpdateSystemConfig(editingSystemConfigId.value, updatePayload)
|
||||
ElMessage.success('更新成功')
|
||||
}
|
||||
else {
|
||||
await adminCreateSystemConfig(payload)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
systemConfigDialogVisible.value = false
|
||||
loadSystemConfigs()
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '保存失败')
|
||||
}
|
||||
finally {
|
||||
systemConfigSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSystemConfig(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除配置「${row.title}」吗?`, '删除确认', { type: 'warning' })
|
||||
await adminDeleteSystemConfig(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadSystemConfigs()
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([searchUsers(), loadNotifications(), loadReports(), loadLogs(), loadSystemConfigs()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-head">
|
||||
<h2>消息与风控</h2>
|
||||
<p>通知消息、举报处理、审计日志统一管理</p>
|
||||
</section>
|
||||
|
||||
<el-tabs v-model="activeTab">
|
||||
<el-tab-pane label="通知消息" name="notifications">
|
||||
<el-card shadow="never" class="filter-card panel-card">
|
||||
<el-form inline class="admin-form">
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="用户(筛选)">
|
||||
<el-select v-model="notificationFilters.user_id" filterable remote reserve-keyword clearable :remote-method="searchUsers" placeholder="输入昵称/手机号搜索">
|
||||
<el-option v-for="u in userOptions" :key="u.id" :label="u.title" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-input v-model="notificationFilters.type" placeholder="system/event/shooting..." />
|
||||
</el-form-item>
|
||||
<el-form-item label="已读状态">
|
||||
<el-select v-model="notificationFilters.is_read" clearable placeholder="全部">
|
||||
<el-option label="已读" value="true" />
|
||||
<el-option label="未读" value="false" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="notificationFilters.page = 1; loadNotifications()">
|
||||
查询
|
||||
</el-button>
|
||||
<el-button @click="notificationFilters.user_id = null; notificationFilters.type = ''; notificationFilters.is_read = ''; notificationFilters.page = 1; loadNotifications()">
|
||||
重置
|
||||
</el-button>
|
||||
<el-button type="primary" plain @click="openCreateNotification">
|
||||
新建通知
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="panel-card">
|
||||
<el-table :data="notifications" v-loading="notificationsLoading">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="user_nickname" label="用户" width="140" />
|
||||
<el-table-column prop="type" label="类型" width="120" />
|
||||
<el-table-column prop="title" label="标题" min-width="180" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_read ? 'success' : 'warning'" effect="light" round>
|
||||
{{ row.is_read ? '已读' : '未读' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="时间" min-width="170" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="openEditNotification(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link @click="removeNotification(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="举报处理" name="reports">
|
||||
<el-card shadow="never" class="filter-card panel-card">
|
||||
<el-form inline class="admin-form">
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="reportFilters.status" clearable placeholder="全部">
|
||||
<el-option label="待处理" value="pending" />
|
||||
<el-option label="处理中" value="processing" />
|
||||
<el-option label="已解决" value="resolved" />
|
||||
<el-option label="已驳回" value="rejected" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标类型">
|
||||
<el-input v-model="reportFilters.target_type" placeholder="comment/spot/..." />
|
||||
</el-form-item>
|
||||
<el-form-item label="举报人(筛选)">
|
||||
<el-select v-model="reportFilters.reporter_id" filterable remote reserve-keyword clearable :remote-method="searchUsers" placeholder="输入昵称/手机号搜索">
|
||||
<el-option v-for="u in userOptions" :key="u.id" :label="u.title" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="reportFilters.page = 1; loadReports()">
|
||||
查询
|
||||
</el-button>
|
||||
<el-button @click="reportFilters.status = ''; reportFilters.target_type = ''; reportFilters.reporter_id = null; reportFilters.page = 1; loadReports()">
|
||||
重置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="panel-card">
|
||||
<el-table :data="reports" v-loading="reportsLoading">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="reporter_nickname" label="举报人" width="120" />
|
||||
<el-table-column prop="target_type" label="目标类型" width="120" />
|
||||
<el-table-column prop="target_id" label="目标ID" width="100" />
|
||||
<el-table-column prop="reason" label="原因" min-width="200" />
|
||||
<el-table-column prop="status" label="状态" width="110" />
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="openReportProcess(row)">
|
||||
处理
|
||||
</el-button>
|
||||
<el-button type="danger" link @click="removeReport(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="审计日志" name="audit-logs">
|
||||
<el-card shadow="never" class="filter-card panel-card">
|
||||
<el-form inline class="admin-form">
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="操作人(筛选)">
|
||||
<el-select v-model="logFilters.operator_id" filterable remote reserve-keyword clearable :remote-method="searchUsers" placeholder="输入昵称/手机号搜索">
|
||||
<el-option v-for="u in userOptions" :key="u.id" :label="u.title" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="动作">
|
||||
<el-input v-model="logFilters.action" placeholder="admin_*" />
|
||||
</el-form-item>
|
||||
<el-form-item label="目标类型">
|
||||
<el-input v-model="logFilters.target_type" placeholder="report/notification/..." />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="logFilters.page = 1; loadLogs()">
|
||||
查询
|
||||
</el-button>
|
||||
<el-button @click="logFilters.operator_id = null; logFilters.action = ''; logFilters.target_type = ''; logFilters.page = 1; loadLogs()">
|
||||
重置
|
||||
</el-button>
|
||||
<el-button type="primary" plain @click="openCreateLog">
|
||||
新增日志
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="panel-card">
|
||||
<el-table :data="logs" v-loading="logsLoading">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="operator_nickname" label="操作人" width="120" />
|
||||
<el-table-column prop="action" label="动作" min-width="170" />
|
||||
<el-table-column prop="target_type" label="目标类型" width="120" />
|
||||
<el-table-column prop="target_id" label="目标ID" width="100" />
|
||||
<el-table-column prop="detail" label="详情" min-width="200" />
|
||||
<el-table-column prop="created_at" label="时间" min-width="170" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="模板与规则" name="system-configs">
|
||||
<el-card shadow="never" class="filter-card panel-card">
|
||||
<el-form inline class="admin-form">
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="systemConfigFilters.category" clearable placeholder="全部">
|
||||
<el-option label="通知模板" value="notification_template" />
|
||||
<el-option label="通知规则" value="notification_rule" />
|
||||
<el-option label="举报SOP" value="report_sop" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="systemConfigFilters.keyword" placeholder="key/标题" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="systemConfigFilters.page = 1; loadSystemConfigs()">
|
||||
查询
|
||||
</el-button>
|
||||
<el-button @click="systemConfigFilters.category = ''; systemConfigFilters.keyword = ''; systemConfigFilters.page = 1; loadSystemConfigs()">
|
||||
重置
|
||||
</el-button>
|
||||
<el-button type="primary" plain @click="openCreateSystemConfig">
|
||||
新增配置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="panel-card">
|
||||
<el-table :data="systemConfigs" v-loading="systemConfigsLoading">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="config_key" label="配置键" width="220" />
|
||||
<el-table-column prop="category" label="分类" width="160" />
|
||||
<el-table-column prop="title" label="标题" min-width="160" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" effect="light" round>
|
||||
{{ row.is_active ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="openEditSystemConfig(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link @click="removeSystemConfig(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
<el-dialog v-model="notificationDialogVisible" :title="editingNotificationId ? '编辑通知' : '新建通知'" width="620px">
|
||||
<el-form class="admin-form" label-position="top">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="用户(筛选)">
|
||||
<el-select v-model="notificationForm.user_id" filterable remote reserve-keyword :remote-method="searchUsers" placeholder="输入昵称/手机号搜索">
|
||||
<el-option v-for="u in userOptions" :key="u.id" :label="u.title" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="类型">
|
||||
<el-input v-model="notificationForm.type" placeholder="system/event/shooting..." />
|
||||
</el-form-item>
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="notificationForm.title" />
|
||||
</el-form-item>
|
||||
<el-form-item label="已读">
|
||||
<el-switch v-model="notificationForm.is_read" />
|
||||
</el-form-item>
|
||||
<el-form-item label="引用类型">
|
||||
<el-select v-model="notificationRefLinkType" clearable placeholder="可选(取景地/活动/约拍)" @change="notificationRefLinkId = null; searchNotificationRefOptions()">
|
||||
<el-option label="取景地" value="spot" />
|
||||
<el-option label="活动" value="event" />
|
||||
<el-option label="约拍" value="shooting" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="引用ID">
|
||||
<el-select
|
||||
v-model="notificationRefLinkId"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
clearable
|
||||
:disabled="!notificationRefLinkType"
|
||||
:remote-method="searchNotificationRefOptions"
|
||||
placeholder="先选择引用类型,再搜索对象"
|
||||
>
|
||||
<el-option v-for="item in notificationRefOptions" :key="item.id" :label="item.title" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="内容">
|
||||
<el-input v-model="notificationForm.content" type="textarea" :rows="4" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="notificationDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="notificationSubmitting" @click="submitNotification">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="reportDialogVisible" title="处理举报" width="560px">
|
||||
<el-form class="admin-form" label-position="top">
|
||||
<el-form-item label="处理状态">
|
||||
<el-select v-model="reportForm.status">
|
||||
<el-option label="待处理" value="pending" />
|
||||
<el-option label="处理中" value="processing" />
|
||||
<el-option label="已解决" value="resolved" />
|
||||
<el-option label="已驳回" value="rejected" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="处理人(筛选)">
|
||||
<el-select v-model="reportForm.handler_id" filterable remote reserve-keyword clearable :remote-method="searchUsers">
|
||||
<el-option v-for="u in userOptions" :key="u.id" :label="u.title" :value="u.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="处理结论">
|
||||
<el-input v-model="reportForm.conclusion" type="textarea" :rows="4" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="reportDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="reportSubmitting" @click="submitReport">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="logDialogVisible" title="新增审计日志" width="520px">
|
||||
<el-form class="admin-form" label-position="top">
|
||||
<el-form-item label="动作">
|
||||
<el-input v-model="logForm.action" placeholder="admin_custom_action" />
|
||||
</el-form-item>
|
||||
<el-form-item label="目标类型">
|
||||
<el-input v-model="logForm.target_type" placeholder="report/notification/..." />
|
||||
</el-form-item>
|
||||
<el-form-item label="目标ID">
|
||||
<el-input-number v-model="logForm.target_id" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="详情">
|
||||
<el-input v-model="logForm.detail" type="textarea" :rows="4" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="logDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="logSubmitting" @click="submitLog">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="systemConfigDialogVisible" :title="editingSystemConfigId ? '编辑规则配置' : '新增规则配置'" width="700px">
|
||||
<el-form class="admin-form" label-position="top">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="配置键(唯一)">
|
||||
<el-input v-model="systemConfigForm.config_key" :disabled="!!editingSystemConfigId" />
|
||||
</el-form-item>
|
||||
<el-form-item label="分类">
|
||||
<el-select v-model="systemConfigForm.category">
|
||||
<el-option label="通知模板" value="notification_template" />
|
||||
<el-option label="通知规则" value="notification_rule" />
|
||||
<el-option label="举报SOP" value="report_sop" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="systemConfigForm.title" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="systemConfigForm.sort_order" :min="0" :max="999" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="JSON配置字符串">
|
||||
<el-input v-model="systemConfigForm.config_json" type="textarea" :rows="8" />
|
||||
</el-form-item>
|
||||
<el-form-item label="说明">
|
||||
<el-input v-model="systemConfigForm.description" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="systemConfigForm.is_active" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="systemConfigDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="systemConfigSubmitting" @click="submitSystemConfig">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.filter-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 12px;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,311 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
adminCreatePromotion,
|
||||
adminDeletePromotion,
|
||||
adminListPromotions,
|
||||
adminPromotionLinkOptions,
|
||||
adminUpdatePromotion,
|
||||
} from '~/composables/admin-api'
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const total = ref(0)
|
||||
const rows = ref<any[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const linkOptions = ref<Array<{ id: number, title: string }>>([])
|
||||
const filters = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
keyword: '',
|
||||
position: '',
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
image_url: '',
|
||||
link_type: 'spot',
|
||||
selected_link_id: null as number | null,
|
||||
link_url: '',
|
||||
position: 'home_banner',
|
||||
sort_order: 0,
|
||||
is_active: true,
|
||||
})
|
||||
|
||||
const needsSelector = computed(() => ['spot', 'event', 'shooting'].includes(form.link_type))
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await adminListPromotions(filters)
|
||||
rows.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '推广位数据加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
filters.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
filters.page = page
|
||||
loadData()
|
||||
}
|
||||
|
||||
async function searchLinkOptions(keyword = '') {
|
||||
if (!needsSelector.value)
|
||||
return
|
||||
linkOptions.value = await adminPromotionLinkOptions(form.link_type as any, keyword)
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
editingId.value = null
|
||||
form.title = ''
|
||||
form.image_url = ''
|
||||
form.link_type = 'spot'
|
||||
form.selected_link_id = null
|
||||
form.link_url = ''
|
||||
form.position = 'home_banner'
|
||||
form.sort_order = 0
|
||||
form.is_active = true
|
||||
linkOptions.value = []
|
||||
}
|
||||
|
||||
async function openCreate() {
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
await searchLinkOptions()
|
||||
}
|
||||
|
||||
async function openEdit(row: any) {
|
||||
editingId.value = row.id
|
||||
form.title = row.title
|
||||
form.image_url = row.image_url
|
||||
form.link_type = row.link_type
|
||||
form.selected_link_id = row.link_id || row.spot_id || row.event_id || row.shooting_id || null
|
||||
form.link_url = row.link_url || ''
|
||||
form.position = row.position
|
||||
form.sort_order = row.sort_order
|
||||
form.is_active = row.is_active
|
||||
dialogVisible.value = true
|
||||
if (needsSelector.value)
|
||||
await searchLinkOptions()
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload: Record<string, any> = {
|
||||
title: form.title,
|
||||
image_url: form.image_url,
|
||||
link_type: form.link_type,
|
||||
position: form.position,
|
||||
sort_order: form.sort_order,
|
||||
is_active: form.is_active,
|
||||
}
|
||||
if (needsSelector.value)
|
||||
payload.link_id = form.selected_link_id
|
||||
else
|
||||
payload.link_url = form.link_url
|
||||
|
||||
if (editingId.value) {
|
||||
await adminUpdatePromotion(editingId.value, payload)
|
||||
ElMessage.success('更新成功')
|
||||
}
|
||||
else {
|
||||
await adminCreatePromotion(payload)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeItem(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除推广位「${row.title}」吗?`, '删除确认', { type: 'warning' })
|
||||
await adminDeletePromotion(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-head">
|
||||
<h2>推广位管理</h2>
|
||||
<p>管理 Banner 和运营位,关联对象统一使用筛选器选择</p>
|
||||
</section>
|
||||
|
||||
<el-card shadow="never" class="filter-card panel-card">
|
||||
<el-form inline class="admin-form">
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="filters.keyword" placeholder="标题" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="位置">
|
||||
<el-select v-model="filters.position" clearable placeholder="全部">
|
||||
<el-option label="首页 Banner" value="home_banner" />
|
||||
<el-option label="首页弹层" value="home_popup" />
|
||||
<el-option label="列表顶部" value="list_top" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="onSearch">
|
||||
查询
|
||||
</el-button>
|
||||
<el-button @click="filters.keyword = ''; filters.position = ''; onSearch()">
|
||||
重置
|
||||
</el-button>
|
||||
<el-button type="primary" plain @click="openCreate">
|
||||
新增推广位
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="panel-card">
|
||||
<el-table :data="rows" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="title" label="标题" min-width="180" />
|
||||
<el-table-column prop="position" label="位置" width="120" />
|
||||
<el-table-column prop="link_type" label="关联类型" width="110" />
|
||||
<el-table-column label="关联目标" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.link_id || row.spot_id || row.event_id || row.shooting_id || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort_order" label="排序" width="90" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" effect="light" round>
|
||||
{{ row.is_active ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="openEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link @click="removeItem(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
:total="total"
|
||||
:page-size="filters.page_size"
|
||||
:current-page="filters.page"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑推广位' : '新增推广位'" width="620px">
|
||||
<el-form class="admin-form" label-position="top">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="form.title" placeholder="请输入标题" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图片地址">
|
||||
<el-input v-model="form.image_url" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
<el-form-item label="位置">
|
||||
<el-select v-model="form.position">
|
||||
<el-option label="首页 Banner" value="home_banner" />
|
||||
<el-option label="首页弹层" value="home_popup" />
|
||||
<el-option label="列表顶部" value="list_top" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort_order" :min="0" :max="999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="关联类型">
|
||||
<el-select v-model="form.link_type" @change="form.selected_link_id = null; form.link_url = ''; searchLinkOptions()">
|
||||
<el-option label="取景地" value="spot" />
|
||||
<el-option label="活动" value="event" />
|
||||
<el-option label="约拍" value="shooting" />
|
||||
<el-option label="外链" value="url" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="needsSelector" label="关联对象(筛选选择)">
|
||||
<el-select
|
||||
v-model="form.selected_link_id"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
:remote-method="searchLinkOptions"
|
||||
placeholder="输入关键词搜索"
|
||||
>
|
||||
<el-option v-for="item in linkOptions" :key="item.id" :label="item.title" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item v-else label="外链地址">
|
||||
<el-input v-model="form.link_url" placeholder="https://..." />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="form.is_active" active-text="启用" inactive-text="停用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitForm">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.filter-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 12px;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,989 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
adminAuditShooting,
|
||||
adminBatchAuditShooting,
|
||||
adminCreateShooting,
|
||||
adminCreateShootingApplication,
|
||||
adminDeleteShooting,
|
||||
adminDeleteShootingApplication,
|
||||
adminGetShooting,
|
||||
adminListShooting,
|
||||
adminListShootingApplications,
|
||||
adminPromotionLinkOptions,
|
||||
adminUpdateShooting,
|
||||
adminUpdateShootingApplication,
|
||||
adminUserOptions,
|
||||
isRequestNotFound,
|
||||
} from '~/composables/admin-api'
|
||||
|
||||
type OptionItem = { label: string, value: number }
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const total = ref(0)
|
||||
const rows = ref<any[]>([])
|
||||
const selectedIds = ref<number[]>([])
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailData = ref<any>(null)
|
||||
|
||||
const userOptions = ref<OptionItem[]>([])
|
||||
const creatorOptions = ref<OptionItem[]>([])
|
||||
const spotOptions = ref<OptionItem[]>([])
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const currentRow = ref<any>(null)
|
||||
const auditForm = reactive({
|
||||
audit_status: 'approved',
|
||||
reject_reason: '',
|
||||
})
|
||||
const batchDialogVisible = ref(false)
|
||||
const batchAuditForm = reactive({
|
||||
audit_status: 'approved',
|
||||
reject_reason: '',
|
||||
})
|
||||
|
||||
const formVisible = ref(false)
|
||||
const formMode = ref<'create' | 'edit'>('create')
|
||||
const currentRequestId = ref<number | null>(null)
|
||||
const form = reactive({
|
||||
creator_id: undefined as number | undefined,
|
||||
title: '',
|
||||
city: '',
|
||||
description: '',
|
||||
style: '',
|
||||
shoot_date: '',
|
||||
is_free: false,
|
||||
budget_min: undefined as number | undefined,
|
||||
budget_max: undefined as number | undefined,
|
||||
role_needed: 'photographer',
|
||||
max_applicants: 1,
|
||||
contact_info: '',
|
||||
spot_id: undefined as number | undefined,
|
||||
status: 'open',
|
||||
audit_status: 'pending',
|
||||
reject_reason: '',
|
||||
})
|
||||
|
||||
const appVisible = ref(false)
|
||||
const appRows = ref<any[]>([])
|
||||
const appRequest = ref<any>(null)
|
||||
const appForm = reactive({
|
||||
applicant_id: undefined as number | undefined,
|
||||
message: '',
|
||||
status: 'pending',
|
||||
})
|
||||
const appEditRow = ref<any>(null)
|
||||
|
||||
const filters = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
keyword: '',
|
||||
city: '',
|
||||
role_needed: '',
|
||||
status: '',
|
||||
audit_status: '',
|
||||
})
|
||||
|
||||
const pendingCount = computed(() => rows.value.filter(r => r.audit_status === 'pending').length)
|
||||
const openCount = computed(() => rows.value.filter(r => r.status === 'open').length)
|
||||
const canSubmitRejectReason = computed(() => auditForm.audit_status !== 'rejected' || auditForm.reject_reason.trim().length > 0)
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await adminListShooting(filters)
|
||||
rows.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
const currentIds = new Set(rows.value.map((item: any) => item.id))
|
||||
selectedIds.value = selectedIds.value.filter(id => currentIds.has(id))
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '约拍数据加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserOptions(keyword = '') {
|
||||
try {
|
||||
const items = await adminUserOptions(keyword)
|
||||
userOptions.value = (items || []).map((item: any) => ({ label: `${item.title} (#${item.id})`, value: item.id }))
|
||||
creatorOptions.value = [...userOptions.value]
|
||||
}
|
||||
catch {
|
||||
userOptions.value = []
|
||||
creatorOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSpotOptions(keyword = '') {
|
||||
try {
|
||||
const items = await adminPromotionLinkOptions('spot', keyword)
|
||||
spotOptions.value = (items || []).map((item: any) => ({ label: `${item.title} (#${item.id})`, value: item.id }))
|
||||
}
|
||||
catch {
|
||||
spotOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
filters.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
filters.keyword = ''
|
||||
filters.city = ''
|
||||
filters.role_needed = ''
|
||||
filters.status = ''
|
||||
filters.audit_status = ''
|
||||
onSearch()
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
filters.page = page
|
||||
loadData()
|
||||
}
|
||||
|
||||
function openAudit(row: any) {
|
||||
currentRow.value = row
|
||||
auditForm.audit_status = row.audit_status || 'approved'
|
||||
auditForm.reject_reason = row.reject_reason || ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function toggleSelect(row: any) {
|
||||
if (selectedIds.value.includes(row.id))
|
||||
selectedIds.value = selectedIds.value.filter(id => id !== row.id)
|
||||
else
|
||||
selectedIds.value.push(row.id)
|
||||
}
|
||||
|
||||
function openBatchAudit() {
|
||||
if (!selectedIds.value.length) {
|
||||
ElMessage.error('请先勾选要批量审核的数据')
|
||||
return
|
||||
}
|
||||
batchAuditForm.audit_status = 'approved'
|
||||
batchAuditForm.reject_reason = ''
|
||||
batchDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitAudit() {
|
||||
if (!currentRow.value)
|
||||
return
|
||||
if (!canSubmitRejectReason.value) {
|
||||
ElMessage.error('驳回时必须填写驳回原因')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await adminAuditShooting(currentRow.value.id, {
|
||||
audit_status: auditForm.audit_status,
|
||||
reject_reason: auditForm.reject_reason || undefined,
|
||||
})
|
||||
ElMessage.success('约拍审核已更新')
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '提交失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitBatchAudit() {
|
||||
if (batchAuditForm.audit_status === 'rejected' && !batchAuditForm.reject_reason.trim()) {
|
||||
ElMessage.error('批量驳回时必须填写原因')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await adminBatchAuditShooting({
|
||||
ids: [...selectedIds.value],
|
||||
audit_status: batchAuditForm.audit_status,
|
||||
reject_reason: batchAuditForm.reject_reason || undefined,
|
||||
})
|
||||
ElMessage.success(`批量审核完成(${selectedIds.value.length}条)`)
|
||||
batchDialogVisible.value = false
|
||||
selectedIds.value = []
|
||||
loadData()
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '批量审核失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.creator_id = undefined
|
||||
form.title = ''
|
||||
form.city = ''
|
||||
form.description = ''
|
||||
form.style = ''
|
||||
form.shoot_date = ''
|
||||
form.is_free = false
|
||||
form.budget_min = undefined
|
||||
form.budget_max = undefined
|
||||
form.role_needed = 'photographer'
|
||||
form.max_applicants = 1
|
||||
form.contact_info = ''
|
||||
form.spot_id = undefined
|
||||
form.status = 'open'
|
||||
form.audit_status = 'pending'
|
||||
form.reject_reason = ''
|
||||
}
|
||||
|
||||
async function openCreate() {
|
||||
formMode.value = 'create'
|
||||
currentRequestId.value = null
|
||||
resetForm()
|
||||
await Promise.all([loadUserOptions(), loadSpotOptions()])
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
async function openEdit(row: any) {
|
||||
formMode.value = 'edit'
|
||||
currentRequestId.value = row.id
|
||||
submitting.value = true
|
||||
try {
|
||||
const detail = await adminGetShooting(row.id)
|
||||
form.creator_id = detail.creator_id
|
||||
form.title = detail.title || ''
|
||||
form.city = detail.city || ''
|
||||
form.description = detail.description || ''
|
||||
form.style = detail.style || ''
|
||||
form.shoot_date = detail.shoot_date ? String(detail.shoot_date).slice(0, 19) : ''
|
||||
form.is_free = detail.is_free
|
||||
form.budget_min = detail.budget_min ?? undefined
|
||||
form.budget_max = detail.budget_max ?? undefined
|
||||
form.role_needed = detail.role_needed || 'photographer'
|
||||
form.max_applicants = detail.max_applicants || 1
|
||||
form.contact_info = detail.contact_info || ''
|
||||
form.spot_id = detail.spot_id || undefined
|
||||
form.status = detail.status || 'open'
|
||||
form.audit_status = detail.audit_status || 'pending'
|
||||
form.reject_reason = detail.reject_reason || ''
|
||||
await Promise.all([loadUserOptions(), loadSpotOptions()])
|
||||
formVisible.value = true
|
||||
}
|
||||
catch (err: any) {
|
||||
if (isRequestNotFound(err)) {
|
||||
ElMessage.warning('约拍数据不存在或已被删除,列表已刷新')
|
||||
loadData()
|
||||
return
|
||||
}
|
||||
ElMessage.error(err?.message || '加载详情失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(row: any) {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
detailData.value = null
|
||||
try {
|
||||
detailData.value = await adminGetShooting(row.id)
|
||||
}
|
||||
catch (err: any) {
|
||||
if (isRequestNotFound(err)) {
|
||||
detailVisible.value = false
|
||||
ElMessage.warning('约拍数据不存在或已被删除,列表已刷新')
|
||||
loadData()
|
||||
return
|
||||
}
|
||||
ElMessage.error(err?.message || '详情加载失败')
|
||||
}
|
||||
finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
if (!form.creator_id) {
|
||||
ElMessage.error('请选择发布用户')
|
||||
return false
|
||||
}
|
||||
if (!form.title.trim()) {
|
||||
ElMessage.error('请填写标题')
|
||||
return false
|
||||
}
|
||||
if (!form.city.trim()) {
|
||||
ElMessage.error('请填写城市')
|
||||
return false
|
||||
}
|
||||
if (!form.is_free && form.budget_min !== undefined && form.budget_max !== undefined && Number(form.budget_min) > Number(form.budget_max)) {
|
||||
ElMessage.error('预算区间不正确')
|
||||
return false
|
||||
}
|
||||
if (form.audit_status === 'rejected' && !form.reject_reason.trim()) {
|
||||
ElMessage.error('驳回状态必须填写原因')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
return {
|
||||
creator_id: form.creator_id,
|
||||
title: form.title.trim(),
|
||||
city: form.city.trim(),
|
||||
description: form.description?.trim() || null,
|
||||
style: form.style?.trim() || null,
|
||||
shoot_date: form.shoot_date || null,
|
||||
is_free: form.is_free,
|
||||
budget_min: form.is_free ? null : form.budget_min ?? null,
|
||||
budget_max: form.is_free ? null : form.budget_max ?? null,
|
||||
role_needed: form.role_needed,
|
||||
max_applicants: Number(form.max_applicants || 1),
|
||||
contact_info: form.contact_info?.trim() || null,
|
||||
spot_id: form.spot_id || null,
|
||||
status: form.status,
|
||||
audit_status: form.audit_status,
|
||||
reject_reason: form.audit_status === 'rejected' ? (form.reject_reason?.trim() || null) : null,
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (!validateForm())
|
||||
return
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = buildPayload()
|
||||
if (formMode.value === 'create')
|
||||
await adminCreateShooting(payload)
|
||||
else if (currentRequestId.value)
|
||||
await adminUpdateShooting(currentRequestId.value, payload)
|
||||
ElMessage.success(formMode.value === 'create' ? '约拍已创建' : '约拍已更新')
|
||||
formVisible.value = false
|
||||
loadData()
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '提交失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除约拍「${row.title}」?删除后不可恢复。`, '删除确认', { type: 'warning' })
|
||||
await adminDeleteShooting(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
async function openApps(row: any) {
|
||||
try {
|
||||
appRequest.value = row
|
||||
appRows.value = await adminListShootingApplications(row.id)
|
||||
appForm.applicant_id = undefined
|
||||
appForm.message = ''
|
||||
appForm.status = 'pending'
|
||||
appEditRow.value = null
|
||||
await loadUserOptions()
|
||||
appVisible.value = true
|
||||
}
|
||||
catch (err: any) {
|
||||
if (isRequestNotFound(err)) {
|
||||
ElMessage.warning('约拍数据不存在或已被删除,列表已刷新')
|
||||
loadData()
|
||||
return
|
||||
}
|
||||
ElMessage.error(err?.message || '申请记录加载失败')
|
||||
}
|
||||
}
|
||||
|
||||
function editApp(row: any) {
|
||||
appEditRow.value = row
|
||||
appForm.applicant_id = row.applicant_id
|
||||
appForm.message = row.message || ''
|
||||
appForm.status = row.status || 'pending'
|
||||
}
|
||||
|
||||
async function submitApp() {
|
||||
if (!appRequest.value)
|
||||
return
|
||||
if (!appForm.applicant_id) {
|
||||
ElMessage.error('请选择申请用户')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
if (appEditRow.value) {
|
||||
await adminUpdateShootingApplication(appRequest.value.id, appEditRow.value.id, {
|
||||
message: appForm.message?.trim() || null,
|
||||
status: appForm.status,
|
||||
})
|
||||
ElMessage.success('申请记录已更新')
|
||||
}
|
||||
else {
|
||||
await adminCreateShootingApplication(appRequest.value.id, {
|
||||
applicant_id: appForm.applicant_id,
|
||||
message: appForm.message?.trim() || null,
|
||||
status: appForm.status,
|
||||
})
|
||||
ElMessage.success('申请记录已新增')
|
||||
}
|
||||
appRows.value = await adminListShootingApplications(appRequest.value.id)
|
||||
appForm.applicant_id = undefined
|
||||
appForm.message = ''
|
||||
appForm.status = 'pending'
|
||||
appEditRow.value = null
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '操作失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeApp(row: any) {
|
||||
if (!appRequest.value)
|
||||
return
|
||||
try {
|
||||
await ElMessageBox.confirm('确认删除该申请记录?', '删除确认', { type: 'warning' })
|
||||
await adminDeleteShootingApplication(appRequest.value.id, row.id)
|
||||
appRows.value = await adminListShootingApplications(appRequest.value.id)
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h2>约拍管理</h2>
|
||||
<p>覆盖约拍完整 CRUD,并支持申请记录管理</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<el-button @click="openBatchAudit">
|
||||
批量审核({{ selectedIds.length }})
|
||||
</el-button>
|
||||
<el-button type="primary" @click="openCreate">
|
||||
新建约拍
|
||||
</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stat-row">
|
||||
<article class="stat panel-card">
|
||||
<span>本页总量</span>
|
||||
<strong>{{ rows.length }}</strong>
|
||||
</article>
|
||||
<article class="stat panel-card warn">
|
||||
<span>待审核</span>
|
||||
<strong>{{ pendingCount }}</strong>
|
||||
</article>
|
||||
<article class="stat panel-card ok">
|
||||
<span>开放招募</span>
|
||||
<strong>{{ openCount }}</strong>
|
||||
</article>
|
||||
<article class="stat panel-card">
|
||||
<span>全量数据</span>
|
||||
<strong>{{ total }}</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<el-card shadow="never" class="filter-card panel-card">
|
||||
<el-form inline class="admin-form">
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="filters.keyword" placeholder="标题/描述" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="城市">
|
||||
<el-input v-model="filters.city" placeholder="城市" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="需求角色">
|
||||
<el-select v-model="filters.role_needed" clearable placeholder="全部">
|
||||
<el-option label="摄影师" value="photographer" />
|
||||
<el-option label="Coser" value="cosplayer" />
|
||||
<el-option label="不限" value="both" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="业务状态">
|
||||
<el-select v-model="filters.status" clearable placeholder="全部">
|
||||
<el-option label="招募中" value="open" />
|
||||
<el-option label="匹配完成" value="matched" />
|
||||
<el-option label="已关闭" value="closed" />
|
||||
<el-option label="已取消" value="cancelled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="审核状态">
|
||||
<el-select v-model="filters.audit_status" clearable placeholder="全部">
|
||||
<el-option label="待审核" value="pending" />
|
||||
<el-option label="已通过" value="approved" />
|
||||
<el-option label="已驳回" value="rejected" />
|
||||
<el-option label="已删除" value="deleted" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="onSearch">
|
||||
查询
|
||||
</el-button>
|
||||
<el-button @click="onReset">
|
||||
重置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<section v-if="!loading && rows.length" class="spot-grid">
|
||||
<article v-for="row in rows" :key="row.id" class="spot-card panel-card">
|
||||
<header>
|
||||
<div class="title-wrap">
|
||||
<el-checkbox :model-value="selectedIds.includes(row.id)" @change="toggleSelect(row)" />
|
||||
<h4>{{ row.title }}</h4>
|
||||
</div>
|
||||
<el-tag :type="row.audit_status === 'approved' ? 'success' : row.audit_status === 'pending' ? 'warning' : 'danger'" effect="light" round>
|
||||
{{ row.audit_status }}
|
||||
</el-tag>
|
||||
</header>
|
||||
<div class="meta">
|
||||
<span>城市:{{ row.city || '-' }}</span>
|
||||
<span>角色需求:{{ row.role_needed || '-' }}</span>
|
||||
<span>业务状态:{{ row.status || '-' }}</span>
|
||||
</div>
|
||||
<div class="meta">
|
||||
<span>发布人:#{{ row.creator_id }}</span>
|
||||
<span>是否免费:{{ row.is_free ? '是' : '否' }}</span>
|
||||
<span>人数上限:{{ row.max_applicants || 0 }}</span>
|
||||
</div>
|
||||
<p class="reason">
|
||||
{{ row.reject_reason || '暂无驳回原因' }}
|
||||
</p>
|
||||
<footer>
|
||||
<el-button @click="openDetail(row)">
|
||||
详情聚合
|
||||
</el-button>
|
||||
<el-button type="primary" plain @click="openEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button @click="openAudit(row)">
|
||||
审核
|
||||
</el-button>
|
||||
<el-button @click="openApps(row)">
|
||||
申请管理
|
||||
</el-button>
|
||||
<el-button type="danger" plain @click="onDelete(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</footer>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<el-empty v-else-if="!loading && !rows.length" description="暂无约拍数据" class="panel-card empty" />
|
||||
<el-skeleton v-else :rows="6" animated class="panel-card loading-card" />
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
:total="total"
|
||||
:page-size="filters.page_size"
|
||||
:current-page="filters.page"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="dialogVisible" title="约拍审核设置" width="460px">
|
||||
<el-form label-position="top" class="admin-form">
|
||||
<el-form-item label="审核状态">
|
||||
<el-select v-model="auditForm.audit_status">
|
||||
<el-option label="待审核" value="pending" />
|
||||
<el-option label="已通过" value="approved" />
|
||||
<el-option label="已驳回" value="rejected" />
|
||||
<el-option label="已删除" value="deleted" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="驳回原因(驳回时必填)">
|
||||
<el-input v-model="auditForm.reject_reason" type="textarea" :rows="4" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitAudit">
|
||||
提交
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="batchDialogVisible" title="批量审核设置" width="460px">
|
||||
<el-form label-position="top" class="admin-form">
|
||||
<el-form-item label="审核状态">
|
||||
<el-select v-model="batchAuditForm.audit_status">
|
||||
<el-option label="待审核" value="pending" />
|
||||
<el-option label="已通过" value="approved" />
|
||||
<el-option label="已驳回" value="rejected" />
|
||||
<el-option label="已删除" value="deleted" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="驳回原因(批量驳回时必填)">
|
||||
<el-input v-model="batchAuditForm.reject_reason" type="textarea" :rows="4" />
|
||||
</el-form-item>
|
||||
<el-alert type="info" :closable="false" show-icon :title="`已选择 ${selectedIds.length} 条记录`" />
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="batchDialogVisible = false">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitBatchAudit">
|
||||
提交
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="formVisible" :title="formMode === 'create' ? '新建约拍' : '编辑约拍'" width="860px">
|
||||
<el-form label-position="top" class="admin-form event-form">
|
||||
<el-row :gutter="12">
|
||||
<el-col :md="12" :sm="24">
|
||||
<el-form-item label="发布用户">
|
||||
<el-select v-model="form.creator_id" filterable remote reserve-keyword clearable :remote-method="loadUserOptions" placeholder="搜索用户">
|
||||
<el-option v-for="item in creatorOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :md="12" :sm="24">
|
||||
<el-form-item label="关联取景地">
|
||||
<el-select v-model="form.spot_id" filterable remote reserve-keyword clearable :remote-method="loadSpotOptions" placeholder="可选">
|
||||
<el-option v-for="item in spotOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :md="12" :sm="24">
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="form.title" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :md="12" :sm="24">
|
||||
<el-form-item label="城市">
|
||||
<el-input v-model="form.city" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :md="12" :sm="24">
|
||||
<el-form-item label="风格">
|
||||
<el-input v-model="form.style" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :md="12" :sm="24">
|
||||
<el-form-item label="拍摄时间">
|
||||
<el-input v-model="form.shoot_date" placeholder="YYYY-MM-DDTHH:mm:ss" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :md="8" :sm="24">
|
||||
<el-form-item label="需求角色">
|
||||
<el-select v-model="form.role_needed">
|
||||
<el-option label="摄影师" value="photographer" />
|
||||
<el-option label="Coser" value="cosplayer" />
|
||||
<el-option label="不限" value="both" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :md="8" :sm="24">
|
||||
<el-form-item label="业务状态">
|
||||
<el-select v-model="form.status">
|
||||
<el-option label="招募中" value="open" />
|
||||
<el-option label="匹配完成" value="matched" />
|
||||
<el-option label="已关闭" value="closed" />
|
||||
<el-option label="已取消" value="cancelled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :md="8" :sm="24">
|
||||
<el-form-item label="审核状态">
|
||||
<el-select v-model="form.audit_status">
|
||||
<el-option label="待审核" value="pending" />
|
||||
<el-option label="已通过" value="approved" />
|
||||
<el-option label="已驳回" value="rejected" />
|
||||
<el-option label="已删除" value="deleted" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :md="8" :sm="24">
|
||||
<el-form-item label="人数上限">
|
||||
<el-input-number v-model="form.max_applicants" :min="1" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :md="8" :sm="24">
|
||||
<el-form-item label="是否免费">
|
||||
<el-switch v-model="form.is_free" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :md="8" :sm="24">
|
||||
<el-form-item label="联系方式">
|
||||
<el-input v-model="form.contact_info" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :md="12" :sm="24">
|
||||
<el-form-item label="预算最低">
|
||||
<el-input-number v-model="form.budget_min" :disabled="form.is_free" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :md="12" :sm="24">
|
||||
<el-form-item label="预算最高">
|
||||
<el-input-number v-model="form.budget_max" :disabled="form.is_free" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item v-if="form.audit_status === 'rejected'" label="驳回原因">
|
||||
<el-input v-model="form.reject_reason" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="formVisible = false">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitForm">
|
||||
提交
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="appVisible" :title="`申请管理:${appRequest?.title || ''}`" width="760px">
|
||||
<el-form inline class="admin-form">
|
||||
<el-form-item label="申请用户">
|
||||
<el-select v-model="appForm.applicant_id" filterable remote reserve-keyword clearable :remote-method="loadUserOptions" placeholder="选择用户">
|
||||
<el-option v-for="item in userOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="appForm.status">
|
||||
<el-option label="待处理" value="pending" />
|
||||
<el-option label="已接受" value="accepted" />
|
||||
<el-option label="已拒绝" value="rejected" />
|
||||
<el-option label="已取消" value="cancelled" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="留言">
|
||||
<el-input v-model="appForm.message" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="submitting" @click="submitApp">
|
||||
{{ appEditRow ? '更新' : '新增' }}
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="appRows" border class="table-box">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="applicant_nickname" label="申请用户" min-width="130" />
|
||||
<el-table-column prop="status" label="状态" width="110" />
|
||||
<el-table-column prop="message" label="留言" min-width="150" show-overflow-tooltip />
|
||||
<el-table-column prop="created_at" label="创建时间" min-width="170" />
|
||||
<el-table-column label="操作" width="180">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="editApp(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button link type="danger" @click="removeApp(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-dialog>
|
||||
|
||||
<el-drawer v-model="detailVisible" title="约拍详情聚合" size="720px">
|
||||
<el-skeleton v-if="detailLoading" :rows="10" animated />
|
||||
<template v-else-if="detailData">
|
||||
<el-descriptions :column="2" border class="detail-desc">
|
||||
<el-descriptions-item label="ID">{{ detailData.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="标题">{{ detailData.title }}</el-descriptions-item>
|
||||
<el-descriptions-item label="城市">{{ detailData.city }}</el-descriptions-item>
|
||||
<el-descriptions-item label="发布者">#{{ detailData.creator_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="需求角色">{{ detailData.role_needed }}</el-descriptions-item>
|
||||
<el-descriptions-item label="业务状态">{{ detailData.status }}</el-descriptions-item>
|
||||
<el-descriptions-item label="审核状态">{{ detailData.audit_status }}</el-descriptions-item>
|
||||
<el-descriptions-item label="人数上限">{{ detailData.max_applicants }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="detail-block">
|
||||
<h4>申请记录({{ detailData.applications?.length || 0 }})</h4>
|
||||
<ul class="detail-list">
|
||||
<li v-for="item in (detailData.applications || [])" :key="item.id">
|
||||
#{{ item.id }} 申请人#{{ item.applicant_id }} {{ item.applicant_nickname }} - {{ item.status }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="detail-block">
|
||||
<h4>描述</h4>
|
||||
<p>{{ detailData.description || '-' }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.filter-card {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.filter-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
}
|
||||
.stat-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.stat {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.stat span {
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.stat strong {
|
||||
font-size: 24px;
|
||||
}
|
||||
.stat.warn strong {
|
||||
color: var(--warn);
|
||||
}
|
||||
.stat.ok strong {
|
||||
color: var(--ok);
|
||||
}
|
||||
.spot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.spot-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.spot-card:hover {
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
}
|
||||
.spot-card header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.spot-card h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
.meta {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.reason {
|
||||
min-height: 36px;
|
||||
margin: 12px 0 0;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.spot-card footer {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.loading-card,
|
||||
.empty {
|
||||
padding: 18px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.event-form {
|
||||
max-height: 68vh;
|
||||
overflow: auto;
|
||||
padding-right: 6px;
|
||||
}
|
||||
.table-box {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.detail-desc {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.detail-block {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.detail-block h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.detail-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.spot-grid,
|
||||
.stat-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,880 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
adminAuditSpot,
|
||||
adminBatchAuditSpots,
|
||||
adminCreateSpot,
|
||||
adminDeleteSpot,
|
||||
adminGetSpot,
|
||||
adminListSpots,
|
||||
adminSpotTagOptions,
|
||||
adminUpdateSpot,
|
||||
adminUserOptions,
|
||||
isRequestNotFound,
|
||||
} from '~/composables/admin-api'
|
||||
|
||||
type OptionItem = { label: string, value: number }
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const total = ref(0)
|
||||
const rows = ref<any[]>([])
|
||||
const creatorOptions = ref<OptionItem[]>([])
|
||||
const tagOptions = ref<OptionItem[]>([])
|
||||
const selectedIds = ref<number[]>([])
|
||||
const detailVisible = ref(false)
|
||||
const detailLoading = ref(false)
|
||||
const detailData = ref<any>(null)
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const currentRow = ref<any>(null)
|
||||
const auditForm = reactive({
|
||||
audit_status: 'approved',
|
||||
reject_reason: '',
|
||||
})
|
||||
const batchDialogVisible = ref(false)
|
||||
const batchAuditForm = reactive({
|
||||
audit_status: 'approved',
|
||||
reject_reason: '',
|
||||
})
|
||||
|
||||
const formVisible = ref(false)
|
||||
const formMode = ref<'create' | 'edit'>('create')
|
||||
const currentSpotId = ref<number | null>(null)
|
||||
const form = reactive({
|
||||
title: '',
|
||||
city: '',
|
||||
creator_id: undefined as number | undefined,
|
||||
longitude: 121.4737,
|
||||
latitude: 31.2304,
|
||||
description: '',
|
||||
transport: '',
|
||||
best_time: '',
|
||||
difficulty: '',
|
||||
is_free: true,
|
||||
price_min: undefined as number | undefined,
|
||||
price_max: undefined as number | undefined,
|
||||
audit_status: 'pending',
|
||||
reject_reason: '',
|
||||
tag_ids: [] as number[],
|
||||
image_urls: [''] as string[],
|
||||
})
|
||||
|
||||
const filters = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
keyword: '',
|
||||
city: '',
|
||||
audit_status: '',
|
||||
})
|
||||
|
||||
const canSubmitRejectReason = computed(() => auditForm.audit_status !== 'rejected' || auditForm.reject_reason.trim().length > 0)
|
||||
const pendingCount = computed(() => rows.value.filter(r => r.audit_status === 'pending').length)
|
||||
const approvedCount = computed(() => rows.value.filter(r => r.audit_status === 'approved').length)
|
||||
|
||||
function resetForm() {
|
||||
form.title = ''
|
||||
form.city = ''
|
||||
form.creator_id = undefined
|
||||
form.longitude = 121.4737
|
||||
form.latitude = 31.2304
|
||||
form.description = ''
|
||||
form.transport = ''
|
||||
form.best_time = ''
|
||||
form.difficulty = ''
|
||||
form.is_free = true
|
||||
form.price_min = undefined
|
||||
form.price_max = undefined
|
||||
form.audit_status = 'pending'
|
||||
form.reject_reason = ''
|
||||
form.tag_ids = []
|
||||
form.image_urls = ['']
|
||||
}
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await adminListSpots(filters)
|
||||
rows.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
const currentIds = new Set(rows.value.map((item: any) => item.id))
|
||||
selectedIds.value = selectedIds.value.filter(id => currentIds.has(id))
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCreatorOptions(keyword = '') {
|
||||
try {
|
||||
const items = await adminUserOptions(keyword)
|
||||
creatorOptions.value = (items || []).map((item: any) => ({
|
||||
label: `${item.title} (#${item.id})`,
|
||||
value: item.id,
|
||||
}))
|
||||
}
|
||||
catch {
|
||||
creatorOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTagOptions(keyword = '') {
|
||||
try {
|
||||
const items = await adminSpotTagOptions(keyword)
|
||||
tagOptions.value = (items || []).map((item: any) => ({
|
||||
label: item.title,
|
||||
value: item.id,
|
||||
}))
|
||||
}
|
||||
catch {
|
||||
tagOptions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
function addImageInput() {
|
||||
form.image_urls.push('')
|
||||
}
|
||||
|
||||
function removeImageInput(index: number) {
|
||||
form.image_urls.splice(index, 1)
|
||||
if (!form.image_urls.length)
|
||||
form.image_urls.push('')
|
||||
}
|
||||
|
||||
function normalizePayload() {
|
||||
const image_urls = form.image_urls.map(item => item.trim()).filter(Boolean)
|
||||
const payload: Record<string, any> = {
|
||||
title: form.title.trim(),
|
||||
city: form.city.trim(),
|
||||
creator_id: form.creator_id,
|
||||
longitude: Number(form.longitude),
|
||||
latitude: Number(form.latitude),
|
||||
description: form.description?.trim() || null,
|
||||
transport: form.transport?.trim() || null,
|
||||
best_time: form.best_time?.trim() || null,
|
||||
difficulty: form.difficulty?.trim() || null,
|
||||
is_free: form.is_free,
|
||||
price_min: form.is_free ? null : form.price_min ?? null,
|
||||
price_max: form.is_free ? null : form.price_max ?? null,
|
||||
audit_status: form.audit_status,
|
||||
reject_reason: form.audit_status === 'rejected' ? (form.reject_reason?.trim() || null) : null,
|
||||
tag_ids: [...form.tag_ids],
|
||||
image_urls,
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
if (!form.title.trim()) {
|
||||
ElMessage.error('请填写标题')
|
||||
return false
|
||||
}
|
||||
if (!form.city.trim()) {
|
||||
ElMessage.error('请填写城市')
|
||||
return false
|
||||
}
|
||||
if (!form.creator_id) {
|
||||
ElMessage.error('请选择创建用户')
|
||||
return false
|
||||
}
|
||||
if (Number.isNaN(Number(form.longitude)) || Number.isNaN(Number(form.latitude))) {
|
||||
ElMessage.error('请填写有效经纬度')
|
||||
return false
|
||||
}
|
||||
if (!form.is_free && form.price_min !== undefined && form.price_max !== undefined && Number(form.price_min) > Number(form.price_max)) {
|
||||
ElMessage.error('价格区间不正确')
|
||||
return false
|
||||
}
|
||||
if (form.audit_status === 'rejected' && !form.reject_reason.trim()) {
|
||||
ElMessage.error('驳回状态必须填写原因')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
filters.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
filters.page = page
|
||||
loadData()
|
||||
}
|
||||
|
||||
function openAudit(row: any) {
|
||||
currentRow.value = row
|
||||
auditForm.audit_status = row.audit_status || 'approved'
|
||||
auditForm.reject_reason = row.reject_reason || ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function toggleSelect(row: any) {
|
||||
if (selectedIds.value.includes(row.id))
|
||||
selectedIds.value = selectedIds.value.filter(id => id !== row.id)
|
||||
else
|
||||
selectedIds.value.push(row.id)
|
||||
}
|
||||
|
||||
function openBatchAudit() {
|
||||
if (!selectedIds.value.length) {
|
||||
ElMessage.error('请先勾选要批量审核的数据')
|
||||
return
|
||||
}
|
||||
batchAuditForm.audit_status = 'approved'
|
||||
batchAuditForm.reject_reason = ''
|
||||
batchDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitAudit() {
|
||||
if (!currentRow.value)
|
||||
return
|
||||
if (!canSubmitRejectReason.value) {
|
||||
ElMessage.error('驳回时必须填写驳回原因')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await adminAuditSpot(currentRow.value.id, {
|
||||
audit_status: auditForm.audit_status,
|
||||
reject_reason: auditForm.reject_reason || undefined,
|
||||
})
|
||||
ElMessage.success('审核状态已更新')
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '提交失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitBatchAudit() {
|
||||
if (batchAuditForm.audit_status === 'rejected' && !batchAuditForm.reject_reason.trim()) {
|
||||
ElMessage.error('批量驳回时必须填写原因')
|
||||
return
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await adminBatchAuditSpots({
|
||||
ids: [...selectedIds.value],
|
||||
audit_status: batchAuditForm.audit_status,
|
||||
reject_reason: batchAuditForm.reject_reason || undefined,
|
||||
})
|
||||
ElMessage.success(`批量审核完成(${selectedIds.value.length}条)`)
|
||||
batchDialogVisible.value = false
|
||||
selectedIds.value = []
|
||||
loadData()
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '批量审核失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openCreate() {
|
||||
formMode.value = 'create'
|
||||
currentSpotId.value = null
|
||||
resetForm()
|
||||
await Promise.all([loadCreatorOptions(), loadTagOptions()])
|
||||
formVisible.value = true
|
||||
}
|
||||
|
||||
async function openEdit(row: any) {
|
||||
formMode.value = 'edit'
|
||||
currentSpotId.value = row.id
|
||||
submitting.value = true
|
||||
try {
|
||||
const detail = await adminGetSpot(row.id)
|
||||
form.title = detail.title || ''
|
||||
form.city = detail.city || ''
|
||||
form.creator_id = detail.creator_id
|
||||
form.longitude = detail.longitude || 0
|
||||
form.latitude = detail.latitude || 0
|
||||
form.description = detail.description || ''
|
||||
form.transport = detail.transport || ''
|
||||
form.best_time = detail.best_time || ''
|
||||
form.difficulty = detail.difficulty || ''
|
||||
form.is_free = detail.is_free
|
||||
form.price_min = detail.price_min ?? undefined
|
||||
form.price_max = detail.price_max ?? undefined
|
||||
form.audit_status = detail.audit_status || 'pending'
|
||||
form.reject_reason = detail.reject_reason || ''
|
||||
form.tag_ids = detail.tag_ids || []
|
||||
form.image_urls = detail.image_urls?.length ? [...detail.image_urls] : ['']
|
||||
await Promise.all([loadCreatorOptions(), loadTagOptions()])
|
||||
formVisible.value = true
|
||||
}
|
||||
catch (err: any) {
|
||||
if (isRequestNotFound(err)) {
|
||||
ElMessage.warning('取景地数据不存在或已被删除,列表已刷新')
|
||||
loadData()
|
||||
return
|
||||
}
|
||||
ElMessage.error(err?.message || '加载详情失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(row: any) {
|
||||
detailVisible.value = true
|
||||
detailLoading.value = true
|
||||
detailData.value = null
|
||||
try {
|
||||
detailData.value = await adminGetSpot(row.id)
|
||||
}
|
||||
catch (err: any) {
|
||||
if (isRequestNotFound(err)) {
|
||||
detailVisible.value = false
|
||||
ElMessage.warning('取景地数据不存在或已被删除,列表已刷新')
|
||||
loadData()
|
||||
return
|
||||
}
|
||||
ElMessage.error(err?.message || '详情加载失败')
|
||||
}
|
||||
finally {
|
||||
detailLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (!validateForm())
|
||||
return
|
||||
const payload = normalizePayload()
|
||||
submitting.value = true
|
||||
try {
|
||||
if (formMode.value === 'create') {
|
||||
await adminCreateSpot(payload)
|
||||
ElMessage.success('取景地已创建')
|
||||
}
|
||||
else if (currentSpotId.value) {
|
||||
await adminUpdateSpot(currentSpotId.value, payload)
|
||||
ElMessage.success('取景地已更新')
|
||||
}
|
||||
formVisible.value = false
|
||||
loadData()
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '提交失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认删除取景地「${row.title}」?删除后不可恢复。`, '删除确认', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消',
|
||||
})
|
||||
await adminDeleteSpot(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
loadData()
|
||||
}
|
||||
catch {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadData(), loadTagOptions(), loadCreatorOptions()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h2>取景地管理</h2>
|
||||
<p>支持筛选、审核与完整 CRUD(含图片列表与标签关系)</p>
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<el-button @click="openBatchAudit">
|
||||
批量审核({{ selectedIds.length }})
|
||||
</el-button>
|
||||
<el-button type="primary" @click="openCreate">
|
||||
新建取景地
|
||||
</el-button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stat-row">
|
||||
<article class="stat panel-card">
|
||||
<span>本页总量</span>
|
||||
<strong>{{ rows.length }}</strong>
|
||||
</article>
|
||||
<article class="stat panel-card warn">
|
||||
<span>待审核</span>
|
||||
<strong>{{ pendingCount }}</strong>
|
||||
</article>
|
||||
<article class="stat panel-card ok">
|
||||
<span>已通过</span>
|
||||
<strong>{{ approvedCount }}</strong>
|
||||
</article>
|
||||
<article class="stat panel-card">
|
||||
<span>全量数据</span>
|
||||
<strong>{{ total }}</strong>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<el-card shadow="never" class="filter-card panel-card">
|
||||
<el-form inline class="admin-form">
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="filters.keyword" placeholder="标题/描述" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="城市">
|
||||
<el-input v-model="filters.city" placeholder="城市" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="审核状态">
|
||||
<el-select v-model="filters.audit_status" clearable placeholder="全部">
|
||||
<el-option label="待审核" value="pending" />
|
||||
<el-option label="已通过" value="approved" />
|
||||
<el-option label="已驳回" value="rejected" />
|
||||
<el-option label="已删除" value="deleted" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="onSearch">
|
||||
查询
|
||||
</el-button>
|
||||
<el-button @click="filters.keyword = ''; filters.city = ''; filters.audit_status = ''; onSearch()">
|
||||
重置
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<section v-if="!loading && rows.length" class="spot-grid">
|
||||
<article v-for="row in rows" :key="row.id" class="spot-card panel-card">
|
||||
<header>
|
||||
<div class="title-wrap">
|
||||
<el-checkbox :model-value="selectedIds.includes(row.id)" @change="toggleSelect(row)" />
|
||||
<h4>{{ row.title }}</h4>
|
||||
</div>
|
||||
<el-tag
|
||||
:type="row.audit_status === 'approved' ? 'success' : row.audit_status === 'pending' ? 'warning' : 'danger'"
|
||||
effect="light"
|
||||
round
|
||||
>
|
||||
{{ row.audit_status }}
|
||||
</el-tag>
|
||||
</header>
|
||||
<div class="meta">
|
||||
<span>城市:{{ row.city || '-' }}</span>
|
||||
<span>创建者:#{{ row.creator_id }}</span>
|
||||
<span>ID:{{ row.id }}</span>
|
||||
</div>
|
||||
<p class="reason">
|
||||
{{ row.reject_reason || '暂无驳回原因' }}
|
||||
</p>
|
||||
<footer>
|
||||
<el-button @click="openDetail(row)">
|
||||
详情聚合
|
||||
</el-button>
|
||||
<el-button type="primary" plain @click="openEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button @click="openAudit(row)">
|
||||
审核
|
||||
</el-button>
|
||||
<el-button type="danger" plain @click="onDelete(row)">
|
||||
删除
|
||||
</el-button>
|
||||
</footer>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<el-empty v-else-if="!loading && !rows.length" description="暂无数据" class="panel-card empty" />
|
||||
<el-skeleton v-else :rows="6" animated class="panel-card loading-card" />
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
:total="total"
|
||||
:page-size="filters.page_size"
|
||||
:current-page="filters.page"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="dialogVisible" title="审核设置" width="460px">
|
||||
<el-form label-position="top" class="admin-form">
|
||||
<el-form-item label="审核状态">
|
||||
<el-select v-model="auditForm.audit_status">
|
||||
<el-option label="待审核" value="pending" />
|
||||
<el-option label="已通过" value="approved" />
|
||||
<el-option label="已驳回" value="rejected" />
|
||||
<el-option label="已删除" value="deleted" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="驳回原因(驳回时必填)">
|
||||
<el-input v-model="auditForm.reject_reason" type="textarea" :rows="4" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitAudit">
|
||||
提交
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="formVisible" :title="formMode === 'create' ? '新建取景地' : '编辑取景地'" width="860px">
|
||||
<el-form label-position="top" class="admin-form spot-form">
|
||||
<el-row :gutter="12">
|
||||
<el-col :md="12" :sm="24">
|
||||
<el-form-item label="标题">
|
||||
<el-input v-model="form.title" placeholder="请输入标题" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :md="12" :sm="24">
|
||||
<el-form-item label="城市">
|
||||
<el-input v-model="form.city" placeholder="请输入城市" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :md="12" :sm="24">
|
||||
<el-form-item label="创建用户">
|
||||
<el-select
|
||||
v-model="form.creator_id"
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
clearable
|
||||
placeholder="搜索昵称/手机号/邮箱"
|
||||
:remote-method="loadCreatorOptions"
|
||||
>
|
||||
<el-option v-for="item in creatorOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :md="12" :sm="24">
|
||||
<el-form-item label="审核状态">
|
||||
<el-select v-model="form.audit_status">
|
||||
<el-option label="待审核" value="pending" />
|
||||
<el-option label="已通过" value="approved" />
|
||||
<el-option label="已驳回" value="rejected" />
|
||||
<el-option label="已删除" value="deleted" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :md="12" :sm="24">
|
||||
<el-form-item label="经度">
|
||||
<el-input-number v-model="form.longitude" :precision="6" :step="0.000001" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :md="12" :sm="24">
|
||||
<el-form-item label="纬度">
|
||||
<el-input-number v-model="form.latitude" :precision="6" :step="0.000001" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="标签(多选)">
|
||||
<el-select
|
||||
v-model="form.tag_ids"
|
||||
multiple
|
||||
filterable
|
||||
remote
|
||||
reserve-keyword
|
||||
clearable
|
||||
placeholder="搜索并选择标签"
|
||||
:remote-method="loadTagOptions"
|
||||
>
|
||||
<el-option v-for="item in tagOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="描述">
|
||||
<el-input v-model="form.description" type="textarea" :rows="3" />
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :md="12" :sm="24">
|
||||
<el-form-item label="交通">
|
||||
<el-input v-model="form.transport" placeholder="地铁/公交/停车信息" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :md="12" :sm="24">
|
||||
<el-form-item label="最佳时间">
|
||||
<el-input v-model="form.best_time" placeholder="如:17:30-19:00" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="难度">
|
||||
<el-input v-model="form.difficulty" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :md="8" :sm="24">
|
||||
<el-form-item label="是否免费">
|
||||
<el-switch v-model="form.is_free" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :md="8" :sm="24">
|
||||
<el-form-item label="最低价格">
|
||||
<el-input-number v-model="form.price_min" :disabled="form.is_free" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
<el-col :md="8" :sm="24">
|
||||
<el-form-item label="最高价格">
|
||||
<el-input-number v-model="form.price_max" :disabled="form.is_free" :min="0" style="width: 100%" />
|
||||
</el-form-item>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item v-if="form.audit_status === 'rejected'" label="驳回原因">
|
||||
<el-input v-model="form.reject_reason" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="图片 URL 列表(JSON数组)">
|
||||
<div class="image-list">
|
||||
<div v-for="(item, idx) in form.image_urls" :key="idx" class="image-row">
|
||||
<el-input v-model="form.image_urls[idx]" placeholder="https://..." />
|
||||
<el-button v-if="form.image_urls.length > 1" @click="removeImageInput(idx)">
|
||||
删除
|
||||
</el-button>
|
||||
</div>
|
||||
<el-button class="image-add" @click="addImageInput">
|
||||
新增图片
|
||||
</el-button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="formVisible = false">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitForm">
|
||||
提交
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="batchDialogVisible" title="批量审核设置" width="460px">
|
||||
<el-form label-position="top" class="admin-form">
|
||||
<el-form-item label="审核状态">
|
||||
<el-select v-model="batchAuditForm.audit_status">
|
||||
<el-option label="待审核" value="pending" />
|
||||
<el-option label="已通过" value="approved" />
|
||||
<el-option label="已驳回" value="rejected" />
|
||||
<el-option label="已删除" value="deleted" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="驳回原因(批量驳回时必填)">
|
||||
<el-input v-model="batchAuditForm.reject_reason" type="textarea" :rows="4" />
|
||||
</el-form-item>
|
||||
<el-alert type="info" :closable="false" show-icon :title="`已选择 ${selectedIds.length} 条记录`" />
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="batchDialogVisible = false">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitBatchAudit">
|
||||
提交
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-drawer v-model="detailVisible" title="取景地详情聚合" size="640px">
|
||||
<el-skeleton v-if="detailLoading" :rows="10" animated />
|
||||
<template v-else-if="detailData">
|
||||
<el-descriptions :column="2" border class="detail-desc">
|
||||
<el-descriptions-item label="ID">{{ detailData.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="标题">{{ detailData.title }}</el-descriptions-item>
|
||||
<el-descriptions-item label="城市">{{ detailData.city }}</el-descriptions-item>
|
||||
<el-descriptions-item label="创建者">#{{ detailData.creator_id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="审核状态">{{ detailData.audit_status }}</el-descriptions-item>
|
||||
<el-descriptions-item label="是否免费">{{ detailData.is_free ? '是' : '否' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="经度">{{ detailData.longitude }}</el-descriptions-item>
|
||||
<el-descriptions-item label="纬度">{{ detailData.latitude }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="detail-block">
|
||||
<h4>标签({{ detailData.tag_ids?.length || 0 }})</h4>
|
||||
<el-tag v-for="tagId in (detailData.tag_ids || [])" :key="tagId" class="tag-item" effect="light">#{{ tagId }}</el-tag>
|
||||
</div>
|
||||
<div class="detail-block">
|
||||
<h4>图片列表({{ detailData.image_urls?.length || 0 }})</h4>
|
||||
<ul class="detail-list">
|
||||
<li v-for="(url, idx) in (detailData.image_urls || [])" :key="`${url}-${idx}`">{{ url }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="detail-block">
|
||||
<h4>描述</h4>
|
||||
<p>{{ detailData.description || '-' }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.head-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.filter-card {
|
||||
margin-bottom: 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.filter-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
}
|
||||
.stat-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.stat {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.stat span {
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.stat strong {
|
||||
font-size: 24px;
|
||||
}
|
||||
.stat.warn strong {
|
||||
color: var(--warn);
|
||||
}
|
||||
.stat.ok strong {
|
||||
color: var(--ok);
|
||||
}
|
||||
.spot-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.spot-card {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
.spot-card:hover {
|
||||
box-shadow: var(--shadow-card-hover);
|
||||
}
|
||||
.spot-card header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.title-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.spot-card h4 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
.meta {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 12px;
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
.reason {
|
||||
min-height: 36px;
|
||||
margin: 12px 0 0;
|
||||
color: var(--text-2);
|
||||
}
|
||||
.spot-card footer {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.loading-card,
|
||||
.empty {
|
||||
padding: 18px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.spot-form {
|
||||
max-height: 68vh;
|
||||
overflow: auto;
|
||||
padding-right: 6px;
|
||||
}
|
||||
.image-list {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.image-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.image-add {
|
||||
align-self: flex-start;
|
||||
}
|
||||
.detail-desc {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.detail-block {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.detail-block h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.detail-list {
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
.tag-item {
|
||||
margin-right: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.spot-grid,
|
||||
.stat-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,308 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
adminCreateUser,
|
||||
adminDeleteUser,
|
||||
adminListUsers,
|
||||
adminUpdateUser,
|
||||
} from '~/composables/admin-api'
|
||||
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const total = ref(0)
|
||||
const rows = ref<any[]>([])
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const filters = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
keyword: '',
|
||||
role: '',
|
||||
is_active: '',
|
||||
})
|
||||
|
||||
const form = reactive({
|
||||
nickname: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
city: '',
|
||||
identity: 'both',
|
||||
role: 'user',
|
||||
is_active: true,
|
||||
password: '',
|
||||
})
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = { ...filters }
|
||||
if (filters.is_active !== '')
|
||||
params.is_active = filters.is_active === 'true'
|
||||
const data = await adminListUsers(params)
|
||||
rows.value = data.items || []
|
||||
total.value = data.total || 0
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '用户数据加载失败')
|
||||
}
|
||||
finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
filters.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
function onPageChange(page: number) {
|
||||
filters.page = page
|
||||
loadData()
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
editingId.value = null
|
||||
form.nickname = ''
|
||||
form.phone = ''
|
||||
form.email = ''
|
||||
form.city = ''
|
||||
form.identity = 'both'
|
||||
form.role = 'user'
|
||||
form.is_active = true
|
||||
form.password = ''
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
resetForm()
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
function openEdit(row: any) {
|
||||
editingId.value = row.id
|
||||
form.nickname = row.nickname || ''
|
||||
form.phone = row.phone || ''
|
||||
form.email = row.email || ''
|
||||
form.city = row.city || ''
|
||||
form.identity = row.identity || 'both'
|
||||
form.role = row.role || 'user'
|
||||
form.is_active = !!row.is_active
|
||||
form.password = ''
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload: Record<string, any> = {
|
||||
nickname: form.nickname,
|
||||
phone: form.phone || null,
|
||||
email: form.email || null,
|
||||
city: form.city || null,
|
||||
identity: form.identity,
|
||||
role: form.role,
|
||||
is_active: form.is_active,
|
||||
}
|
||||
if (form.password.trim())
|
||||
payload.password = form.password.trim()
|
||||
|
||||
if (editingId.value) {
|
||||
await adminUpdateUser(editingId.value, payload)
|
||||
ElMessage.success('更新成功')
|
||||
}
|
||||
else {
|
||||
if (!payload.password) {
|
||||
ElMessage.error('新增用户必须填写密码')
|
||||
submitting.value = false
|
||||
return
|
||||
}
|
||||
await adminCreateUser(payload)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
loadData()
|
||||
}
|
||||
catch (err: any) {
|
||||
ElMessage.error(err?.message || '保存失败')
|
||||
}
|
||||
finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeUser(row: any) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确认停用用户「${row.nickname}」吗?`, '停用确认', { type: 'warning' })
|
||||
await adminDeleteUser(row.id)
|
||||
ElMessage.success('已停用')
|
||||
loadData()
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
onMounted(loadData)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-head">
|
||||
<h2>用户与权限</h2>
|
||||
<p>用户列表、角色权限、账号启停用统一管理</p>
|
||||
</section>
|
||||
|
||||
<el-card shadow="never" class="filter-card panel-card">
|
||||
<el-form inline class="admin-form">
|
||||
<div class="filter-grid">
|
||||
<el-form-item label="关键词">
|
||||
<el-input v-model="filters.keyword" placeholder="昵称/手机号/邮箱" clearable />
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="filters.role" clearable placeholder="全部">
|
||||
<el-option label="用户" value="user" />
|
||||
<el-option label="审核员" value="moderator" />
|
||||
<el-option label="管理员" value="admin" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="filters.is_active" clearable placeholder="全部">
|
||||
<el-option label="启用" value="true" />
|
||||
<el-option label="停用" value="false" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="onSearch">
|
||||
查询
|
||||
</el-button>
|
||||
<el-button @click="filters.keyword = ''; filters.role = ''; filters.is_active = ''; onSearch()">
|
||||
重置
|
||||
</el-button>
|
||||
<el-button type="primary" plain @click="openCreate">
|
||||
新增用户
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="panel-card">
|
||||
<el-table :data="rows" v-loading="loading">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="nickname" label="昵称" width="140" />
|
||||
<el-table-column prop="phone" label="手机号" width="140" />
|
||||
<el-table-column prop="email" label="邮箱" min-width="180" />
|
||||
<el-table-column prop="city" label="城市" width="120" />
|
||||
<el-table-column label="身份" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.identity || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="角色" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.role === 'admin' ? 'danger' : row.role === 'moderator' ? 'warning' : 'info'" effect="light" round>
|
||||
{{ row.role }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" effect="light" round>
|
||||
{{ row.is_active ? '启用' : '停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button type="primary" link @click="openEdit(row)">
|
||||
编辑
|
||||
</el-button>
|
||||
<el-button type="danger" link @click="removeUser(row)">
|
||||
停用
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<div class="pager">
|
||||
<el-pagination
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
:total="total"
|
||||
:page-size="filters.page_size"
|
||||
:current-page="filters.page"
|
||||
@current-change="onPageChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑用户' : '新增用户'" width="620px">
|
||||
<el-form class="admin-form" label-position="top">
|
||||
<div class="form-grid">
|
||||
<el-form-item label="昵称">
|
||||
<el-input v-model="form.nickname" placeholder="请输入昵称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="手机号">
|
||||
<el-input v-model="form.phone" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="邮箱">
|
||||
<el-input v-model="form.email" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="城市">
|
||||
<el-input v-model="form.city" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="身份">
|
||||
<el-select v-model="form.identity">
|
||||
<el-option label="摄影师" value="photographer" />
|
||||
<el-option label="Coser" value="cosplayer" />
|
||||
<el-option label="双身份" value="both" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="角色">
|
||||
<el-select v-model="form.role">
|
||||
<el-option label="用户" value="user" />
|
||||
<el-option label="审核员" value="moderator" />
|
||||
<el-option label="管理员" value="admin" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item :label="editingId ? '重置密码(可选)' : '密码'">
|
||||
<el-input v-model="form.password" type="password" show-password :placeholder="editingId ? '留空则不修改' : '至少6位'" />
|
||||
</el-form-item>
|
||||
</div>
|
||||
<el-form-item label="账号状态">
|
||||
<el-switch v-model="form.is_active" active-text="启用" inactive-text="停用" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">
|
||||
取消
|
||||
</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitForm">
|
||||
保存
|
||||
</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-card {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.filter-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 8px;
|
||||
}
|
||||
.pager {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 12px;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router'
|
||||
|
||||
import AdminLayout from '~/layouts/AdminLayout.vue'
|
||||
import LoginPage from '~/pages/login.vue'
|
||||
import DashboardPage from '~/pages/dashboard.vue'
|
||||
import SpotsPage from '~/pages/spots.vue'
|
||||
import EventsPage from '~/pages/events.vue'
|
||||
import ShootingPage from '~/pages/shooting.vue'
|
||||
import ModuleDesignPage from '~/pages/module-design.vue'
|
||||
import NavConfigsPage from '~/pages/nav-configs.vue'
|
||||
import PromotionsPage from '~/pages/promotions.vue'
|
||||
import UsersPage from '~/pages/users.vue'
|
||||
import MembershipPage from '~/pages/membership.vue'
|
||||
import OpsPage from '~/pages/ops.vue'
|
||||
import { getAdminToken } from '~/composables/admin-auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: AdminLayout,
|
||||
children: [
|
||||
{ path: '', redirect: '/dashboard' },
|
||||
{ path: 'dashboard', component: DashboardPage },
|
||||
{ path: 'spots', component: SpotsPage },
|
||||
{ path: 'events', component: EventsPage },
|
||||
{ path: 'shooting', component: ShootingPage },
|
||||
{ path: 'module-design', component: ModuleDesignPage },
|
||||
{ path: 'nav-configs', component: NavConfigsPage },
|
||||
{ path: 'promotions', component: PromotionsPage },
|
||||
{ path: 'users', component: UsersPage },
|
||||
{ path: 'membership', component: MembershipPage },
|
||||
{ path: 'ops', component: OpsPage },
|
||||
],
|
||||
},
|
||||
{ path: '/login', component: LoginPage },
|
||||
],
|
||||
})
|
||||
|
||||
router.beforeEach((to, _from, next) => {
|
||||
if (to.path === '/login') {
|
||||
next()
|
||||
return
|
||||
}
|
||||
if (!getAdminToken()) {
|
||||
next('/login')
|
||||
return
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,11 @@
|
||||
// only scss variables
|
||||
|
||||
$--colors: (
|
||||
'primary': (
|
||||
'base': #589ef8,
|
||||
),
|
||||
);
|
||||
|
||||
@forward 'element-plus/theme-chalk/src/dark/var.scss' with (
|
||||
$colors: $--colors
|
||||
);
|
||||
@@ -0,0 +1,42 @@
|
||||
$--colors: (
|
||||
'primary': (
|
||||
'base': green,
|
||||
),
|
||||
'success': (
|
||||
'base': #21ba45,
|
||||
),
|
||||
'warning': (
|
||||
'base': #f2711c,
|
||||
),
|
||||
'danger': (
|
||||
'base': #db2828,
|
||||
),
|
||||
'error': (
|
||||
'base': #db2828,
|
||||
),
|
||||
'info': (
|
||||
'base': #42b8dd,
|
||||
),
|
||||
);
|
||||
|
||||
// we can add this to custom namespace, default is 'el'
|
||||
@forward 'element-plus/theme-chalk/src/mixins/config.scss' with (
|
||||
$namespace: 'ep'
|
||||
);
|
||||
|
||||
// You should use them in scss, because we calculate it by sass.
|
||||
// comment next lines to use default color
|
||||
@forward 'element-plus/theme-chalk/src/common/var.scss' with (
|
||||
// do not use same name, it will override.
|
||||
$colors: $--colors,
|
||||
$button-padding-horizontal: ('default': 50px)
|
||||
);
|
||||
|
||||
// if you want to import all
|
||||
// @use "element-plus/theme-chalk/src/index.scss" as *;
|
||||
|
||||
// You can comment it to hide debug info.
|
||||
// @debug $--colors;
|
||||
|
||||
// custom dark variables
|
||||
@use './dark.scss';
|
||||
@@ -0,0 +1,342 @@
|
||||
@use 'element-plus/theme-chalk/src/dark/css-vars.scss' as *;
|
||||
|
||||
:root {
|
||||
--brand-primary: #1677ff;
|
||||
--brand-primary-hover: #4096ff;
|
||||
--brand-primary-active: #0958d9;
|
||||
--bg-page: #f5f7fa;
|
||||
--bg-card: #ffffff;
|
||||
--bg-subtle: #fafbfc;
|
||||
--border-base: #e5eaf0;
|
||||
--border-split: #eef1f4;
|
||||
--text-1: #1f2329;
|
||||
--text-2: #4e5969;
|
||||
--text-3: #86909c;
|
||||
--text-disabled: #c9cdd4;
|
||||
--ok: #00b578;
|
||||
--warn: #faad14;
|
||||
--danger: #f53f3f;
|
||||
--info: #1677ff;
|
||||
--radius-sm: 6px;
|
||||
--radius-md: 8px;
|
||||
--radius-card: 12px;
|
||||
--radius-lg: 16px;
|
||||
--shadow-card: 0 4px 16px rgba(31, 35, 41, 0.04);
|
||||
--shadow-card-hover: 0 8px 24px rgba(22, 119, 255, 0.08);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
margin: 0;
|
||||
min-height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
color: var(--text-1);
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--brand-primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.admin-shell {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
grid-template-columns: 248px 1fr;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background: var(--bg-card);
|
||||
border-right: 1px solid var(--border-split);
|
||||
padding: 20px 14px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 10px 20px;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, var(--brand-primary), #36a3ff);
|
||||
}
|
||||
|
||||
.brand-text h1 {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-text p {
|
||||
margin: 3px 0 0;
|
||||
color: var(--text-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
height: 42px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 10px;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 12px;
|
||||
color: var(--text-2);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
background: var(--bg-subtle);
|
||||
color: var(--text-1);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
color: var(--brand-primary);
|
||||
background: rgba(22, 119, 255, 0.08);
|
||||
border-color: rgba(22, 119, 255, 0.24);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nav-item.active::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -14px;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: var(--brand-primary);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
height: 64px;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
border-bottom: 1px solid var(--border-split);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 20px;
|
||||
backdrop-filter: blur(6px);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.search-wrap {
|
||||
flex: 1;
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.global-search {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-base);
|
||||
background: #fff;
|
||||
padding: 0 12px;
|
||||
outline: none;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.global-search:focus {
|
||||
border-color: var(--brand-primary);
|
||||
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.12);
|
||||
}
|
||||
|
||||
.top-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: 1px solid var(--border-base);
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background: var(--bg-subtle);
|
||||
}
|
||||
|
||||
.user-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 14px;
|
||||
background: linear-gradient(135deg, #4e9cff, #1668dc);
|
||||
}
|
||||
|
||||
.user-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.user-meta small {
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
border: 1px solid var(--border-base);
|
||||
background: #fff;
|
||||
color: var(--text-2);
|
||||
border-radius: 8px;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
color: var(--danger);
|
||||
border-color: #f8c5c5;
|
||||
background: #fff7f7;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.page-head h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.page-head p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--text-3);
|
||||
}
|
||||
|
||||
.panel-card {
|
||||
border-radius: var(--radius-card);
|
||||
border: 1px solid var(--border-split);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.admin-form {
|
||||
.el-form-item {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.el-form-item__label {
|
||||
color: var(--text-2);
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.el-input__wrapper,
|
||||
.el-textarea__inner,
|
||||
.el-select__wrapper {
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--border-base);
|
||||
box-shadow: none;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease;
|
||||
}
|
||||
|
||||
.el-input__wrapper:hover,
|
||||
.el-textarea__inner:hover,
|
||||
.el-select__wrapper:hover {
|
||||
border-color: #cfdae6;
|
||||
}
|
||||
|
||||
.el-input__wrapper.is-focus,
|
||||
.el-select__wrapper.is-focused,
|
||||
.el-textarea__inner:focus {
|
||||
border-color: var(--brand-primary);
|
||||
box-shadow: 0 0 0 3px rgba(22, 119, 255, 0.12);
|
||||
}
|
||||
|
||||
.el-button {
|
||||
border-radius: var(--radius-md);
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.el-button--primary {
|
||||
background: var(--brand-primary);
|
||||
border-color: var(--brand-primary);
|
||||
}
|
||||
|
||||
.el-button--primary:hover {
|
||||
background: var(--brand-primary-hover);
|
||||
border-color: var(--brand-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.admin-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.sidebar {
|
||||
position: static;
|
||||
height: auto;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border-split);
|
||||
}
|
||||
.content {
|
||||
padding: 16px;
|
||||
}
|
||||
}
|
||||
Vendored
+26
@@ -0,0 +1,26 @@
|
||||
/* eslint-disable */
|
||||
/* prettier-ignore */
|
||||
// @ts-nocheck
|
||||
// Generated by unplugin-vue-router. ‼️ DO NOT MODIFY THIS FILE ‼️
|
||||
// It's recommended to commit this file.
|
||||
// Make sure to add this file to your tsconfig.json file as an "includes" or "files" entry.
|
||||
|
||||
declare module 'vue-router/auto-routes' {
|
||||
import type {
|
||||
RouteRecordInfo,
|
||||
ParamValue,
|
||||
ParamValueOneOrMore,
|
||||
ParamValueZeroOrMore,
|
||||
ParamValueZeroOrOne,
|
||||
} from 'vue-router'
|
||||
|
||||
/**
|
||||
* Route name map generated by unplugin-vue-router
|
||||
*/
|
||||
export interface RouteNamedMap {
|
||||
'/': RouteRecordInfo<'/', '/', Record<never, never>, Record<never, never>>,
|
||||
'/nav/1/item-1': RouteRecordInfo<'/nav/1/item-1', '/nav/1/item-1', Record<never, never>, Record<never, never>>,
|
||||
'/nav/2': RouteRecordInfo<'/nav/2', '/nav/2', Record<never, never>, Record<never, never>>,
|
||||
'/nav/4': RouteRecordInfo<'/nav/4', '/nav/4', Record<never, never>, Record<never, never>>,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { ViteSSGContext } from 'vite-ssg'
|
||||
|
||||
export type UserModule = (ctx: ViteSSGContext) => void
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "esnext",
|
||||
"jsx": "preserve",
|
||||
"lib": ["esnext", "dom"],
|
||||
"useDefineForClassFields": true,
|
||||
"baseUrl": ".",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"paths": {
|
||||
"~/*": ["src/*"]
|
||||
},
|
||||
"resolveJsonModule": true,
|
||||
"types": [
|
||||
"vite/client"
|
||||
],
|
||||
"strict": true,
|
||||
"sourceMap": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"vueCompilerOptions": {
|
||||
"target": 3
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
defineConfig,
|
||||
presetAttributify,
|
||||
presetIcons,
|
||||
presetTypography,
|
||||
presetUno,
|
||||
presetWebFonts,
|
||||
transformerDirectives,
|
||||
transformerVariantGroup,
|
||||
} from 'unocss'
|
||||
|
||||
export default defineConfig({
|
||||
shortcuts: [
|
||||
['btn', 'px-4 py-1 rounded inline-block bg-teal-700 text-white cursor-pointer !outline-none hover:bg-teal-800 disabled:cursor-default disabled:bg-gray-600 disabled:opacity-50'],
|
||||
['icon-btn', 'inline-block cursor-pointer select-none opacity-75 transition duration-200 ease-in-out hover:opacity-100 hover:text-teal-600'],
|
||||
],
|
||||
presets: [
|
||||
presetUno(),
|
||||
presetAttributify(),
|
||||
presetIcons({
|
||||
scale: 1.2,
|
||||
}),
|
||||
presetTypography(),
|
||||
presetWebFonts({
|
||||
fonts: {
|
||||
sans: 'DM Sans',
|
||||
serif: 'DM Serif Display',
|
||||
mono: 'DM Mono',
|
||||
},
|
||||
}),
|
||||
],
|
||||
transformers: [
|
||||
transformerDirectives(),
|
||||
transformerVariantGroup(),
|
||||
],
|
||||
safelist: 'prose prose-sm m-auto text-left'.split(' '),
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import path from 'node:path'
|
||||
import Vue from '@vitejs/plugin-vue'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
'~/': `${path.resolve(__dirname, 'src')}/`,
|
||||
},
|
||||
},
|
||||
|
||||
css: {
|
||||
preprocessorOptions: {
|
||||
scss: {
|
||||
additionalData: `@use "~/styles/element/index.scss" as *;`,
|
||||
api: 'modern-compiler',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
plugins: [
|
||||
Vue(),
|
||||
],
|
||||
})
|
||||
Reference in New Issue
Block a user