Compare commits
10 Commits
1a88be5bb9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8186e36d3d | |||
| 66713b110f | |||
| d62c51f140 | |||
| 54b461dfb4 | |||
| 14590c5eda | |||
| ed1fcdfdaa | |||
| 0d12ce5201 | |||
| 9e0d321177 | |||
| 4d8901d1af | |||
| ad408db118 |
+4
-1
@@ -1,2 +1,5 @@
|
||||
# 生产环境(前后端同域部署时可直接使用相对路径)
|
||||
# 生产环境 API(前后端同域部署时使用相对路径)
|
||||
VITE_API_BASE_URL=/
|
||||
|
||||
# 生产环境前端路由基路径
|
||||
VITE_PUBLIC_BASE=/panel/
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
name: deploy-admin-frontend-prod
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: admin-frontend-prod
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Package dist
|
||||
run: |
|
||||
set -e
|
||||
tar -czf "/tmp/admin-frontend-${GITHUB_SHA}.tar.gz" -C dist .
|
||||
|
||||
- name: Validate required secrets
|
||||
env:
|
||||
PROD_SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
|
||||
PROD_HOST: ${{ secrets.PROD_HOST }}
|
||||
PROD_PORT: ${{ secrets.PROD_PORT }}
|
||||
PROD_USER: ${{ secrets.PROD_USER }}
|
||||
ADMIN_WEB_ROOT: ${{ secrets.ADMIN_WEB_ROOT }}
|
||||
ADMIN_WEB_USER: ${{ secrets.ADMIN_WEB_USER }}
|
||||
ADMIN_WEB_GROUP: ${{ secrets.ADMIN_WEB_GROUP }}
|
||||
ADMIN_KEEP_BACKUPS: ${{ secrets.ADMIN_KEEP_BACKUPS }}
|
||||
run: |
|
||||
set -e
|
||||
for key in PROD_SSH_KEY PROD_HOST PROD_PORT PROD_USER ADMIN_WEB_ROOT ADMIN_WEB_USER ADMIN_WEB_GROUP ADMIN_KEEP_BACKUPS; do
|
||||
if [ -z "${!key}" ]; then
|
||||
echo "Missing required secret: ${key}"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
- name: Prepare SSH
|
||||
env:
|
||||
SSH_KEY: ${{ secrets.PROD_SSH_KEY }}
|
||||
HOST: ${{ secrets.PROD_HOST }}
|
||||
PORT: ${{ secrets.PROD_PORT }}
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||
chmod 600 ~/.ssh/id_ed25519
|
||||
ssh-keyscan -p "$PORT" "$HOST" >> ~/.ssh/known_hosts
|
||||
|
||||
- name: Upload build artifact to server
|
||||
env:
|
||||
HOST: ${{ secrets.PROD_HOST }}
|
||||
PORT: ${{ secrets.PROD_PORT }}
|
||||
USER: ${{ secrets.PROD_USER }}
|
||||
run: |
|
||||
set -e
|
||||
SSH_OPTS="-o BatchMode=yes -o ConnectTimeout=15 -o ServerAliveInterval=10 -o ServerAliveCountMax=6"
|
||||
scp ${SSH_OPTS} -P "$PORT" \
|
||||
"/tmp/admin-frontend-${GITHUB_SHA}.tar.gz" \
|
||||
"${USER}@${HOST}:/tmp/admin-frontend-${GITHUB_SHA}.tar.gz"
|
||||
|
||||
- name: Deploy static files on server
|
||||
env:
|
||||
HOST: ${{ secrets.PROD_HOST }}
|
||||
PORT: ${{ secrets.PROD_PORT }}
|
||||
USER: ${{ secrets.PROD_USER }}
|
||||
WEB_ROOT: ${{ secrets.ADMIN_WEB_ROOT }}
|
||||
WEB_USER: ${{ secrets.ADMIN_WEB_USER }}
|
||||
WEB_GROUP: ${{ secrets.ADMIN_WEB_GROUP }}
|
||||
KEEP_BACKUPS: ${{ secrets.ADMIN_KEEP_BACKUPS }}
|
||||
HEALTHCHECK_URL: ${{ secrets.ADMIN_HEALTHCHECK_URL }}
|
||||
run: |
|
||||
set -e
|
||||
SSH_OPTS="-o BatchMode=yes -o ConnectTimeout=15 -o ServerAliveInterval=10 -o ServerAliveCountMax=6"
|
||||
|
||||
ssh ${SSH_OPTS} -p "$PORT" "${USER}@${HOST}" \
|
||||
"DEPLOY_TAR='/tmp/admin-frontend-${GITHUB_SHA}.tar.gz' \
|
||||
TARGET_DIR='${WEB_ROOT}' \
|
||||
RELEASE_ID='${GITHUB_SHA}' \
|
||||
RUN_USER='${WEB_USER}' \
|
||||
RUN_GROUP='${WEB_GROUP}' \
|
||||
KEEP_BACKUPS='${KEEP_BACKUPS}' \
|
||||
HEALTHCHECK_URL='${HEALTHCHECK_URL}' \
|
||||
bash -s" < scripts/ops/deploy_static.sh
|
||||
@@ -32,3 +32,20 @@ npm run build
|
||||
- 会员、保质期、去水印、系统设置接口联调
|
||||
- 会员管理的记录详情与批量操作
|
||||
- 系统设置中的操作日志查询
|
||||
|
||||
## 自动构建与发布(GitHub Actions)
|
||||
|
||||
已新增生产自动发布流程:
|
||||
- 工作流:`.github/workflows/deploy-prod.yml`
|
||||
- 远程发布脚本:`scripts/ops/deploy_static.sh`
|
||||
- 详细说明:`docs/deploy_ci.md`
|
||||
|
||||
部署参数全部走 GitHub Secrets(不使用脚本默认值),并与 `wx_service` 对齐:
|
||||
- `PROD_HOST=115.159.198.14`
|
||||
- `PROD_PORT=22`
|
||||
- `PROD_USER=root`
|
||||
- `ADMIN_WEB_ROOT=/www/wwwroot/wx_service/web/admin-frontend/panel`
|
||||
|
||||
密钥说明:
|
||||
- `~/.ssh/id_ed25519.pub` 是公钥(放服务器)
|
||||
- GitHub Secret `PROD_SSH_KEY` 需填写私钥 `~/.ssh/id_ed25519`
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# 生产部署(GitHub Actions,前端静态站点)
|
||||
|
||||
本文档用于 `admin-frontend` 的自动化构建发布:
|
||||
- 触发:`main` 分支 push 或手动触发
|
||||
- 流程:GitHub Actions 构建 `dist` -> 通过 SSH 上传到服务器 -> 远程脚本覆盖发布
|
||||
- 特点:参考 `wx_service` 部署流程,全部参数由 GitHub Secrets 驱动
|
||||
|
||||
## 0. 与 wx_service 对齐
|
||||
|
||||
- 使用同一台服务器:`115.159.198.14`
|
||||
- SSH 用户:`root`
|
||||
- 可复用 `wx_service` 的 SSH 密钥体系
|
||||
- 不使用脚本内默认服务器参数,统一由仓库 Secrets 注入
|
||||
|
||||
## 1. Nginx 路径约定(当前配置)
|
||||
|
||||
```nginx
|
||||
location ^~ /panel/ {
|
||||
root /www/wwwroot/wx_service/web/admin-frontend;
|
||||
index index.html;
|
||||
try_files $uri $uri/ /panel/index.html;
|
||||
}
|
||||
```
|
||||
|
||||
对应发布目录:
|
||||
- `/www/wwwroot/wx_service/web/admin-frontend/panel`
|
||||
|
||||
## 2. 必填 GitHub Secrets
|
||||
|
||||
在 `admin-frontend` 仓库 `Settings -> Secrets and variables -> Actions` 配置:
|
||||
|
||||
- `PROD_SSH_KEY`:私钥内容(`~/.ssh/id_ed25519`)
|
||||
- `PROD_HOST`:`115.159.198.14`
|
||||
- `PROD_PORT`:`22`
|
||||
- `PROD_USER`:`root`
|
||||
- `ADMIN_WEB_ROOT`:`/www/wwwroot/wx_service/web/admin-frontend/panel`
|
||||
- `ADMIN_WEB_USER`:`www`
|
||||
- `ADMIN_WEB_GROUP`:`www`
|
||||
- `ADMIN_KEEP_BACKUPS`:`5`
|
||||
|
||||
可选:
|
||||
- `ADMIN_HEALTHCHECK_URL`:例如 `http://115.159.198.14/panel/`
|
||||
|
||||
## 3. 密钥说明
|
||||
|
||||
`~/.ssh/id_ed25519.pub` 是公钥,用于写入服务器 `~/.ssh/authorized_keys`。
|
||||
|
||||
GitHub Actions 里的 `PROD_SSH_KEY` 必须填写私钥:
|
||||
- `~/.ssh/id_ed25519`
|
||||
|
||||
## 4. 工作流文件
|
||||
|
||||
- `/.github/workflows/deploy-prod.yml`
|
||||
|
||||
每次主分支变更会自动:
|
||||
1. `npm ci`
|
||||
2. `npm run build`(生产基路径 `/panel/`)
|
||||
3. 上传构建包到服务器 `/tmp`
|
||||
4. 调用 `scripts/ops/deploy_static.sh` 覆盖发布
|
||||
|
||||
## 5. 回滚
|
||||
|
||||
在服务器执行:
|
||||
|
||||
```bash
|
||||
ls -lt /www/wwwroot/wx_service/web/admin-frontend/backups/admin-frontend.*.tar.gz
|
||||
|
||||
TARGET_DIR=/www/wwwroot/wx_service/web/admin-frontend/panel
|
||||
BACKUP_FILE=/www/wwwroot/wx_service/web/admin-frontend/backups/admin-frontend.<backup_id>.tar.gz
|
||||
|
||||
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
tar -xzf "$BACKUP_FILE" -C "$TARGET_DIR"
|
||||
chown -R www:www "$TARGET_DIR"
|
||||
```
|
||||
Executable
+121
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# 待发布的构建产物(tar.gz),由 CI 上传到服务器后传入
|
||||
DEPLOY_TAR="${DEPLOY_TAR:-}"
|
||||
# 静态站点目录(与 Nginx location /panel/ 对齐)
|
||||
TARGET_DIR="${TARGET_DIR:-}"
|
||||
# 发布标识(通常用 commit sha)
|
||||
RELEASE_ID="${RELEASE_ID:-}"
|
||||
# 文件归属用户/组
|
||||
RUN_USER="${RUN_USER:-}"
|
||||
RUN_GROUP="${RUN_GROUP:-}"
|
||||
# 备份保留数量
|
||||
KEEP_BACKUPS="${KEEP_BACKUPS:-}"
|
||||
# 可选:发布后探活地址,例如 http://example.com/panel/
|
||||
HEALTHCHECK_URL="${HEALTHCHECK_URL:-}"
|
||||
|
||||
BACKUP_ROOT="${BACKUP_ROOT:-$(dirname "$TARGET_DIR")/backups}"
|
||||
|
||||
log() {
|
||||
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"
|
||||
}
|
||||
|
||||
require_env() {
|
||||
local name="$1"
|
||||
local value="$2"
|
||||
if [ -z "$value" ]; then
|
||||
echo "missing required env: $name" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
health_check() {
|
||||
if [ -z "$HEALTHCHECK_URL" ]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if ! command -v curl >/dev/null 2>&1; then
|
||||
log "curl 不存在,跳过 HEALTHCHECK_URL 检查"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local tries=15
|
||||
while [ "$tries" -gt 0 ]; do
|
||||
if curl -fsS --max-time 5 "$HEALTHCHECK_URL" >/dev/null; then
|
||||
return 0
|
||||
fi
|
||||
tries=$((tries - 1))
|
||||
sleep 2
|
||||
done
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
require_env "DEPLOY_TAR" "$DEPLOY_TAR"
|
||||
require_env "TARGET_DIR" "$TARGET_DIR"
|
||||
require_env "RELEASE_ID" "$RELEASE_ID"
|
||||
require_env "RUN_USER" "$RUN_USER"
|
||||
require_env "RUN_GROUP" "$RUN_GROUP"
|
||||
require_env "KEEP_BACKUPS" "$KEEP_BACKUPS"
|
||||
|
||||
if [ ! -f "$DEPLOY_TAR" ]; then
|
||||
echo "deploy tar not found: $DEPLOY_TAR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
tmp_dir="/tmp/admin-frontend-${RELEASE_ID:0:12}-$$"
|
||||
backup_file=""
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$tmp_dir"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
log "deploy start, release: ${RELEASE_ID}"
|
||||
mkdir -p "$tmp_dir" "$TARGET_DIR" "$BACKUP_ROOT"
|
||||
|
||||
log "extracting artifact"
|
||||
tar -xzf "$DEPLOY_TAR" -C "$tmp_dir"
|
||||
|
||||
if [ ! -f "$tmp_dir/index.html" ]; then
|
||||
echo "invalid artifact: index.html not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 发布前备份当前站点,支持快速回滚
|
||||
if [ -n "$(ls -A "$TARGET_DIR" 2>/dev/null)" ]; then
|
||||
backup_file="$BACKUP_ROOT/admin-frontend.${RELEASE_ID}.tar.gz"
|
||||
tar -czf "$backup_file" -C "$TARGET_DIR" .
|
||||
log "backup created: $backup_file"
|
||||
fi
|
||||
|
||||
# 覆盖发布:先清空目录再写入新构建
|
||||
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
tar -cf - -C "$tmp_dir" . | tar -xf - -C "$TARGET_DIR"
|
||||
chown -R "$RUN_USER:$RUN_GROUP" "$TARGET_DIR" || true
|
||||
|
||||
if health_check; then
|
||||
if [ -n "$HEALTHCHECK_URL" ]; then
|
||||
log "health check success: $HEALTHCHECK_URL"
|
||||
fi
|
||||
else
|
||||
log "health check failed, rolling back"
|
||||
if [ -n "$backup_file" ] && [ -f "$backup_file" ]; then
|
||||
find "$TARGET_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
tar -xzf "$backup_file" -C "$TARGET_DIR"
|
||||
chown -R "$RUN_USER:$RUN_GROUP" "$TARGET_DIR" || true
|
||||
log "rollback done"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 清理上传到 /tmp 的临时制品
|
||||
if [[ "$DEPLOY_TAR" == /tmp/* ]]; then
|
||||
rm -f "$DEPLOY_TAR"
|
||||
fi
|
||||
|
||||
# 清理旧备份,仅保留最近 KEEP_BACKUPS 份
|
||||
ls -1t "$BACKUP_ROOT"/admin-frontend.*.tar.gz 2>/dev/null | tail -n +$((KEEP_BACKUPS + 1)) | xargs -r rm -f
|
||||
|
||||
log "deploy done"
|
||||
@@ -0,0 +1,37 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
export function listThemes() {
|
||||
return request({ url: '/api/admin/achievement/themes', method: 'get' })
|
||||
}
|
||||
|
||||
export function getTheme(id) {
|
||||
return request({ url: `/api/admin/achievement/themes/${id}`, method: 'get' })
|
||||
}
|
||||
|
||||
export function createTheme(data) {
|
||||
return request({ url: '/api/admin/achievement/themes', method: 'post', data })
|
||||
}
|
||||
|
||||
export function updateTheme(id, data) {
|
||||
return request({ url: `/api/admin/achievement/themes/${id}`, method: 'put', data })
|
||||
}
|
||||
|
||||
export function deleteTheme(id) {
|
||||
return request({ url: `/api/admin/achievement/themes/${id}`, method: 'delete' })
|
||||
}
|
||||
|
||||
export function listLevels(themeId) {
|
||||
return request({ url: `/api/admin/achievement/themes/${themeId}/levels`, method: 'get' })
|
||||
}
|
||||
|
||||
export function createLevel(data) {
|
||||
return request({ url: '/api/admin/achievement/levels', method: 'post', data })
|
||||
}
|
||||
|
||||
export function updateLevel(id, data) {
|
||||
return request({ url: `/api/admin/achievement/levels/${id}`, method: 'put', data })
|
||||
}
|
||||
|
||||
export function deleteLevel(id) {
|
||||
return request({ url: `/api/admin/achievement/levels/${id}`, method: 'delete' })
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
export function listDreamPresets() {
|
||||
return request({ url: '/api/admin/dream-presets', method: 'get' })
|
||||
}
|
||||
|
||||
export function createDreamPreset(data) {
|
||||
return request({ url: '/api/admin/dream-presets', method: 'post', data })
|
||||
}
|
||||
|
||||
export function updateDreamPreset(id, data) {
|
||||
return request({ url: `/api/admin/dream-presets/${id}`, method: 'put', data })
|
||||
}
|
||||
|
||||
export function deleteDreamPreset(id) {
|
||||
return request({ url: `/api/admin/dream-presets/${id}`, method: 'delete' })
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
export async function uploadToOSS(file) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
const res = await request({
|
||||
url: '/api/admin/marketing/upload',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 60000
|
||||
})
|
||||
return {
|
||||
url: res.data.url,
|
||||
thumbnailUrl: res.data.thumbnail_url || res.data.url
|
||||
}
|
||||
}
|
||||
|
||||
export function getMarketingStats() {
|
||||
return request({
|
||||
url: '/api/admin/marketing/stats',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function getMarketingCategories() {
|
||||
return request({
|
||||
url: '/api/admin/marketing/categories',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createMarketingCategory(data) {
|
||||
return request({
|
||||
url: '/api/admin/marketing/categories',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateMarketingCategory(id, data) {
|
||||
return request({
|
||||
url: `/api/admin/marketing/categories/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteMarketingCategory(id) {
|
||||
return request({
|
||||
url: `/api/admin/marketing/categories/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function getMarketingTemplates(params) {
|
||||
return request({
|
||||
url: '/api/admin/marketing/templates',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function createMarketingTemplate(data) {
|
||||
return request({
|
||||
url: '/api/admin/marketing/templates',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateMarketingTemplate(id, data) {
|
||||
return request({
|
||||
url: `/api/admin/marketing/templates/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteMarketingTemplate(id) {
|
||||
return request({
|
||||
url: `/api/admin/marketing/templates/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function getAdPlacements() {
|
||||
return request({
|
||||
url: '/api/admin/marketing/ad-placements',
|
||||
method: 'get'
|
||||
})
|
||||
}
|
||||
|
||||
export function createAdPlacement(data) {
|
||||
return request({
|
||||
url: '/api/admin/marketing/ad-placements',
|
||||
method: 'post',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function updateAdPlacement(id, data) {
|
||||
return request({
|
||||
url: `/api/admin/marketing/ad-placements/${id}`,
|
||||
method: 'put',
|
||||
data
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteAdPlacement(id) {
|
||||
return request({
|
||||
url: `/api/admin/marketing/ad-placements/${id}`,
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import request from '../utils/request'
|
||||
|
||||
// 戒烟记录(fa_smoke_log)
|
||||
export function listSmokeLogs(params) {
|
||||
return request({ url: '/api/admin/smoke/logs', method: 'get', params })
|
||||
}
|
||||
export function createSmokeLog(data) {
|
||||
return request({ url: '/api/admin/smoke/logs', method: 'post', data })
|
||||
}
|
||||
export function updateSmokeLog(id, data) {
|
||||
return request({ url: `/api/admin/smoke/logs/${id}`, method: 'put', data })
|
||||
}
|
||||
export function deleteSmokeLog(id) {
|
||||
return request({ url: `/api/admin/smoke/logs/${id}`, method: 'delete' })
|
||||
}
|
||||
|
||||
// 用户画像(fa_smoke_user_profile)
|
||||
export function listSmokeProfiles(params) {
|
||||
return request({ url: '/api/admin/smoke/profiles', method: 'get', params })
|
||||
}
|
||||
export function createSmokeProfile(data) {
|
||||
return request({ url: '/api/admin/smoke/profiles', method: 'post', data })
|
||||
}
|
||||
export function updateSmokeProfile(id, data) {
|
||||
return request({ url: `/api/admin/smoke/profiles/${id}`, method: 'put', data })
|
||||
}
|
||||
export function deleteSmokeProfile(id) {
|
||||
return request({ url: `/api/admin/smoke/profiles/${id}`, method: 'delete' })
|
||||
}
|
||||
|
||||
// AI 建议(fa_smoke_ai_advice)
|
||||
export function listSmokeAIAdvices(params) {
|
||||
return request({ url: '/api/admin/smoke/ai-advices', method: 'get', params })
|
||||
}
|
||||
export function createSmokeAIAdvice(data) {
|
||||
return request({ url: '/api/admin/smoke/ai-advices', method: 'post', data })
|
||||
}
|
||||
export function updateSmokeAIAdvice(id, data) {
|
||||
return request({ url: `/api/admin/smoke/ai-advices/${id}`, method: 'put', data })
|
||||
}
|
||||
export function deleteSmokeAIAdvice(id) {
|
||||
return request({ url: `/api/admin/smoke/ai-advices/${id}`, method: 'delete' })
|
||||
}
|
||||
|
||||
// AI 解锁(fa_smoke_ai_advice_unlocks)
|
||||
export function listSmokeAIUnlocks(params) {
|
||||
return request({ url: '/api/admin/smoke/ai-unlocks', method: 'get', params })
|
||||
}
|
||||
export function createSmokeAIUnlock(data) {
|
||||
return request({ url: '/api/admin/smoke/ai-unlocks', method: 'post', data })
|
||||
}
|
||||
export function updateSmokeAIUnlock(id, data) {
|
||||
return request({ url: `/api/admin/smoke/ai-unlocks/${id}`, method: 'put', data })
|
||||
}
|
||||
export function deleteSmokeAIUnlock(id) {
|
||||
return request({ url: `/api/admin/smoke/ai-unlocks/${id}`, method: 'delete' })
|
||||
}
|
||||
|
||||
// AI 下次抽烟节点(fa_smoke_ai_next_smoke)
|
||||
export function listSmokeAINexts(params) {
|
||||
return request({ url: '/api/admin/smoke/ai-next-smokes', method: 'get', params })
|
||||
}
|
||||
export function createSmokeAINext(data) {
|
||||
return request({ url: '/api/admin/smoke/ai-next-smokes', method: 'post', data })
|
||||
}
|
||||
export function updateSmokeAINext(id, data) {
|
||||
return request({ url: `/api/admin/smoke/ai-next-smokes/${id}`, method: 'put', data })
|
||||
}
|
||||
export function deleteSmokeAINext(id) {
|
||||
return request({ url: `/api/admin/smoke/ai-next-smokes/${id}`, method: 'delete' })
|
||||
}
|
||||
|
||||
// 激励语模板(fa_smoke_motivation_quote)
|
||||
export function listSmokeMotivations(params) {
|
||||
return request({ url: '/api/admin/smoke/motivation-quotes', method: 'get', params })
|
||||
}
|
||||
export function createSmokeMotivation(data) {
|
||||
return request({ url: '/api/admin/smoke/motivation-quotes', method: 'post', data })
|
||||
}
|
||||
export function updateSmokeMotivation(id, data) {
|
||||
return request({ url: `/api/admin/smoke/motivation-quotes/${id}`, method: 'put', data })
|
||||
}
|
||||
export function deleteSmokeMotivation(id) {
|
||||
return request({ url: `/api/admin/smoke/motivation-quotes/${id}`, method: 'delete' })
|
||||
}
|
||||
@@ -28,3 +28,27 @@ export function deleteWatermarkTask(id) {
|
||||
method: 'delete'
|
||||
})
|
||||
}
|
||||
|
||||
export function getVideoParseLogs(params) {
|
||||
return request({
|
||||
url: '/api/admin/watermark/video-parse-logs',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getVideoParseUnlocks(params) {
|
||||
return request({
|
||||
url: '/api/admin/watermark/video-parse-unlocks',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
export function getVideoDownloadFailures(params) {
|
||||
return request({
|
||||
url: '/api/admin/watermark/video-download-failures',
|
||||
method: 'get',
|
||||
params
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
<template>
|
||||
<div class="image-upload">
|
||||
<div v-if="modelValue" class="preview-wrapper">
|
||||
<el-image
|
||||
:src="modelValue"
|
||||
fit="contain"
|
||||
class="preview-img"
|
||||
:style="previewStyle"
|
||||
:preview-src-list="[modelValue]"
|
||||
/>
|
||||
<div class="preview-actions">
|
||||
<el-button link type="primary" @click="triggerUpload">
|
||||
<el-icon><Refresh /></el-icon> 替换
|
||||
</el-button>
|
||||
<el-button link type="danger" @click="handleRemove">
|
||||
<el-icon><Delete /></el-icon> 删除
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<el-upload
|
||||
v-else
|
||||
ref="uploadRef"
|
||||
:auto-upload="false"
|
||||
:show-file-list="false"
|
||||
:on-change="handleFileChange"
|
||||
accept="image/*"
|
||||
drag
|
||||
class="upload-dragger"
|
||||
>
|
||||
<div class="upload-placeholder" :style="placeholderStyle">
|
||||
<el-icon v-if="!uploading" class="upload-icon"><Plus /></el-icon>
|
||||
<el-progress
|
||||
v-if="uploading"
|
||||
type="circle"
|
||||
:percentage="progress"
|
||||
:width="48"
|
||||
:stroke-width="3"
|
||||
/>
|
||||
<div class="upload-text">{{ uploading ? '上传中...' : placeholder }}</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<input
|
||||
ref="hiddenInput"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
style="display: none"
|
||||
@change="handleHiddenInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Plus, Refresh, Delete } from '@element-plus/icons-vue'
|
||||
import { uploadToOSS } from '../api/marketing'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: String, default: '' },
|
||||
placeholder: { type: String, default: '点击或拖拽上传图片' },
|
||||
previewWidth: { type: String, default: '120px' },
|
||||
previewHeight: { type: String, default: '120px' },
|
||||
maxSize: { type: Number, default: 10 },
|
||||
onUploaded: { type: Function, default: null }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const uploadRef = ref(null)
|
||||
const hiddenInput = ref(null)
|
||||
const uploading = ref(false)
|
||||
const progress = ref(0)
|
||||
|
||||
const previewStyle = { width: props.previewWidth, height: props.previewHeight }
|
||||
const placeholderStyle = { width: props.previewWidth, height: props.previewHeight }
|
||||
|
||||
function triggerUpload() {
|
||||
hiddenInput.value?.click()
|
||||
}
|
||||
|
||||
function handleRemove() {
|
||||
emit('update:modelValue', '')
|
||||
}
|
||||
|
||||
async function handleHiddenInput(e) {
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
await doUpload(file)
|
||||
}
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
async function handleFileChange(uploadFile) {
|
||||
if (uploadFile?.raw) {
|
||||
await doUpload(uploadFile.raw)
|
||||
}
|
||||
}
|
||||
|
||||
async function doUpload(file) {
|
||||
if (file.size > props.maxSize * 1024 * 1024) {
|
||||
ElMessage.warning(`图片大小不能超过 ${props.maxSize}MB`)
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
progress.value = 0
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (progress.value < 90) progress.value += 10
|
||||
}, 200)
|
||||
|
||||
try {
|
||||
const result = await uploadToOSS(file)
|
||||
progress.value = 100
|
||||
emit('update:modelValue', result.url)
|
||||
if (props.onUploaded) props.onUploaded(result, file)
|
||||
} catch (e) {
|
||||
console.error('上传失败:', e)
|
||||
ElMessage.error('图片上传失败,请重试')
|
||||
} finally {
|
||||
clearInterval(timer)
|
||||
uploading.value = false
|
||||
progress.value = 0
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.image-upload {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.preview-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.preview-img {
|
||||
border-radius: 6px;
|
||||
border: 1px solid #e4e7ed;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.preview-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.upload-dragger :deep(.el-upload-dragger) {
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: none;
|
||||
}
|
||||
|
||||
.upload-placeholder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 6px;
|
||||
background: #fafafa;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.upload-placeholder:hover {
|
||||
border-color: #409eff;
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 24px;
|
||||
color: #8c939d;
|
||||
}
|
||||
|
||||
.upload-text {
|
||||
font-size: 12px;
|
||||
color: #8c939d;
|
||||
}
|
||||
</style>
|
||||
+113
-27
@@ -6,7 +6,6 @@
|
||||
@click="closeMobileMenu"
|
||||
></div>
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<el-aside :width="asideWidth" class="sidebar" :class="{ 'sidebar-mobile': isMobile }">
|
||||
<div class="logo">
|
||||
<span v-if="!isCollapse">管理后台</span>
|
||||
@@ -20,20 +19,36 @@
|
||||
router
|
||||
@select="handleMenuSelect"
|
||||
>
|
||||
<el-menu-item
|
||||
v-for="route in menuRoutes"
|
||||
:key="route.path"
|
||||
:index="route.path"
|
||||
>
|
||||
<el-icon><component :is="route.meta.icon" /></el-icon>
|
||||
<template #title>{{ route.meta.title }}</template>
|
||||
</el-menu-item>
|
||||
<template v-for="routeItem in menuRoutes" :key="routeKey(routeItem)">
|
||||
<el-sub-menu
|
||||
v-if="hasChildren(routeItem)"
|
||||
:index="resolvePath(routeItem.path)"
|
||||
>
|
||||
<template #title>
|
||||
<el-icon><component :is="routeItem.meta.icon" /></el-icon>
|
||||
<span>{{ routeItem.meta.title }}</span>
|
||||
</template>
|
||||
<el-menu-item
|
||||
v-for="child in visibleChildren(routeItem)"
|
||||
:key="routeKey(child)"
|
||||
:index="resolvePath(routeItem.path, child.path)"
|
||||
>
|
||||
{{ child.meta?.title || child.name }}
|
||||
</el-menu-item>
|
||||
</el-sub-menu>
|
||||
|
||||
<el-menu-item
|
||||
v-else
|
||||
:index="resolvePath(routeItem.path)"
|
||||
>
|
||||
<el-icon><component :is="routeItem.meta.icon" /></el-icon>
|
||||
<template #title>{{ routeItem.meta.title }}</template>
|
||||
</el-menu-item>
|
||||
</template>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
|
||||
<!-- 主内容区 -->
|
||||
<el-container>
|
||||
<!-- 顶部导航栏 -->
|
||||
<el-header class="header">
|
||||
<div class="header-left">
|
||||
<el-icon class="collapse-icon" @click="toggleCollapse">
|
||||
@@ -59,7 +74,6 @@
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<!-- 主内容 -->
|
||||
<el-main class="main-content">
|
||||
<router-view />
|
||||
</el-main>
|
||||
@@ -122,26 +136,51 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
// 获取菜单路由(过滤掉隐藏的路由)
|
||||
const menuRoutes = computed(() => {
|
||||
const routes = router.getRoutes()
|
||||
const mainRoute = routes.find(r => r.path === '/')
|
||||
if (!mainRoute || !mainRoute.children) return []
|
||||
|
||||
return mainRoute.children.filter(r => !r.meta?.hidden)
|
||||
const rootRoute = router.options.routes.find((item) => item.path === '/')
|
||||
if (!rootRoute || !rootRoute.children) return []
|
||||
return rootRoute.children.filter((item) => !item.meta?.hidden)
|
||||
})
|
||||
|
||||
// 当前激活的菜单
|
||||
const visibleChildren = (routeItem) => {
|
||||
return (routeItem.children || []).filter((child) => !child.meta?.hidden)
|
||||
}
|
||||
|
||||
const hasChildren = (routeItem) => {
|
||||
return visibleChildren(routeItem).length > 0
|
||||
}
|
||||
|
||||
const routeKey = (routeItem) => {
|
||||
return `${routeItem.path || ''}-${routeItem.name || ''}`
|
||||
}
|
||||
|
||||
const resolvePath = (parentPath = '', childPath = '') => {
|
||||
const normalize = (value) => {
|
||||
if (!value) return '/'
|
||||
return value.startsWith('/') ? value : `/${value}`
|
||||
}
|
||||
|
||||
const parent = normalize(parentPath)
|
||||
if (!childPath) {
|
||||
return parent
|
||||
}
|
||||
if (childPath.startsWith('/')) {
|
||||
return childPath
|
||||
}
|
||||
return `${parent.replace(/\/$/, '')}/${childPath}`.replace(/\/{2,}/g, '/')
|
||||
}
|
||||
|
||||
const activeMenu = computed(() => {
|
||||
const { path } = route
|
||||
// 如果是子路由,返回父路由路径
|
||||
if (path === '/watermark') {
|
||||
return '/watermark/video-parse-logs'
|
||||
}
|
||||
if (path.includes('/create') || path.includes('/edit') || /\/\d+$/.test(path)) {
|
||||
return '/' + path.split('/')[1]
|
||||
}
|
||||
return path
|
||||
})
|
||||
|
||||
// 切换侧边栏折叠状态
|
||||
const toggleCollapse = () => {
|
||||
if (isMobile.value) {
|
||||
mobileMenuVisible.value = !mobileMenuVisible.value
|
||||
@@ -167,7 +206,6 @@ const handleResize = () => {
|
||||
}
|
||||
}
|
||||
|
||||
// 处理下拉菜单命令
|
||||
const handleCommand = async (command) => {
|
||||
if (command === 'logout') {
|
||||
try {
|
||||
@@ -184,7 +222,10 @@ const handleCommand = async (command) => {
|
||||
// 用户取消
|
||||
}
|
||||
} else if (command === 'profile') {
|
||||
ElMessage.info('个人信息功能开发中...')
|
||||
router.push({
|
||||
path: '/settings',
|
||||
query: { tab: 'profile' }
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -214,16 +255,22 @@ const handleCommand = async (command) => {
|
||||
background: #2b3a4b;
|
||||
}
|
||||
|
||||
.el-menu {
|
||||
:deep(.el-menu) {
|
||||
border-right: none;
|
||||
background: #304156;
|
||||
background: transparent;
|
||||
--el-menu-bg-color: #304156;
|
||||
--el-menu-text-color: #bfcbd9;
|
||||
--el-menu-hover-bg-color: #263445;
|
||||
--el-menu-active-color: #ffffff;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item) {
|
||||
:deep(.el-menu-item),
|
||||
:deep(.el-sub-menu__title) {
|
||||
color: #bfcbd9;
|
||||
}
|
||||
|
||||
:deep(.el-menu-item:hover) {
|
||||
:deep(.el-menu-item:hover),
|
||||
:deep(.el-sub-menu__title:hover) {
|
||||
background: #263445 !important;
|
||||
color: #fff;
|
||||
}
|
||||
@@ -233,6 +280,45 @@ const handleCommand = async (command) => {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
:deep(.el-menu--inline) {
|
||||
background: linear-gradient(180deg, #27374a 0%, #243244 100%) !important;
|
||||
padding: 6px 0 10px;
|
||||
}
|
||||
|
||||
:deep(.el-menu--inline .el-menu-item) {
|
||||
min-width: auto;
|
||||
margin: 4px 10px;
|
||||
border-radius: 8px;
|
||||
color: #d7e2ee;
|
||||
}
|
||||
|
||||
:deep(.el-menu--inline .el-menu-item:hover) {
|
||||
background: #33475e !important;
|
||||
}
|
||||
|
||||
:deep(.el-menu--inline .el-menu-item.is-active) {
|
||||
background: #1f8fff !important;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
:deep(.el-menu--popup) {
|
||||
background: #263445 !important;
|
||||
border: 1px solid #3b4d63;
|
||||
}
|
||||
|
||||
:deep(.el-menu--popup .el-menu-item) {
|
||||
color: #d7e2ee;
|
||||
}
|
||||
|
||||
:deep(.el-menu--popup .el-menu-item:hover) {
|
||||
background: #33475e !important;
|
||||
}
|
||||
|
||||
:deep(.el-menu--popup .el-menu-item.is-active) {
|
||||
background: #1f8fff !important;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
+48
-3
@@ -51,6 +51,12 @@ const routes = [
|
||||
component: () => import('../views/users/detail.vue'),
|
||||
meta: { title: '用户详情', hidden: true }
|
||||
},
|
||||
{
|
||||
path: 'marketing',
|
||||
name: 'Marketing',
|
||||
component: () => import('../views/marketing/index.vue'),
|
||||
meta: { title: '营销图管理', icon: 'Picture' }
|
||||
},
|
||||
{
|
||||
path: 'memberships',
|
||||
name: 'Memberships',
|
||||
@@ -66,8 +72,47 @@ const routes = [
|
||||
{
|
||||
path: 'watermark',
|
||||
name: 'Watermark',
|
||||
component: () => import('../views/watermark/index.vue'),
|
||||
meta: { title: '去水印管理', icon: 'Brush' }
|
||||
component: () => import('../views/watermark/layout.vue'),
|
||||
redirect: '/watermark/video-parse-logs',
|
||||
meta: { title: '去水印小程序', icon: 'Brush' },
|
||||
children: [
|
||||
{
|
||||
path: 'video-parse-logs',
|
||||
name: 'VideoParseLogs',
|
||||
component: () => import('../views/watermark/video-parse-logs.vue'),
|
||||
meta: { title: '解析日志' }
|
||||
},
|
||||
{
|
||||
path: 'video-parse-unlocks',
|
||||
name: 'VideoParseUnlocks',
|
||||
component: () => import('../views/watermark/video-parse-unlocks.vue'),
|
||||
meta: { title: '广告解锁' }
|
||||
},
|
||||
{
|
||||
path: 'video-download-failures',
|
||||
name: 'VideoDownloadFailures',
|
||||
component: () => import('../views/watermark/video-download-failures.vue'),
|
||||
meta: { title: '下载失败上报' }
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
path: 'smoke',
|
||||
name: 'Smoke',
|
||||
component: () => import('../views/smoke/index.vue'),
|
||||
meta: { title: '戒烟小程序', icon: 'Opportunity' }
|
||||
},
|
||||
{
|
||||
path: 'achievement',
|
||||
name: 'Achievement',
|
||||
component: () => import('../views/achievement/index.vue'),
|
||||
meta: { title: '成就管理', icon: 'Trophy' }
|
||||
},
|
||||
{
|
||||
path: 'dream-presets',
|
||||
name: 'DreamPresets',
|
||||
component: () => import('../views/dream-presets/index.vue'),
|
||||
meta: { title: '梦想图标', icon: 'Flag' }
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
@@ -80,7 +125,7 @@ const routes = [
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import axios from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const loginPath = `${import.meta.env.BASE_URL}login`
|
||||
|
||||
// 创建 axios 实例
|
||||
const request = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080',
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/',
|
||||
timeout: 10000
|
||||
})
|
||||
|
||||
@@ -33,7 +35,7 @@ request.interceptors.response.use(
|
||||
// 401: 未登录
|
||||
if (res.code === 401) {
|
||||
localStorage.removeItem('admin_token')
|
||||
window.location.href = '/login'
|
||||
window.location.href = loginPath
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(res.message || '请求失败'))
|
||||
@@ -50,7 +52,7 @@ request.interceptors.response.use(
|
||||
if (status === 401) {
|
||||
ElMessage.error('登录已过期,请重新登录')
|
||||
localStorage.removeItem('admin_token')
|
||||
window.location.href = '/login'
|
||||
window.location.href = loginPath
|
||||
} else if (status === 403) {
|
||||
ElMessage.error('没有权限访问')
|
||||
} else if (status === 404) {
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<div class="achievement-page">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>成就主题管理</span>
|
||||
<el-button type="primary" @click="openThemeDialog()">新增主题</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table :data="themes" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="icon" label="图标" width="60" />
|
||||
<el-table-column prop="name" label="名称" width="100" />
|
||||
<el-table-column prop="key" label="标识" width="100" />
|
||||
<el-table-column label="等级数" width="80">
|
||||
<template #default="{ row }">{{ row.levels?.length || 0 }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="等级预览" min-width="280">
|
||||
<template #default="{ row }">
|
||||
<span v-for="(level, idx) in (row.levels || [])" :key="level.id" class="level-preview">
|
||||
{{ level.name }}({{ level.required_days }}天)<span v-if="idx < row.levels.length - 1"> → </span>
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort_order" label="排序" width="60" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
|
||||
{{ row.is_active ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openLevelDialog(row)">管理等级</el-button>
|
||||
<el-button size="small" @click="openThemeDialog(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDeleteTheme(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 主题编辑弹窗 -->
|
||||
<el-dialog v-model="themeDialogVisible" :title="editingTheme ? '编辑主题' : '新增主题'" width="460px">
|
||||
<el-form :model="themeForm" label-width="80px">
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="themeForm.name" placeholder="如:修仙" />
|
||||
</el-form-item>
|
||||
<el-form-item label="标识">
|
||||
<el-input v-model="themeForm.key" placeholder="如:xiuxian" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图标">
|
||||
<el-input v-model="themeForm.icon" placeholder="如:⚔️" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="themeForm.sort_order" :min="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="themeForm.is_active" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="themeDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveTheme" :loading="saving">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 等级管理弹窗 -->
|
||||
<el-dialog v-model="levelDialogVisible" :title="`管理等级 - ${currentTheme?.name || ''}`" width="680px">
|
||||
<div class="level-toolbar">
|
||||
<el-button type="primary" size="small" @click="openLevelForm()">新增等级</el-button>
|
||||
</div>
|
||||
<el-table :data="levels" v-loading="levelLoading" stripe size="small">
|
||||
<el-table-column prop="name" label="名称" width="120" />
|
||||
<el-table-column prop="icon" label="图标" width="60" />
|
||||
<el-table-column prop="required_days" label="所需天数" width="100" />
|
||||
<el-table-column prop="sort_order" label="排序" width="60" />
|
||||
<el-table-column label="操作" width="160">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openLevelForm(row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="handleDeleteLevel(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<el-divider v-if="showLevelForm" />
|
||||
<el-form v-if="showLevelForm" :model="levelForm" label-width="80px" size="small" class="level-form">
|
||||
<el-form-item label="名称">
|
||||
<el-input v-model="levelForm.name" placeholder="如:炼体" />
|
||||
</el-form-item>
|
||||
<el-form-item label="图标">
|
||||
<el-input v-model="levelForm.icon" placeholder="可选" />
|
||||
</el-form-item>
|
||||
<el-form-item label="所需天数">
|
||||
<el-input-number v-model="levelForm.required_days" :min="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="levelForm.sort_order" :min="0" />
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button @click="showLevelForm = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveLevel" :loading="levelSaving">保存</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import {
|
||||
listThemes, createTheme, updateTheme, deleteTheme,
|
||||
listLevels, createLevel, updateLevel, deleteLevel
|
||||
} from '../../api/achievement'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const themes = ref([])
|
||||
const themeDialogVisible = ref(false)
|
||||
const editingTheme = ref(null)
|
||||
const themeForm = ref({ name: '', key: '', icon: '', sort_order: 0, is_active: true })
|
||||
|
||||
const levelDialogVisible = ref(false)
|
||||
const levelLoading = ref(false)
|
||||
const levelSaving = ref(false)
|
||||
const currentTheme = ref(null)
|
||||
const levels = ref([])
|
||||
const showLevelForm = ref(false)
|
||||
const editingLevel = ref(null)
|
||||
const levelForm = ref({ name: '', icon: '', required_days: 0, sort_order: 0 })
|
||||
|
||||
async function loadThemes() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listThemes()
|
||||
themes.value = res.data?.themes || []
|
||||
} catch (e) {
|
||||
ElMessage.error('加载主题失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openThemeDialog(theme = null) {
|
||||
editingTheme.value = theme
|
||||
if (theme) {
|
||||
themeForm.value = { name: theme.name, key: theme.key, icon: theme.icon, sort_order: theme.sort_order, is_active: theme.is_active }
|
||||
} else {
|
||||
themeForm.value = { name: '', key: '', icon: '', sort_order: 0, is_active: true }
|
||||
}
|
||||
themeDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function saveTheme() {
|
||||
if (!themeForm.value.name || !themeForm.value.key) {
|
||||
ElMessage.warning('名称和标识必填')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
if (editingTheme.value) {
|
||||
await updateTheme(editingTheme.value.id, themeForm.value)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await createTheme(themeForm.value)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
themeDialogVisible.value = false
|
||||
await loadThemes()
|
||||
} catch (e) {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteTheme(theme) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除主题「${theme.name}」?`, '确认')
|
||||
await deleteTheme(theme.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadThemes()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
async function openLevelDialog(theme) {
|
||||
currentTheme.value = theme
|
||||
levelDialogVisible.value = true
|
||||
showLevelForm.value = false
|
||||
await loadLevels(theme.id)
|
||||
}
|
||||
|
||||
async function loadLevels(themeId) {
|
||||
levelLoading.value = true
|
||||
try {
|
||||
const res = await listLevels(themeId)
|
||||
levels.value = res.data?.levels || []
|
||||
} catch (e) {
|
||||
ElMessage.error('加载等级失败')
|
||||
} finally {
|
||||
levelLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openLevelForm(level = null) {
|
||||
editingLevel.value = level
|
||||
if (level) {
|
||||
levelForm.value = { name: level.name, icon: level.icon || '', required_days: level.required_days, sort_order: level.sort_order }
|
||||
} else {
|
||||
levelForm.value = { name: '', icon: '', required_days: 0, sort_order: levels.value.length }
|
||||
}
|
||||
showLevelForm.value = true
|
||||
}
|
||||
|
||||
async function saveLevel() {
|
||||
if (!levelForm.value.name) {
|
||||
ElMessage.warning('名称必填')
|
||||
return
|
||||
}
|
||||
levelSaving.value = true
|
||||
try {
|
||||
if (editingLevel.value) {
|
||||
await updateLevel(editingLevel.value.id, levelForm.value)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await createLevel({ ...levelForm.value, theme_id: currentTheme.value.id })
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
showLevelForm.value = false
|
||||
await loadLevels(currentTheme.value.id)
|
||||
await loadThemes()
|
||||
} catch (e) {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
levelSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteLevel(level) {
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定删除等级「${level.name}」?`, '确认')
|
||||
await deleteLevel(level.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadLevels(currentTheme.value.id)
|
||||
await loadThemes()
|
||||
} catch (e) {
|
||||
if (e !== 'cancel') ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadThemes()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.level-preview {
|
||||
font-size: 13px;
|
||||
color: #606266;
|
||||
}
|
||||
.level-toolbar {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.level-form {
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<div class="dream-icons-page">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>梦想目标图标管理</span>
|
||||
<el-button type="primary" @click="openDialog()">新增图标</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-alert type="info" :closable="false" style="margin-bottom: 16px">
|
||||
用户添加梦想目标时,从此处配置的图标中选择。名称和价格由用户自行填写。
|
||||
</el-alert>
|
||||
|
||||
<el-table :data="icons" v-loading="loading" stripe>
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column label="图标" width="100">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.cover_image && row.cover_image.startsWith('icon:')" style="font-size: 28px">
|
||||
{{ row.cover_image.replace('icon:', '') }}
|
||||
</span>
|
||||
<el-image v-else-if="row.cover_image" :src="row.cover_image" style="width: 44px; height: 44px; border-radius: 8px" fit="cover" />
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="图标名称" min-width="120">
|
||||
<template #default="{ row }">
|
||||
<span style="color: #999">{{ row.title || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort_order" label="排序" width="80" />
|
||||
<el-table-column label="状态" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.is_active ? 'success' : 'info'" size="small">
|
||||
{{ row.is_active ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button size="small" @click="openDialog(row)">编辑</el-button>
|
||||
<el-popconfirm title="确定删除?" @confirm="handleDelete(row.id)">
|
||||
<template #reference>
|
||||
<el-button size="small" type="danger">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="editingId ? '编辑图标' : '新增图标'" width="480px">
|
||||
<el-form :model="form" label-width="90px">
|
||||
<el-form-item label="图标名称">
|
||||
<el-input v-model="form.title" placeholder="备注名(如:耳机、手机)" maxlength="20" />
|
||||
</el-form-item>
|
||||
<el-form-item label="Emoji" required>
|
||||
<div class="icon-grid">
|
||||
<div
|
||||
v-for="icon in emojiOptions"
|
||||
:key="icon"
|
||||
class="icon-option"
|
||||
:class="{ active: form.cover_image === `icon:${icon}` }"
|
||||
@click="selectEmoji(icon)"
|
||||
>
|
||||
{{ icon }}
|
||||
</div>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="或图片URL">
|
||||
<el-input v-model="form.imageUrl" placeholder="https://... 图片链接(优先于 Emoji)" @input="onImageUrlInput" />
|
||||
</el-form-item>
|
||||
<el-form-item label="预览" v-if="previewImage">
|
||||
<div class="preview-box">
|
||||
<span v-if="previewImage.startsWith('icon:')" style="font-size: 36px">{{ previewImage.replace('icon:', '') }}</span>
|
||||
<el-image v-else :src="previewImage" style="width: 60px; height: 60px; border-radius: 10px" fit="cover" />
|
||||
</div>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="form.sort_order" :min="0" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="form.is_active" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSave">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { listDreamPresets, createDreamPreset, updateDreamPreset, deleteDreamPreset } from '../../api/dreamPreset'
|
||||
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const icons = ref([])
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref(null)
|
||||
|
||||
const emojiOptions = [
|
||||
'🎧', '👟', '📱', '⌚', '🎮', '📷',
|
||||
'💻', '🎸', '🏖️', '🎂', '🎁', '🚲',
|
||||
'🎒', '👜', '🕶️', '🧸', '🏠', '✈️',
|
||||
'🚗', '💎', '🎨', '📚', '🍰', '🌸',
|
||||
]
|
||||
|
||||
const defaultForm = () => ({
|
||||
title: '',
|
||||
cover_image: '',
|
||||
imageUrl: '',
|
||||
sort_order: 0,
|
||||
is_active: true,
|
||||
})
|
||||
|
||||
const form = ref(defaultForm())
|
||||
|
||||
const previewImage = computed(() => {
|
||||
if (form.value.imageUrl.trim()) return form.value.imageUrl.trim()
|
||||
if (form.value.cover_image) return form.value.cover_image
|
||||
return ''
|
||||
})
|
||||
|
||||
function selectEmoji(emoji) {
|
||||
form.value.cover_image = `icon:${emoji}`
|
||||
form.value.imageUrl = ''
|
||||
}
|
||||
|
||||
function onImageUrlInput() {
|
||||
if (form.value.imageUrl.trim()) {
|
||||
form.value.cover_image = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchList() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await listDreamPresets()
|
||||
icons.value = res.data?.items || []
|
||||
} catch (e) {
|
||||
ElMessage.error('获取失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openDialog(row) {
|
||||
if (row) {
|
||||
editingId.value = row.id
|
||||
const isIcon = row.cover_image?.startsWith('icon:')
|
||||
form.value = {
|
||||
title: row.title || '',
|
||||
cover_image: isIcon ? row.cover_image : '',
|
||||
imageUrl: isIcon ? '' : (row.cover_image || ''),
|
||||
sort_order: row.sort_order,
|
||||
is_active: row.is_active,
|
||||
}
|
||||
} else {
|
||||
editingId.value = null
|
||||
form.value = defaultForm()
|
||||
}
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
const coverImage = form.value.imageUrl.trim() || form.value.cover_image || ''
|
||||
if (!coverImage) {
|
||||
ElMessage.warning('请选择 Emoji 或填入图片链接')
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
const data = {
|
||||
title: form.value.title.trim(),
|
||||
cover_image: coverImage,
|
||||
sort_order: form.value.sort_order,
|
||||
is_active: form.value.is_active,
|
||||
}
|
||||
try {
|
||||
if (editingId.value) {
|
||||
await updateDreamPreset(editingId.value, data)
|
||||
ElMessage.success('更新成功')
|
||||
} else {
|
||||
await createDreamPreset(data)
|
||||
ElMessage.success('创建成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await fetchList()
|
||||
} catch (e) {
|
||||
ElMessage.error('保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id) {
|
||||
try {
|
||||
await deleteDreamPreset(id)
|
||||
ElMessage.success('已删除')
|
||||
await fetchList()
|
||||
} catch (e) {
|
||||
ElMessage.error('删除失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.icon-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.icon-option {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
background: #f5f7fa;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.icon-option:hover {
|
||||
border-color: #c6e7d9;
|
||||
}
|
||||
|
||||
.icon-option.active {
|
||||
border-color: #14936d;
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
.preview-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
border-radius: 12px;
|
||||
background: #f9fafb;
|
||||
border: 1px dashed #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,747 @@
|
||||
<template>
|
||||
<div class="marketing-page">
|
||||
<el-alert
|
||||
v-if="errorMessage"
|
||||
:title="errorMessage"
|
||||
type="error"
|
||||
show-icon
|
||||
closable
|
||||
@close="errorMessage = ''"
|
||||
/>
|
||||
|
||||
<el-row :gutter="16" class="cards" v-loading="statsLoading">
|
||||
<el-col :xs="12" :sm="12" :md="6">
|
||||
<el-card shadow="hover">
|
||||
<div class="card-value">{{ stats.categoryCount }}</div>
|
||||
<div class="card-label">分类总数</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="12" :md="6">
|
||||
<el-card shadow="hover">
|
||||
<div class="card-value">{{ stats.templateCount }}</div>
|
||||
<div class="card-label">模板总数</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="12" :md="6">
|
||||
<el-card shadow="hover">
|
||||
<div class="card-value">{{ stats.totalDownloads }}</div>
|
||||
<div class="card-label">总下载次数</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="12" :md="6">
|
||||
<el-card shadow="hover">
|
||||
<div class="card-value">{{ stats.todayDownloads }}</div>
|
||||
<div class="card-label">今日下载</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="header-row">
|
||||
<span>分类管理</span>
|
||||
<el-button type="primary" @click="openCategoryDialog()">新增分类</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table v-loading="categoriesLoading" :data="categories" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="图标" width="80">
|
||||
<template #default="{ row }">
|
||||
<el-image v-if="row.icon" :src="row.icon" fit="contain" style="width: 32px; height: 32px" />
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="名称" min-width="160" />
|
||||
<el-table-column prop="sort_order" label="排序" width="90" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openCategoryDialog(row)">编辑</el-button>
|
||||
<el-popconfirm title="确认删除该分类?" @confirm="handleDeleteCategory(row.id)">
|
||||
<template #reference>
|
||||
<el-button link type="danger">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="header-row">
|
||||
<div class="left-tools">
|
||||
<span>模板管理</span>
|
||||
<el-select
|
||||
v-model="templateQuery.category_id"
|
||||
clearable
|
||||
placeholder="按分类筛选"
|
||||
style="width: 180px"
|
||||
@change="handleTemplateFilterChange"
|
||||
>
|
||||
<el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
<el-button type="primary" @click="openTemplateDialog()">新增模板</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table v-loading="templatesLoading" :data="templateList" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="预览" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-image
|
||||
v-if="row.thumbnail_url || row.image_url"
|
||||
:src="row.thumbnail_url || row.image_url"
|
||||
fit="cover"
|
||||
style="width: 56px; height: 56px; border-radius: 4px"
|
||||
:preview-src-list="[row.image_url]"
|
||||
/>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="title" label="模板名称" min-width="180" />
|
||||
<el-table-column label="分类" min-width="130">
|
||||
<template #default="{ row }">
|
||||
{{ row.category?.name || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="尺寸" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ row.width || 0 }} x {{ row.height || 0 }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="sort_order" label="排序" width="90" />
|
||||
<el-table-column prop="download_count" label="下载次数" width="100" />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openTemplateDialog(row)">编辑</el-button>
|
||||
<el-popconfirm title="确认删除该模板?" @confirm="handleDeleteTemplate(row.id)">
|
||||
<template #reference>
|
||||
<el-button link type="danger">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="templateQuery.page"
|
||||
v-model:page-size="templateQuery.page_size"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:page-sizes="[10, 20, 50]"
|
||||
:total="templateTotal"
|
||||
@current-change="loadTemplates"
|
||||
@size-change="handleTemplatePageSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 广告位管理 -->
|
||||
<el-card class="section-card">
|
||||
<template #header>
|
||||
<div class="header-row">
|
||||
<span>广告位管理</span>
|
||||
<el-button type="primary" @click="openAdDialog()">新增广告位</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table v-loading="adsLoading" :data="adPlacements" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="广告位名称" min-width="140" />
|
||||
<el-table-column prop="ad_type" label="广告类型" width="140">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" :type="row.ad_type === 'rewarded_video' ? 'warning' : 'info'">
|
||||
{{ { rewarded_video: '激励视频', banner: 'Banner', interstitial: '插屏' }[row.ad_type] || row.ad_type }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="ad_unit_id" label="广告单元 ID" min-width="200">
|
||||
<template #default="{ row }">
|
||||
<span>{{ row.ad_unit_id || '-' }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="mini_program_id" label="小程序 ID" width="100" />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 1 ? 'success' : 'info'" size="small">
|
||||
{{ row.status === 1 ? '启用' : '禁用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="备注" min-width="140" />
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openAdDialog(row)">编辑</el-button>
|
||||
<el-popconfirm title="确认删除该广告位?" @confirm="handleDeleteAd(row.id)">
|
||||
<template #reference>
|
||||
<el-button link type="danger">删除</el-button>
|
||||
</template>
|
||||
</el-popconfirm>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<!-- 广告位弹窗 -->
|
||||
<el-dialog v-model="adDialogVisible" :title="adForm.id ? '编辑广告位' : '新增广告位'" width="520px">
|
||||
<el-form label-width="110px">
|
||||
<el-form-item label="广告位名称" required>
|
||||
<el-input v-model="adForm.name" placeholder="如: 保存营销图" />
|
||||
</el-form-item>
|
||||
<el-form-item label="广告类型" required>
|
||||
<el-select v-model="adForm.ad_type" style="width: 100%">
|
||||
<el-option label="激励视频" value="rewarded_video" />
|
||||
<el-option label="Banner" value="banner" />
|
||||
<el-option label="插屏" value="interstitial" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="广告单元 ID">
|
||||
<el-input v-model="adForm.ad_unit_id" placeholder="在微信后台申请后填入" />
|
||||
</el-form-item>
|
||||
<el-form-item label="小程序 ID" required>
|
||||
<el-input-number v-model="adForm.mini_program_id" :min="1" />
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-switch v-model="adForm.statusBool" active-text="启用" inactive-text="禁用" />
|
||||
</el-form-item>
|
||||
<el-form-item label="备注">
|
||||
<el-input v-model="adForm.description" type="textarea" :rows="2" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="adDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="adSaving" @click="saveAd">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="categoryDialogVisible"
|
||||
:title="categoryForm.id ? '编辑分类' : '新增分类'"
|
||||
width="520px"
|
||||
>
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="名称" required>
|
||||
<el-input v-model="categoryForm.name" maxlength="50" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="图标">
|
||||
<ImageUpload
|
||||
v-model="categoryForm.icon"
|
||||
placeholder="上传图标"
|
||||
preview-width="64px"
|
||||
preview-height="64px"
|
||||
:max-size="2"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="categoryForm.sort_order" :min="0" :max="9999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="categoryForm.statusBool" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="categoryDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="categorySaving" @click="saveCategory">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog
|
||||
v-model="templateDialogVisible"
|
||||
:title="templateForm.id ? '编辑模板' : '新增模板'"
|
||||
width="620px"
|
||||
>
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="模板名称" required>
|
||||
<el-input v-model="templateForm.title" maxlength="100" show-word-limit />
|
||||
</el-form-item>
|
||||
<el-form-item label="所属分类" required>
|
||||
<el-select v-model="templateForm.category_id" placeholder="请选择分类" style="width: 100%">
|
||||
<el-option v-for="item in categories" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="模板图片" required>
|
||||
<ImageUpload
|
||||
v-model="templateForm.image_url"
|
||||
placeholder="上传模板原图"
|
||||
preview-width="160px"
|
||||
preview-height="200px"
|
||||
:max-size="10"
|
||||
:on-uploaded="onTemplateImageUploaded"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="缩略图">
|
||||
<ImageUpload
|
||||
v-model="templateForm.thumbnail_url"
|
||||
placeholder="上传缩略图(可选)"
|
||||
preview-width="100px"
|
||||
preview-height="100px"
|
||||
:max-size="5"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="宽度(px)">
|
||||
<el-input-number v-model="templateForm.width" :min="0" :max="10000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="高度(px)">
|
||||
<el-input-number v-model="templateForm.height" :min="0" :max="10000" />
|
||||
</el-form-item>
|
||||
<el-form-item label="排序">
|
||||
<el-input-number v-model="templateForm.sort_order" :min="0" :max="9999" />
|
||||
</el-form-item>
|
||||
<el-form-item label="启用">
|
||||
<el-switch v-model="templateForm.statusBool" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="templateDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="templateSaving" @click="saveTemplate">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import ImageUpload from '../../components/ImageUpload.vue'
|
||||
import {
|
||||
createMarketingCategory,
|
||||
createMarketingTemplate,
|
||||
deleteMarketingCategory,
|
||||
deleteMarketingTemplate,
|
||||
getMarketingCategories,
|
||||
getMarketingStats,
|
||||
getMarketingTemplates,
|
||||
updateMarketingCategory,
|
||||
updateMarketingTemplate,
|
||||
getAdPlacements,
|
||||
createAdPlacement,
|
||||
updateAdPlacement,
|
||||
deleteAdPlacement
|
||||
} from '../../api/marketing'
|
||||
|
||||
const errorMessage = ref('')
|
||||
|
||||
const statsLoading = ref(false)
|
||||
const categoriesLoading = ref(false)
|
||||
const templatesLoading = ref(false)
|
||||
|
||||
const stats = reactive({
|
||||
categoryCount: 0,
|
||||
templateCount: 0,
|
||||
totalDownloads: 0,
|
||||
todayDownloads: 0
|
||||
})
|
||||
|
||||
const categories = ref([])
|
||||
|
||||
const templateQuery = reactive({
|
||||
category_id: undefined,
|
||||
page: 1,
|
||||
page_size: 20
|
||||
})
|
||||
const templateList = ref([])
|
||||
const templateTotal = ref(0)
|
||||
|
||||
const categoryDialogVisible = ref(false)
|
||||
const categorySaving = ref(false)
|
||||
const categoryForm = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
icon: '',
|
||||
sort_order: 0,
|
||||
statusBool: true
|
||||
})
|
||||
|
||||
const templateDialogVisible = ref(false)
|
||||
const templateSaving = ref(false)
|
||||
const templateForm = reactive({
|
||||
id: null,
|
||||
title: '',
|
||||
category_id: null,
|
||||
image_url: '',
|
||||
thumbnail_url: '',
|
||||
width: 0,
|
||||
height: 0,
|
||||
sort_order: 0,
|
||||
statusBool: true
|
||||
})
|
||||
|
||||
const adPlacements = ref([])
|
||||
const adsLoading = ref(false)
|
||||
const adDialogVisible = ref(false)
|
||||
const adSaving = ref(false)
|
||||
const adForm = reactive({
|
||||
id: null,
|
||||
name: '',
|
||||
ad_type: 'rewarded_video',
|
||||
ad_unit_id: '',
|
||||
mini_program_id: 3,
|
||||
statusBool: true,
|
||||
description: ''
|
||||
})
|
||||
|
||||
const parseDownloads = (data, key) => {
|
||||
const candidates = [
|
||||
data?.[key],
|
||||
data?.[key.charAt(0).toUpperCase() + key.slice(1)],
|
||||
data?.[key.replace(/[A-Z]/g, (match) => `_${match.toLowerCase()}`)]
|
||||
]
|
||||
const value = candidates.find((item) => typeof item === 'number')
|
||||
return value || 0
|
||||
}
|
||||
|
||||
const loadCategories = async () => {
|
||||
categoriesLoading.value = true
|
||||
try {
|
||||
const res = await getMarketingCategories()
|
||||
categories.value = res.data || []
|
||||
stats.categoryCount = categories.value.length
|
||||
} catch (error) {
|
||||
errorMessage.value = '加载分类失败,请稍后重试'
|
||||
} finally {
|
||||
categoriesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadTemplates = async () => {
|
||||
templatesLoading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: templateQuery.page,
|
||||
page_size: templateQuery.page_size
|
||||
}
|
||||
if (templateQuery.category_id) {
|
||||
params.category_id = templateQuery.category_id
|
||||
}
|
||||
|
||||
const res = await getMarketingTemplates(params)
|
||||
const payload = res.data || {}
|
||||
templateList.value = payload.templates || []
|
||||
templateTotal.value = payload.total || 0
|
||||
} catch (error) {
|
||||
errorMessage.value = '加载模板失败,请稍后重试'
|
||||
} finally {
|
||||
templatesLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadTemplateCount = async () => {
|
||||
try {
|
||||
const res = await getMarketingTemplates({ page: 1, page_size: 1 })
|
||||
const payload = res.data || {}
|
||||
stats.templateCount = payload.total || 0
|
||||
} catch (error) {
|
||||
stats.templateCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
statsLoading.value = true
|
||||
try {
|
||||
const res = await getMarketingStats()
|
||||
const payload = res.data || {}
|
||||
stats.totalDownloads = parseDownloads(payload, 'totalDownloads')
|
||||
stats.todayDownloads = parseDownloads(payload, 'todayDownloads')
|
||||
} catch (error) {
|
||||
errorMessage.value = '加载营销统计失败,请稍后重试'
|
||||
} finally {
|
||||
statsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadAdPlacements = async () => {
|
||||
adsLoading.value = true
|
||||
try {
|
||||
const res = await getAdPlacements()
|
||||
adPlacements.value = res.data || []
|
||||
} catch (error) {
|
||||
errorMessage.value = '加载广告位失败'
|
||||
} finally {
|
||||
adsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openAdDialog = (row) => {
|
||||
if (!row) {
|
||||
adForm.id = null
|
||||
adForm.name = ''
|
||||
adForm.ad_type = 'rewarded_video'
|
||||
adForm.ad_unit_id = ''
|
||||
adForm.mini_program_id = 3
|
||||
adForm.statusBool = true
|
||||
adForm.description = ''
|
||||
} else {
|
||||
adForm.id = row.id
|
||||
adForm.name = row.name || ''
|
||||
adForm.ad_type = row.ad_type || 'rewarded_video'
|
||||
adForm.ad_unit_id = row.ad_unit_id || ''
|
||||
adForm.mini_program_id = row.mini_program_id || 3
|
||||
adForm.statusBool = row.status === 1
|
||||
adForm.description = row.description || ''
|
||||
}
|
||||
adDialogVisible.value = true
|
||||
}
|
||||
|
||||
const saveAd = async () => {
|
||||
if (!adForm.name.trim()) {
|
||||
ElMessage.warning('请填写广告位名称')
|
||||
return
|
||||
}
|
||||
adSaving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
name: adForm.name.trim(),
|
||||
ad_type: adForm.ad_type,
|
||||
ad_unit_id: adForm.ad_unit_id.trim(),
|
||||
mini_program_id: adForm.mini_program_id,
|
||||
status: adForm.statusBool ? 1 : 0,
|
||||
description: adForm.description.trim()
|
||||
}
|
||||
if (adForm.id) {
|
||||
await updateAdPlacement(adForm.id, payload)
|
||||
ElMessage.success('广告位更新成功')
|
||||
} else {
|
||||
await createAdPlacement(payload)
|
||||
ElMessage.success('广告位创建成功')
|
||||
}
|
||||
adDialogVisible.value = false
|
||||
await loadAdPlacements()
|
||||
} finally {
|
||||
adSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteAd = async (id) => {
|
||||
await deleteAdPlacement(id)
|
||||
ElMessage.success('广告位删除成功')
|
||||
await loadAdPlacements()
|
||||
}
|
||||
|
||||
const loadAll = async () => {
|
||||
errorMessage.value = ''
|
||||
await Promise.all([loadCategories(), loadTemplates(), loadTemplateCount(), loadStats(), loadAdPlacements()])
|
||||
}
|
||||
|
||||
const resetCategoryForm = () => {
|
||||
categoryForm.id = null
|
||||
categoryForm.name = ''
|
||||
categoryForm.icon = ''
|
||||
categoryForm.sort_order = 0
|
||||
categoryForm.statusBool = true
|
||||
}
|
||||
|
||||
const openCategoryDialog = (row) => {
|
||||
if (!row) {
|
||||
resetCategoryForm()
|
||||
} else {
|
||||
categoryForm.id = row.id
|
||||
categoryForm.name = row.name || ''
|
||||
categoryForm.icon = row.icon || ''
|
||||
categoryForm.sort_order = row.sort_order || 0
|
||||
categoryForm.statusBool = row.status === 1
|
||||
}
|
||||
categoryDialogVisible.value = true
|
||||
}
|
||||
|
||||
const saveCategory = async () => {
|
||||
const name = (categoryForm.name || '').trim()
|
||||
if (!name) {
|
||||
ElMessage.warning('请先填写分类名称')
|
||||
return
|
||||
}
|
||||
|
||||
categorySaving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
name,
|
||||
icon: (categoryForm.icon || '').trim(),
|
||||
sort_order: Number(categoryForm.sort_order || 0),
|
||||
status: categoryForm.statusBool ? 1 : 0
|
||||
}
|
||||
if (categoryForm.id) {
|
||||
await updateMarketingCategory(categoryForm.id, payload)
|
||||
ElMessage.success('分类更新成功')
|
||||
} else {
|
||||
await createMarketingCategory(payload)
|
||||
ElMessage.success('分类创建成功')
|
||||
}
|
||||
categoryDialogVisible.value = false
|
||||
await Promise.all([loadCategories(), loadTemplateCount()])
|
||||
} finally {
|
||||
categorySaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteCategory = async (id) => {
|
||||
await deleteMarketingCategory(id)
|
||||
ElMessage.success('分类删除成功')
|
||||
await Promise.all([loadCategories(), loadTemplates(), loadTemplateCount()])
|
||||
}
|
||||
|
||||
const resetTemplateForm = () => {
|
||||
templateForm.id = null
|
||||
templateForm.title = ''
|
||||
templateForm.category_id = null
|
||||
templateForm.image_url = ''
|
||||
templateForm.thumbnail_url = ''
|
||||
templateForm.width = 0
|
||||
templateForm.height = 0
|
||||
templateForm.sort_order = 0
|
||||
templateForm.statusBool = true
|
||||
}
|
||||
|
||||
const openTemplateDialog = (row) => {
|
||||
if (!row) {
|
||||
resetTemplateForm()
|
||||
} else {
|
||||
templateForm.id = row.id
|
||||
templateForm.title = row.title || ''
|
||||
templateForm.category_id = row.category_id || null
|
||||
templateForm.image_url = row.image_url || ''
|
||||
templateForm.thumbnail_url = row.thumbnail_url || ''
|
||||
templateForm.width = row.width || 0
|
||||
templateForm.height = row.height || 0
|
||||
templateForm.sort_order = row.sort_order || 0
|
||||
templateForm.statusBool = row.status === 1
|
||||
}
|
||||
templateDialogVisible.value = true
|
||||
}
|
||||
|
||||
const onTemplateImageUploaded = (result) => {
|
||||
if (result.thumbnailUrl && !templateForm.thumbnail_url) {
|
||||
templateForm.thumbnail_url = result.thumbnailUrl
|
||||
}
|
||||
const img = new Image()
|
||||
img.onload = () => {
|
||||
templateForm.width = img.naturalWidth
|
||||
templateForm.height = img.naturalHeight
|
||||
}
|
||||
img.src = result.url
|
||||
}
|
||||
|
||||
const saveTemplate = async () => {
|
||||
if (!(templateForm.title || '').trim()) {
|
||||
ElMessage.warning('请先填写模板名称')
|
||||
return
|
||||
}
|
||||
if (!templateForm.category_id) {
|
||||
ElMessage.warning('请选择所属分类')
|
||||
return
|
||||
}
|
||||
if (!(templateForm.image_url || '').trim()) {
|
||||
ElMessage.warning('请先上传模板图片')
|
||||
return
|
||||
}
|
||||
|
||||
templateSaving.value = true
|
||||
try {
|
||||
const payload = {
|
||||
title: (templateForm.title || '').trim(),
|
||||
category_id: Number(templateForm.category_id),
|
||||
image_url: (templateForm.image_url || '').trim(),
|
||||
thumbnail_url: (templateForm.thumbnail_url || '').trim(),
|
||||
width: Number(templateForm.width || 0),
|
||||
height: Number(templateForm.height || 0),
|
||||
sort_order: Number(templateForm.sort_order || 0),
|
||||
status: templateForm.statusBool ? 1 : 0
|
||||
}
|
||||
|
||||
if (templateForm.id) {
|
||||
await updateMarketingTemplate(templateForm.id, payload)
|
||||
ElMessage.success('模板更新成功')
|
||||
} else {
|
||||
await createMarketingTemplate(payload)
|
||||
ElMessage.success('模板创建成功')
|
||||
}
|
||||
|
||||
templateDialogVisible.value = false
|
||||
await Promise.all([loadTemplates(), loadTemplateCount()])
|
||||
} finally {
|
||||
templateSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDeleteTemplate = async (id) => {
|
||||
await deleteMarketingTemplate(id)
|
||||
ElMessage.success('模板删除成功')
|
||||
await Promise.all([loadTemplates(), loadTemplateCount()])
|
||||
}
|
||||
|
||||
const handleTemplateFilterChange = () => {
|
||||
templateQuery.page = 1
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
const handleTemplatePageSizeChange = () => {
|
||||
templateQuery.page = 1
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadAll()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.marketing-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.cards {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.card-value {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #303133;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.card-label {
|
||||
margin-top: 8px;
|
||||
color: #909399;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.left-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -67,11 +67,29 @@
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</div>
|
||||
<el-button type="primary" @click="openCreateDialog">生成兑换码</el-button>
|
||||
<div class="right-actions">
|
||||
<el-button
|
||||
:disabled="selectedActiveCount === 0"
|
||||
:loading="batchDisabling"
|
||||
type="warning"
|
||||
@click="handleBatchDisable"
|
||||
>
|
||||
批量停用{{ selectedActiveCount > 0 ? `(${selectedActiveCount})` : '' }}
|
||||
</el-button>
|
||||
<el-button type="primary" @click="openCreateDialog">生成兑换码</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table
|
||||
ref="tableRef"
|
||||
v-loading="loading"
|
||||
:data="list"
|
||||
row-key="id"
|
||||
stripe
|
||||
@selection-change="handleSelectionChange"
|
||||
>
|
||||
<el-table-column type="selection" width="48" reserve-selection />
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="code" label="兑换码" min-width="180" />
|
||||
<el-table-column label="小程序" min-width="140">
|
||||
@@ -111,8 +129,10 @@
|
||||
{{ formatDateTime(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<el-table-column label="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openDetailDialog(row)">详情</el-button>
|
||||
<el-button link @click="handleCopyCode(row)">复制</el-button>
|
||||
<el-button
|
||||
v-if="row.status === 'active'"
|
||||
link
|
||||
@@ -181,13 +201,40 @@
|
||||
<el-button type="primary" :loading="creating" @click="handleCreate">生成</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="detailDialogVisible" title="兑换码详情" width="560px">
|
||||
<el-descriptions v-if="detailRecord" :column="1" border>
|
||||
<el-descriptions-item label="ID">{{ detailRecord.id || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="兑换码">{{ detailRecord.code || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ statusText(detailRecord.status) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="所属小程序">
|
||||
{{ detailRecord.mini_program_name || detailRecord.mini_program?.name || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="套餐类型">
|
||||
{{ detailRecord.package_type || detailRecord.plan_name || '-' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="时长(天)">{{ detailRecord.duration_days || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="可用次数">
|
||||
{{ detailRecord.max_uses || 1 }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="已使用次数">
|
||||
{{ detailRecord.used_count || 0 }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="过期时间">
|
||||
{{ formatDateTime(detailRecord.expires_at) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="创建时间">
|
||||
{{ formatDateTime(detailRecord.created_at) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import dayjs from 'dayjs'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { getMiniPrograms } from '../../api/miniProgram'
|
||||
import {
|
||||
createMembershipRedeemCodes,
|
||||
@@ -201,8 +248,13 @@ const creating = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const miniProgramOptions = ref([])
|
||||
const tableRef = ref(null)
|
||||
const selectedRows = ref([])
|
||||
const batchDisabling = ref(false)
|
||||
const createDialogVisible = ref(false)
|
||||
const createFormRef = ref(null)
|
||||
const detailDialogVisible = ref(false)
|
||||
const detailRecord = ref(null)
|
||||
|
||||
const overview = reactive({
|
||||
total_members: 0,
|
||||
@@ -236,6 +288,9 @@ const createRules = {
|
||||
max_uses: [{ required: true, message: '请输入可用次数', trigger: 'blur' }]
|
||||
}
|
||||
|
||||
const selectedActiveRows = computed(() => selectedRows.value.filter((item) => item.status === 'active'))
|
||||
const selectedActiveCount = computed(() => selectedActiveRows.value.length)
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
@@ -276,6 +331,7 @@ const loadData = async () => {
|
||||
const res = await getMembershipRedeemCodes(query)
|
||||
const payload = res.data || {}
|
||||
list.value = payload.list || payload.items || []
|
||||
selectedRows.value = []
|
||||
total.value = payload.total || 0
|
||||
query.page = payload.page || query.page
|
||||
query.page_size = payload.page_size || query.page_size
|
||||
@@ -301,10 +357,42 @@ const handleSizeChange = () => {
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleSelectionChange = (rows) => {
|
||||
selectedRows.value = rows || []
|
||||
}
|
||||
|
||||
const openCreateDialog = () => {
|
||||
createDialogVisible.value = true
|
||||
}
|
||||
|
||||
const openDetailDialog = (row) => {
|
||||
detailRecord.value = row
|
||||
detailDialogVisible.value = true
|
||||
}
|
||||
|
||||
const handleCopyCode = async (row) => {
|
||||
if (!row?.code) {
|
||||
ElMessage.warning('兑换码为空,无法复制')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(row.code)
|
||||
} else {
|
||||
const input = document.createElement('input')
|
||||
input.value = row.code
|
||||
document.body.appendChild(input)
|
||||
input.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(input)
|
||||
}
|
||||
ElMessage.success('兑换码已复制')
|
||||
} catch (error) {
|
||||
ElMessage.error('复制失败,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!createFormRef.value) return
|
||||
try {
|
||||
@@ -341,6 +429,43 @@ const handleDisable = async (row) => {
|
||||
loadData()
|
||||
}
|
||||
|
||||
const handleBatchDisable = async () => {
|
||||
if (selectedActiveRows.value.length === 0) {
|
||||
ElMessage.warning('请先选择可用状态的兑换码')
|
||||
return
|
||||
}
|
||||
|
||||
const activeCount = selectedActiveRows.value.length
|
||||
await ElMessageBox.confirm(`确认批量停用 ${activeCount} 条兑换码吗?`, '批量停用', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消'
|
||||
})
|
||||
|
||||
batchDisabling.value = true
|
||||
try {
|
||||
const results = await Promise.allSettled(
|
||||
selectedActiveRows.value.map((item) =>
|
||||
updateMembershipRedeemCodeStatus(item.id, { status: 'disabled' })
|
||||
)
|
||||
)
|
||||
|
||||
const successCount = results.filter((item) => item.status === 'fulfilled').length
|
||||
const failCount = activeCount - successCount
|
||||
|
||||
if (successCount > 0) {
|
||||
ElMessage.success(`批量停用完成:成功 ${successCount} 条${failCount > 0 ? `,失败 ${failCount} 条` : ''}`)
|
||||
} else {
|
||||
ElMessage.error('批量停用失败,请稍后重试')
|
||||
}
|
||||
|
||||
tableRef.value?.clearSelection()
|
||||
await loadData()
|
||||
} finally {
|
||||
batchDisabling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadMiniPrograms(), loadOverview(), loadData()])
|
||||
})
|
||||
@@ -370,6 +495,12 @@ onMounted(async () => {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.right-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -89,8 +89,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import {
|
||||
getAdminSettings,
|
||||
getSystemConfig,
|
||||
@@ -99,6 +101,16 @@ import {
|
||||
updateSystemConfig
|
||||
} from '../../api/settings'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const validTabs = ['profile', 'security', 'system']
|
||||
const normalizeTab = (tab) => {
|
||||
if (typeof tab !== 'string') return 'profile'
|
||||
return validTabs.includes(tab) ? tab : 'profile'
|
||||
}
|
||||
|
||||
const activeTab = ref('profile')
|
||||
const profileFormRef = ref(null)
|
||||
const passwordFormRef = ref(null)
|
||||
@@ -150,7 +162,21 @@ const passwordRules = {
|
||||
old_password: [{ required: true, message: '请输入当前密码', trigger: 'blur' }],
|
||||
new_password: [
|
||||
{ required: true, message: '请输入新密码', trigger: 'blur' },
|
||||
{ min: 6, message: '新密码至少 6 位', trigger: 'blur' }
|
||||
{ min: 6, message: '新密码至少 6 位', trigger: 'blur' },
|
||||
{
|
||||
validator: (_, value, callback) => {
|
||||
if (!value) {
|
||||
callback()
|
||||
return
|
||||
}
|
||||
if (value === passwordForm.old_password) {
|
||||
callback(new Error('新密码不能与当前密码相同'))
|
||||
return
|
||||
}
|
||||
callback()
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
confirm_password: [{ validator: confirmPasswordValidator, trigger: 'blur' }]
|
||||
}
|
||||
@@ -212,10 +238,18 @@ const handleChangePassword = async () => {
|
||||
old_password: passwordForm.old_password,
|
||||
new_password: passwordForm.new_password
|
||||
})
|
||||
|
||||
passwordForm.old_password = ''
|
||||
passwordForm.new_password = ''
|
||||
passwordForm.confirm_password = ''
|
||||
ElMessage.success('密码更新成功,请使用新密码重新登录')
|
||||
|
||||
await ElMessageBox.alert('密码更新成功,请使用新密码重新登录。', '提示', {
|
||||
confirmButtonText: '确定',
|
||||
type: 'success'
|
||||
})
|
||||
|
||||
userStore.logout()
|
||||
router.push('/login')
|
||||
} finally {
|
||||
passwordSaving.value = false
|
||||
}
|
||||
@@ -231,7 +265,30 @@ const handleSaveSystem = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
(tab) => {
|
||||
const normalizedTab = normalizeTab(tab)
|
||||
if (activeTab.value !== normalizedTab) {
|
||||
activeTab.value = normalizedTab
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(activeTab, (tab) => {
|
||||
if (route.query.tab === tab) return
|
||||
router.replace({
|
||||
path: route.path,
|
||||
query: {
|
||||
...route.query,
|
||||
tab
|
||||
}
|
||||
}).catch(() => {})
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
activeTab.value = normalizeTab(route.query.tab)
|
||||
await Promise.all([loadProfile(), loadSystemConfig()])
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="header-row">
|
||||
<span>戒烟小程序数据管理(fa_smoke)</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-tabs v-model="activeTab" @tab-change="handleTabChange">
|
||||
<el-tab-pane label="抽烟记录" name="logs" />
|
||||
<el-tab-pane label="用户画像" name="profiles" />
|
||||
<el-tab-pane label="AI建议" name="aiAdvices" />
|
||||
<el-tab-pane label="AI解锁" name="aiUnlocks" />
|
||||
<el-tab-pane label="AI节点" name="aiNexts" />
|
||||
<el-tab-pane label="激励语模板" name="motivations" />
|
||||
</el-tabs>
|
||||
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
placeholder="可选:按 uid 过滤(仅数字)"
|
||||
style="width: 260px"
|
||||
clearable
|
||||
@keyup.enter="loadActiveData"
|
||||
@clear="loadActiveData"
|
||||
/>
|
||||
<el-button type="primary" @click="loadActiveData">刷新</el-button>
|
||||
<el-button type="success" @click="openCreate">新增</el-button>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="rows" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="UID" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ row.uid ?? '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="数据预览" min-width="360">
|
||||
<template #default="{ row }">
|
||||
<pre class="json-preview">{{ formatRow(row) }}</pre>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="page"
|
||||
v-model:page-size="pageSize"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
@current-change="loadActiveData"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<el-dialog v-model="dialogVisible" :title="dialogTitle" width="780px">
|
||||
<div class="dialog-tip">请填写 JSON(字段需符合当前 Tab 对应接口要求)</div>
|
||||
<el-input
|
||||
v-model="jsonForm"
|
||||
type="textarea"
|
||||
:rows="16"
|
||||
placeholder='例如:{"uid": 1, "remark": "测试"}'
|
||||
/>
|
||||
<template #footer>
|
||||
<el-button @click="dialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="saving" @click="handleSubmit">保存</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { computed, ref } from 'vue'
|
||||
import {
|
||||
listSmokeLogs,
|
||||
createSmokeLog,
|
||||
updateSmokeLog,
|
||||
deleteSmokeLog,
|
||||
listSmokeProfiles,
|
||||
createSmokeProfile,
|
||||
updateSmokeProfile,
|
||||
deleteSmokeProfile,
|
||||
listSmokeAIAdvices,
|
||||
createSmokeAIAdvice,
|
||||
updateSmokeAIAdvice,
|
||||
deleteSmokeAIAdvice,
|
||||
listSmokeAIUnlocks,
|
||||
createSmokeAIUnlock,
|
||||
updateSmokeAIUnlock,
|
||||
deleteSmokeAIUnlock,
|
||||
listSmokeAINexts,
|
||||
createSmokeAINext,
|
||||
updateSmokeAINext,
|
||||
deleteSmokeAINext,
|
||||
listSmokeMotivations,
|
||||
createSmokeMotivation,
|
||||
updateSmokeMotivation,
|
||||
deleteSmokeMotivation
|
||||
} from '../../api/smoke'
|
||||
|
||||
// 每个 Tab 绑定对应的 CRUD API,便于统一维护。
|
||||
const tabMap = {
|
||||
logs: { list: listSmokeLogs, create: createSmokeLog, update: updateSmokeLog, remove: deleteSmokeLog },
|
||||
profiles: { list: listSmokeProfiles, create: createSmokeProfile, update: updateSmokeProfile, remove: deleteSmokeProfile },
|
||||
aiAdvices: { list: listSmokeAIAdvices, create: createSmokeAIAdvice, update: updateSmokeAIAdvice, remove: deleteSmokeAIAdvice },
|
||||
aiUnlocks: { list: listSmokeAIUnlocks, create: createSmokeAIUnlock, update: updateSmokeAIUnlock, remove: deleteSmokeAIUnlock },
|
||||
aiNexts: { list: listSmokeAINexts, create: createSmokeAINext, update: updateSmokeAINext, remove: deleteSmokeAINext },
|
||||
motivations: { list: listSmokeMotivations, create: createSmokeMotivation, update: updateSmokeMotivation, remove: deleteSmokeMotivation }
|
||||
}
|
||||
|
||||
const activeTab = ref('logs')
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const rows = ref([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const keyword = ref('')
|
||||
|
||||
const dialogVisible = ref(false)
|
||||
const dialogMode = ref('create')
|
||||
const editingId = ref(null)
|
||||
const jsonForm = ref(`{\n "uid": 1\n}`)
|
||||
|
||||
const dialogTitle = computed(() => (dialogMode.value === 'create' ? '新增记录' : `编辑记录 #${editingId.value}`))
|
||||
|
||||
const formatRow = (row) => JSON.stringify(row, null, 2)
|
||||
|
||||
const buildParams = () => {
|
||||
const params = {
|
||||
page: page.value,
|
||||
page_size: pageSize.value
|
||||
}
|
||||
|
||||
const uid = Number(keyword.value)
|
||||
if (keyword.value && !Number.isNaN(uid) && uid > 0) {
|
||||
params.uid = uid
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
const getApi = () => tabMap[activeTab.value]
|
||||
|
||||
const loadActiveData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getApi().list(buildParams())
|
||||
const payload = res.data || {}
|
||||
rows.value = payload.list || []
|
||||
total.value = payload.total || 0
|
||||
page.value = payload.page || page.value
|
||||
pageSize.value = payload.page_size || pageSize.value
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleTabChange = () => {
|
||||
page.value = 1
|
||||
loadActiveData()
|
||||
}
|
||||
|
||||
const handleSizeChange = () => {
|
||||
page.value = 1
|
||||
loadActiveData()
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
dialogMode.value = 'create'
|
||||
editingId.value = null
|
||||
jsonForm.value = '{\n "uid": 1\n}'
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const openEdit = (row) => {
|
||||
dialogMode.value = 'edit'
|
||||
editingId.value = row.id
|
||||
jsonForm.value = JSON.stringify(row, null, 2)
|
||||
dialogVisible.value = true
|
||||
}
|
||||
|
||||
const parseJsonForm = () => {
|
||||
try {
|
||||
return JSON.parse(jsonForm.value)
|
||||
} catch (error) {
|
||||
throw new Error('JSON 格式错误,请检查后重试')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
let payload
|
||||
try {
|
||||
payload = parseJsonForm()
|
||||
} catch (error) {
|
||||
ElMessage.error(error.message)
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
if (dialogMode.value === 'create') {
|
||||
await getApi().create(payload)
|
||||
ElMessage.success('新增成功')
|
||||
} else {
|
||||
await getApi().update(editingId.value, payload)
|
||||
ElMessage.success('更新成功')
|
||||
}
|
||||
dialogVisible.value = false
|
||||
await loadActiveData()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (row) => {
|
||||
await ElMessageBox.confirm('删除后不可恢复,确定继续吗?', '提示', {
|
||||
type: 'warning',
|
||||
confirmButtonText: '删除',
|
||||
cancelButtonText: '取消'
|
||||
})
|
||||
await getApi().remove(row.id)
|
||||
ElMessage.success('删除成功')
|
||||
await loadActiveData()
|
||||
}
|
||||
|
||||
loadActiveData()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.json-preview {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 180px;
|
||||
overflow: auto;
|
||||
background: #f8fafc;
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.dialog-tip {
|
||||
margin-bottom: 8px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="header-row">
|
||||
<div class="filters">
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
placeholder="搜索失败链接/错误信息/UA"
|
||||
clearable
|
||||
style="width: 260px"
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
<el-input
|
||||
v-model="query.domain"
|
||||
placeholder="来源域名"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="domain" label="域名" min-width="140" />
|
||||
<el-table-column label="失败链接" min-width="280" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-link :href="row.failed_url" target="_blank" type="primary" :underline="false">
|
||||
{{ row.failed_url }}
|
||||
</el-link>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="错误信息" min-width="220" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.error_message || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="client_ip" label="IP" width="140" />
|
||||
<el-table-column label="UA" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.user_agent || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="上报时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.reported_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="query.page"
|
||||
v-model:page-size="query.page_size"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
@current-change="loadData"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import dayjs from 'dayjs'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { getVideoDownloadFailures } from '../../api/watermark'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const dateRange = ref([])
|
||||
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
keyword: '',
|
||||
domain: ''
|
||||
})
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
const buildParams = () => {
|
||||
const params = {
|
||||
page: query.page,
|
||||
page_size: query.page_size,
|
||||
keyword: query.keyword || undefined,
|
||||
domain: query.domain || undefined
|
||||
}
|
||||
|
||||
if (Array.isArray(dateRange.value) && dateRange.value.length === 2) {
|
||||
params.date_from = dateRange.value[0]
|
||||
params.date_to = dateRange.value[1]
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getVideoDownloadFailures(buildParams())
|
||||
const payload = res.data || {}
|
||||
list.value = payload.list || []
|
||||
total.value = payload.total || 0
|
||||
query.page = payload.page || query.page
|
||||
query.page_size = payload.page_size || query.page_size
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
query.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const resetSearch = () => {
|
||||
query.keyword = ''
|
||||
query.domain = ''
|
||||
dateRange.value = []
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
const handleSizeChange = () => {
|
||||
query.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadData()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,297 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="header-row">
|
||||
<div class="filters">
|
||||
<el-input
|
||||
v-model="query.keyword"
|
||||
placeholder="搜索原文/解析链接/错误信息"
|
||||
clearable
|
||||
style="width: 260px"
|
||||
@keyup.enter="handleSearch"
|
||||
@clear="handleSearch"
|
||||
/>
|
||||
<el-select
|
||||
v-model="query.mini_program_id"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
placeholder="全部小程序"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in miniProgramOptions"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input-number
|
||||
v-model="query.user_id"
|
||||
:min="1"
|
||||
:controls="false"
|
||||
placeholder="用户ID"
|
||||
style="width: 130px"
|
||||
/>
|
||||
<el-select
|
||||
v-model="query.free_quota_used"
|
||||
clearable
|
||||
style="width: 150px"
|
||||
placeholder="免费次数"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option label="计入免费" :value="true" />
|
||||
<el-option label="不计入免费" :value="false" />
|
||||
</el-select>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="小程序" min-width="150">
|
||||
<template #default="{ row }">
|
||||
{{ row.mini_program_name || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="mini_program_id" label="小程序ID" width="110" />
|
||||
<el-table-column prop="user_id" label="用户ID" width="100" />
|
||||
<el-table-column label="原始内容" min-width="240" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.request_content || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="解析链接" min-width="240" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
<el-link
|
||||
v-if="row.parsed_url"
|
||||
:href="row.parsed_url"
|
||||
target="_blank"
|
||||
type="primary"
|
||||
:underline="false"
|
||||
>
|
||||
{{ row.parsed_url }}
|
||||
</el-link>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="第三方状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.third_party_status)">
|
||||
{{ displayStatus(row.third_party_status) }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="第三方响应" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<el-popover
|
||||
v-if="hasPayload(row.third_party_payload)"
|
||||
placement="top-start"
|
||||
:width="520"
|
||||
trigger="click"
|
||||
>
|
||||
<template #reference>
|
||||
<el-button type="primary" link>查看响应</el-button>
|
||||
</template>
|
||||
<pre class="payload-block">{{ formatPayload(row.third_party_payload) }}</pre>
|
||||
</el-popover>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="免费次数" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.free_quota_used ? 'success' : 'info'">
|
||||
{{ row.free_quota_used ? '是' : '否' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="duration_ms" label="耗时(ms)" width="110" />
|
||||
<el-table-column label="错误信息" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">
|
||||
{{ row.error_message || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="query.page"
|
||||
v-model:page-size="query.page_size"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
@current-change="loadData"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import dayjs from 'dayjs'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { getMiniPrograms } from '../../api/miniProgram'
|
||||
import { getVideoParseLogs } from '../../api/watermark'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const miniProgramOptions = ref([])
|
||||
const dateRange = ref([])
|
||||
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
keyword: '',
|
||||
mini_program_id: undefined,
|
||||
user_id: undefined,
|
||||
free_quota_used: undefined
|
||||
})
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
const hasPayload = (value) => {
|
||||
if (value === null || value === undefined) return false
|
||||
return String(value).trim() !== ''
|
||||
}
|
||||
|
||||
// 后端返回的 payload 可能是 JSON 字符串,也可能是对象,这里统一格式化展示
|
||||
const formatPayload = (value) => {
|
||||
if (!hasPayload(value)) return '-'
|
||||
if (typeof value === 'object') {
|
||||
return JSON.stringify(value, null, 2)
|
||||
}
|
||||
const text = String(value)
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(text), null, 2)
|
||||
} catch (error) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
const displayStatus = (value) => {
|
||||
if (value === null || value === undefined || value === '') return '-'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const statusTagType = (value) => {
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num)) return 'info'
|
||||
if (num >= 200 && num < 400) return 'success'
|
||||
if (num >= 400) return 'danger'
|
||||
return 'info'
|
||||
}
|
||||
|
||||
const loadMiniProgramOptions = async () => {
|
||||
const res = await getMiniPrograms({ page: 1, page_size: 200 })
|
||||
const payload = res.data || {}
|
||||
miniProgramOptions.value = payload.list || []
|
||||
}
|
||||
|
||||
const buildParams = () => {
|
||||
const params = {
|
||||
page: query.page,
|
||||
page_size: query.page_size,
|
||||
keyword: query.keyword || undefined,
|
||||
mini_program_id: query.mini_program_id,
|
||||
user_id: query.user_id,
|
||||
free_quota_used: query.free_quota_used
|
||||
}
|
||||
|
||||
if (Array.isArray(dateRange.value) && dateRange.value.length === 2) {
|
||||
params.date_from = dateRange.value[0]
|
||||
params.date_to = dateRange.value[1]
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getVideoParseLogs(buildParams())
|
||||
const payload = res.data || {}
|
||||
list.value = payload.list || []
|
||||
total.value = payload.total || 0
|
||||
query.page = payload.page || query.page
|
||||
query.page_size = payload.page_size || query.page_size
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
query.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const resetSearch = () => {
|
||||
query.keyword = ''
|
||||
query.mini_program_id = undefined
|
||||
query.user_id = undefined
|
||||
query.free_quota_used = undefined
|
||||
dateRange.value = []
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
const handleSizeChange = () => {
|
||||
query.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadMiniProgramOptions(), loadData()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.payload-block {
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
border-radius: 8px;
|
||||
background: #0f172a;
|
||||
color: #dbeafe;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<template>
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="header-row">
|
||||
<div class="filters">
|
||||
<el-select
|
||||
v-model="query.mini_program_id"
|
||||
clearable
|
||||
style="width: 180px"
|
||||
placeholder="全部小程序"
|
||||
@change="handleSearch"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in miniProgramOptions"
|
||||
:key="item.id"
|
||||
:label="item.name"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-input-number
|
||||
v-model="query.user_id"
|
||||
:min="1"
|
||||
:controls="false"
|
||||
placeholder="用户ID"
|
||||
style="width: 130px"
|
||||
/>
|
||||
<el-date-picker
|
||||
v-model="dateRange"
|
||||
type="daterange"
|
||||
range-separator="至"
|
||||
start-placeholder="开始日期"
|
||||
end-placeholder="结束日期"
|
||||
value-format="YYYY-MM-DD"
|
||||
/>
|
||||
<el-button type="primary" @click="handleSearch">搜索</el-button>
|
||||
<el-button @click="resetSearch">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column label="小程序" min-width="160">
|
||||
<template #default="{ row }">
|
||||
{{ row.mini_program_name || '-' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="user_id" label="用户ID" width="100" />
|
||||
<el-table-column label="解锁日期" width="140">
|
||||
<template #default="{ row }">
|
||||
{{ formatDate(row.unlock_date) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="广告完成时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.ad_watched_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="180">
|
||||
<template #default="{ row }">
|
||||
{{ formatDateTime(row.created_at) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="pagination">
|
||||
<el-pagination
|
||||
v-model:current-page="query.page"
|
||||
v-model:page-size="query.page_size"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
:page-sizes="[10, 20, 50, 100]"
|
||||
:total="total"
|
||||
@current-change="loadData"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import dayjs from 'dayjs'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { getMiniPrograms } from '../../api/miniProgram'
|
||||
import { getVideoParseUnlocks } from '../../api/watermark'
|
||||
|
||||
const loading = ref(false)
|
||||
const list = ref([])
|
||||
const total = ref(0)
|
||||
const miniProgramOptions = ref([])
|
||||
const dateRange = ref([])
|
||||
|
||||
const query = reactive({
|
||||
page: 1,
|
||||
page_size: 20,
|
||||
mini_program_id: undefined,
|
||||
user_id: undefined
|
||||
})
|
||||
|
||||
const formatDateTime = (value) => {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
|
||||
}
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return '-'
|
||||
return dayjs(value).format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
const loadMiniProgramOptions = async () => {
|
||||
const res = await getMiniPrograms({ page: 1, page_size: 200 })
|
||||
const payload = res.data || {}
|
||||
miniProgramOptions.value = payload.list || []
|
||||
}
|
||||
|
||||
const buildParams = () => {
|
||||
const params = {
|
||||
page: query.page,
|
||||
page_size: query.page_size,
|
||||
mini_program_id: query.mini_program_id,
|
||||
user_id: query.user_id
|
||||
}
|
||||
|
||||
if (Array.isArray(dateRange.value) && dateRange.value.length === 2) {
|
||||
params.date_from = dateRange.value[0]
|
||||
params.date_to = dateRange.value[1]
|
||||
}
|
||||
|
||||
return params
|
||||
}
|
||||
|
||||
const loadData = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await getVideoParseUnlocks(buildParams())
|
||||
const payload = res.data || {}
|
||||
list.value = payload.list || []
|
||||
total.value = payload.total || 0
|
||||
query.page = payload.page || query.page
|
||||
query.page_size = payload.page_size || query.page_size
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
query.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
const resetSearch = () => {
|
||||
query.mini_program_id = undefined
|
||||
query.user_id = undefined
|
||||
dateRange.value = []
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
const handleSizeChange = () => {
|
||||
query.page = 1
|
||||
loadData()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadMiniProgramOptions(), loadData()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
+20
-12
@@ -1,18 +1,26 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, loadEnv } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/healthz': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, process.cwd(), '')
|
||||
|
||||
// 生产默认挂载在 /panel/,可通过 VITE_PUBLIC_BASE 覆盖
|
||||
const base = env.VITE_PUBLIC_BASE || (mode === 'production' ? '/panel/' : '/')
|
||||
|
||||
return {
|
||||
base,
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
},
|
||||
'/healthz': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user