Compare commits

...

10 Commits

Author SHA1 Message Date
nepiedg 8186e36d3d feat: 成就管理与梦想图标后台页面
deploy-admin-frontend-prod / deploy (push) Waiting to run
- 成就主题/等级管理
- 梦想目标图标管理(dream-presets)与路由「梦想图标」

Made-with: Cursor
2026-04-04 14:55:57 +08:00
nepiedg 66713b110f feat(marketing): add ad placement management UI
- CRUD for ad placements (rewarded video, banner, interstitial)
- Integrated into marketing management page with table and dialog

Made-with: Cursor
2026-04-04 04:02:17 +08:00
nepiedg d62c51f140 feat: add image upload component with backend proxy and auto thumbnail
- Create reusable ImageUpload.vue component (drag-drop, preview, progress)
- Replace URL input fields with ImageUpload in category and template dialogs
- Upload via backend proxy to avoid OSS CORS issues
- Auto-fill thumbnail_url and image dimensions on template image upload

Made-with: Cursor
2026-04-04 02:52:50 +08:00
root 54b461dfb4 feat: improve submenu dark theme and enrich parse log fields 2026-03-10 18:19:02 +08:00
root 14590c5eda ci: align frontend deploy with wx_service secrets-driven flow 2026-03-10 18:04:02 +08:00
root ed1fcdfdaa feat(smoke): add fa_smoke admin CRUD console page (#9) 2026-03-10 17:29:49 +08:00
root 0d12ce5201 feat(watermark): nested menu and 3 table list pages (#7 #8) 2026-03-10 17:02:58 +08:00
root 9e0d321177 feat(settings): improve profile navigation and password reset UX 2026-03-10 16:40:56 +08:00
root 4d8901d1af feat: add marketing management page in admin frontend 2026-03-10 01:14:57 +08:00
root ad408db118 fix: 调整前端API基址避免跨域预检404 2026-03-09 22:44:16 +08:00
25 changed files with 3377 additions and 54 deletions
+4 -1
View File
@@ -1,2 +1,5 @@
# 生产环境(前后端同域部署时可直接使用相对路径) # 生产环境 API(前后端同域部署时使用相对路径)
VITE_API_BASE_URL=/ VITE_API_BASE_URL=/
# 生产环境前端路由基路径
VITE_PUBLIC_BASE=/panel/
+105
View File
@@ -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
+17
View File
@@ -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`
+74
View File
@@ -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"
```
+121
View File
@@ -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"
+37
View File
@@ -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' })
}
+17
View File
@@ -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' })
}
+116
View File
@@ -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'
})
}
+85
View File
@@ -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' })
}
+24
View File
@@ -28,3 +28,27 @@ export function deleteWatermarkTask(id) {
method: 'delete' 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
})
}
+183
View File
@@ -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
View File
@@ -6,7 +6,6 @@
@click="closeMobileMenu" @click="closeMobileMenu"
></div> ></div>
<!-- 侧边栏 -->
<el-aside :width="asideWidth" class="sidebar" :class="{ 'sidebar-mobile': isMobile }"> <el-aside :width="asideWidth" class="sidebar" :class="{ 'sidebar-mobile': isMobile }">
<div class="logo"> <div class="logo">
<span v-if="!isCollapse">管理后台</span> <span v-if="!isCollapse">管理后台</span>
@@ -20,20 +19,36 @@
router router
@select="handleMenuSelect" @select="handleMenuSelect"
> >
<el-menu-item <template v-for="routeItem in menuRoutes" :key="routeKey(routeItem)">
v-for="route in menuRoutes" <el-sub-menu
:key="route.path" v-if="hasChildren(routeItem)"
:index="route.path" :index="resolvePath(routeItem.path)"
> >
<el-icon><component :is="route.meta.icon" /></el-icon> <template #title>
<template #title>{{ route.meta.title }}</template> <el-icon><component :is="routeItem.meta.icon" /></el-icon>
</el-menu-item> <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-menu>
</el-aside> </el-aside>
<!-- 主内容区 -->
<el-container> <el-container>
<!-- 顶部导航栏 -->
<el-header class="header"> <el-header class="header">
<div class="header-left"> <div class="header-left">
<el-icon class="collapse-icon" @click="toggleCollapse"> <el-icon class="collapse-icon" @click="toggleCollapse">
@@ -59,7 +74,6 @@
</div> </div>
</el-header> </el-header>
<!-- 主内容 -->
<el-main class="main-content"> <el-main class="main-content">
<router-view /> <router-view />
</el-main> </el-main>
@@ -122,26 +136,51 @@ watch(
} }
) )
// 获取菜单路由(过滤掉隐藏的路由)
const menuRoutes = computed(() => { const menuRoutes = computed(() => {
const routes = router.getRoutes() const rootRoute = router.options.routes.find((item) => item.path === '/')
const mainRoute = routes.find(r => r.path === '/') if (!rootRoute || !rootRoute.children) return []
if (!mainRoute || !mainRoute.children) return [] return rootRoute.children.filter((item) => !item.meta?.hidden)
return mainRoute.children.filter(r => !r.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 activeMenu = computed(() => {
const { path } = route const { path } = route
// 如果是子路由,返回父路由路径 if (path === '/watermark') {
return '/watermark/video-parse-logs'
}
if (path.includes('/create') || path.includes('/edit') || /\/\d+$/.test(path)) { if (path.includes('/create') || path.includes('/edit') || /\/\d+$/.test(path)) {
return '/' + path.split('/')[1] return '/' + path.split('/')[1]
} }
return path return path
}) })
// 切换侧边栏折叠状态
const toggleCollapse = () => { const toggleCollapse = () => {
if (isMobile.value) { if (isMobile.value) {
mobileMenuVisible.value = !mobileMenuVisible.value mobileMenuVisible.value = !mobileMenuVisible.value
@@ -167,7 +206,6 @@ const handleResize = () => {
} }
} }
// 处理下拉菜单命令
const handleCommand = async (command) => { const handleCommand = async (command) => {
if (command === 'logout') { if (command === 'logout') {
try { try {
@@ -184,7 +222,10 @@ const handleCommand = async (command) => {
// 用户取消 // 用户取消
} }
} else if (command === 'profile') { } else if (command === 'profile') {
ElMessage.info('个人信息功能开发中...') router.push({
path: '/settings',
query: { tab: 'profile' }
})
} }
} }
</script> </script>
@@ -214,16 +255,22 @@ const handleCommand = async (command) => {
background: #2b3a4b; background: #2b3a4b;
} }
.el-menu { :deep(.el-menu) {
border-right: none; 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; color: #bfcbd9;
} }
:deep(.el-menu-item:hover) { :deep(.el-menu-item:hover),
:deep(.el-sub-menu__title:hover) {
background: #263445 !important; background: #263445 !important;
color: #fff; color: #fff;
} }
@@ -233,6 +280,45 @@ const handleCommand = async (command) => {
color: #fff; 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 { .header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
+48 -3
View File
@@ -51,6 +51,12 @@ const routes = [
component: () => import('../views/users/detail.vue'), component: () => import('../views/users/detail.vue'),
meta: { title: '用户详情', hidden: true } meta: { title: '用户详情', hidden: true }
}, },
{
path: 'marketing',
name: 'Marketing',
component: () => import('../views/marketing/index.vue'),
meta: { title: '营销图管理', icon: 'Picture' }
},
{ {
path: 'memberships', path: 'memberships',
name: 'Memberships', name: 'Memberships',
@@ -66,8 +72,47 @@ const routes = [
{ {
path: 'watermark', path: 'watermark',
name: 'Watermark', name: 'Watermark',
component: () => import('../views/watermark/index.vue'), component: () => import('../views/watermark/layout.vue'),
meta: { title: '去水印管理', icon: 'Brush' } 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', path: 'settings',
@@ -80,7 +125,7 @@ const routes = [
] ]
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(import.meta.env.BASE_URL),
routes routes
}) })
+5 -3
View File
@@ -1,9 +1,11 @@
import axios from 'axios' import axios from 'axios'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
const loginPath = `${import.meta.env.BASE_URL}login`
// 创建 axios 实例 // 创建 axios 实例
const request = axios.create({ 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 timeout: 10000
}) })
@@ -33,7 +35,7 @@ request.interceptors.response.use(
// 401: 未登录 // 401: 未登录
if (res.code === 401) { if (res.code === 401) {
localStorage.removeItem('admin_token') localStorage.removeItem('admin_token')
window.location.href = '/login' window.location.href = loginPath
} }
return Promise.reject(new Error(res.message || '请求失败')) return Promise.reject(new Error(res.message || '请求失败'))
@@ -50,7 +52,7 @@ request.interceptors.response.use(
if (status === 401) { if (status === 401) {
ElMessage.error('登录已过期,请重新登录') ElMessage.error('登录已过期,请重新登录')
localStorage.removeItem('admin_token') localStorage.removeItem('admin_token')
window.location.href = '/login' window.location.href = loginPath
} else if (status === 403) { } else if (status === 403) {
ElMessage.error('没有权限访问') ElMessage.error('没有权限访问')
} else if (status === 404) { } else if (status === 404) {
+276
View File
@@ -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>
+258
View File
@@ -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>
+747
View File
@@ -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>
+135 -4
View File
@@ -67,11 +67,29 @@
<el-button type="primary" @click="handleSearch">搜索</el-button> <el-button type="primary" @click="handleSearch">搜索</el-button>
<el-button @click="resetSearch">重置</el-button> <el-button @click="resetSearch">重置</el-button>
</div> </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> </div>
</template> </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="id" label="ID" width="80" />
<el-table-column prop="code" label="兑换码" min-width="180" /> <el-table-column prop="code" label="兑换码" min-width="180" />
<el-table-column label="小程序" min-width="140"> <el-table-column label="小程序" min-width="140">
@@ -111,8 +129,10 @@
{{ formatDateTime(row.created_at) }} {{ formatDateTime(row.created_at) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="120" fixed="right"> <el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" @click="openDetailDialog(row)">详情</el-button>
<el-button link @click="handleCopyCode(row)">复制</el-button>
<el-button <el-button
v-if="row.status === 'active'" v-if="row.status === 'active'"
link link
@@ -181,13 +201,40 @@
<el-button type="primary" :loading="creating" @click="handleCreate">生成</el-button> <el-button type="primary" :loading="creating" @click="handleCreate">生成</el-button>
</template> </template>
</el-dialog> </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> </div>
</template> </template>
<script setup> <script setup>
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { ElMessage, ElMessageBox } from 'element-plus' 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 { getMiniPrograms } from '../../api/miniProgram'
import { import {
createMembershipRedeemCodes, createMembershipRedeemCodes,
@@ -201,8 +248,13 @@ const creating = ref(false)
const list = ref([]) const list = ref([])
const total = ref(0) const total = ref(0)
const miniProgramOptions = ref([]) const miniProgramOptions = ref([])
const tableRef = ref(null)
const selectedRows = ref([])
const batchDisabling = ref(false)
const createDialogVisible = ref(false) const createDialogVisible = ref(false)
const createFormRef = ref(null) const createFormRef = ref(null)
const detailDialogVisible = ref(false)
const detailRecord = ref(null)
const overview = reactive({ const overview = reactive({
total_members: 0, total_members: 0,
@@ -236,6 +288,9 @@ const createRules = {
max_uses: [{ required: true, message: '请输入可用次数', trigger: 'blur' }] 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) => { const formatDateTime = (value) => {
if (!value) return '-' if (!value) return '-'
return dayjs(value).format('YYYY-MM-DD HH:mm:ss') return dayjs(value).format('YYYY-MM-DD HH:mm:ss')
@@ -276,6 +331,7 @@ const loadData = async () => {
const res = await getMembershipRedeemCodes(query) const res = await getMembershipRedeemCodes(query)
const payload = res.data || {} const payload = res.data || {}
list.value = payload.list || payload.items || [] list.value = payload.list || payload.items || []
selectedRows.value = []
total.value = payload.total || 0 total.value = payload.total || 0
query.page = payload.page || query.page query.page = payload.page || query.page
query.page_size = payload.page_size || query.page_size query.page_size = payload.page_size || query.page_size
@@ -301,10 +357,42 @@ const handleSizeChange = () => {
loadData() loadData()
} }
const handleSelectionChange = (rows) => {
selectedRows.value = rows || []
}
const openCreateDialog = () => { const openCreateDialog = () => {
createDialogVisible.value = true 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 () => { const handleCreate = async () => {
if (!createFormRef.value) return if (!createFormRef.value) return
try { try {
@@ -341,6 +429,43 @@ const handleDisable = async (row) => {
loadData() 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 () => { onMounted(async () => {
await Promise.all([loadMiniPrograms(), loadOverview(), loadData()]) await Promise.all([loadMiniPrograms(), loadOverview(), loadData()])
}) })
@@ -370,6 +495,12 @@ onMounted(async () => {
gap: 12px; gap: 12px;
} }
.right-actions {
display: flex;
align-items: center;
gap: 8px;
}
.filters { .filters {
display: flex; display: flex;
align-items: center; align-items: center;
+61 -4
View File
@@ -89,8 +89,10 @@
</template> </template>
<script setup> <script setup>
import { ElMessage } from 'element-plus' import { ElMessage, ElMessageBox } from 'element-plus'
import { onMounted, reactive, ref } from 'vue' import { onMounted, reactive, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '../../stores/user'
import { import {
getAdminSettings, getAdminSettings,
getSystemConfig, getSystemConfig,
@@ -99,6 +101,16 @@ import {
updateSystemConfig updateSystemConfig
} from '../../api/settings' } 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 activeTab = ref('profile')
const profileFormRef = ref(null) const profileFormRef = ref(null)
const passwordFormRef = ref(null) const passwordFormRef = ref(null)
@@ -150,7 +162,21 @@ const passwordRules = {
old_password: [{ required: true, message: '请输入当前密码', trigger: 'blur' }], old_password: [{ required: true, message: '请输入当前密码', trigger: 'blur' }],
new_password: [ new_password: [
{ required: true, message: '请输入新密码', trigger: 'blur' }, { 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' }] confirm_password: [{ validator: confirmPasswordValidator, trigger: 'blur' }]
} }
@@ -212,10 +238,18 @@ const handleChangePassword = async () => {
old_password: passwordForm.old_password, old_password: passwordForm.old_password,
new_password: passwordForm.new_password new_password: passwordForm.new_password
}) })
passwordForm.old_password = '' passwordForm.old_password = ''
passwordForm.new_password = '' passwordForm.new_password = ''
passwordForm.confirm_password = '' passwordForm.confirm_password = ''
ElMessage.success('密码更新成功,请使用新密码重新登录')
await ElMessageBox.alert('密码更新成功,请使用新密码重新登录。', '提示', {
confirmButtonText: '确定',
type: 'success'
})
userStore.logout()
router.push('/login')
} finally { } finally {
passwordSaving.value = false 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 () => { onMounted(async () => {
activeTab.value = normalizeTab(route.query.tab)
await Promise.all([loadProfile(), loadSystemConfig()]) await Promise.all([loadProfile(), loadSystemConfig()])
}) })
</script> </script>
+274
View File
@@ -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>
+3
View File
@@ -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>
+297
View File
@@ -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>
+186
View File
@@ -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
View File
@@ -1,18 +1,26 @@
import { defineConfig } from 'vite' import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig(({ mode }) => {
plugins: [vue()], const env = loadEnv(mode, process.cwd(), '')
server: {
proxy: { // 生产默认挂载在 /panel/,可通过 VITE_PUBLIC_BASE 覆盖
'/api': { const base = env.VITE_PUBLIC_BASE || (mode === 'production' ? '/panel/' : '/')
target: 'http://localhost:8080',
changeOrigin: true return {
}, base,
'/healthz': { plugins: [vue()],
target: 'http://localhost:8080', server: {
changeOrigin: true proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
},
'/healthz': {
target: 'http://localhost:8080',
changeOrigin: true
}
} }
} }
} }