功能更新
This commit is contained in:
14
index.html
14
index.html
@@ -1,10 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + Vue</title>
|
||||
<title>工具百宝箱 · 管理后台</title>
|
||||
<script>
|
||||
// 防刷新闪白:Vue 挂载前按持久化的主题先挂 dark class(与 stores/app.js 的 tb:theme 键一致)
|
||||
;(function () {
|
||||
try {
|
||||
var m = localStorage.getItem('tb:theme') || 'light'
|
||||
var dark = m === 'dark' || (m === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
if (dark) document.documentElement.classList.add('dark')
|
||||
} catch (e) {}
|
||||
})()
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
32
src/App.vue
32
src/App.vue
@@ -1,21 +1,27 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import zhCN from 'ant-design-vue/es/locale/zh_CN'
|
||||
import { theme as antTheme } from 'ant-design-vue'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
|
||||
const appStore = useAppStore()
|
||||
|
||||
// 统一设计语言:「橘子汽水」珊瑚橙(与小程序一致,见 docs/design-language.md)。
|
||||
// 暗色时切 darkAlgorithm 处理 AntD 组件本体,自研层由 tokens.css 的 html.dark 块接管。
|
||||
const themeConfig = computed(() => ({
|
||||
algorithm: appStore.isDark ? antTheme.darkAlgorithm : antTheme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: '#FF6B35',
|
||||
colorInfo: '#FF6B35',
|
||||
colorLink: '#FF6B35',
|
||||
colorBgLayout: appStore.isDark ? '#171009' : '#FFF9F2',
|
||||
borderRadius: 10,
|
||||
},
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 统一设计语言:「橘子汽水」珊瑚橙(与小程序一致,见 docs/design-language.md) -->
|
||||
<a-config-provider
|
||||
:locale="zhCN"
|
||||
:theme="{
|
||||
token: {
|
||||
colorPrimary: '#FF6B35',
|
||||
colorInfo: '#FF6B35',
|
||||
colorLink: '#FF6B35',
|
||||
colorBgLayout: '#FFF9F2',
|
||||
borderRadius: 10,
|
||||
},
|
||||
}"
|
||||
>
|
||||
<a-config-provider :locale="zhCN" :theme="themeConfig">
|
||||
<router-view />
|
||||
</a-config-provider>
|
||||
</template>
|
||||
|
||||
@@ -4,6 +4,6 @@ export function feedbackListApi(params) {
|
||||
return get('feedback/list', params)
|
||||
}
|
||||
|
||||
export function feedbackHandleApi(id) {
|
||||
return post('feedback/handle', { id })
|
||||
export function feedbackHandleApi(id, reply) {
|
||||
return post('feedback/handle', { id, reply: reply || undefined })
|
||||
}
|
||||
|
||||
@@ -18,17 +18,17 @@ function barColor(i) {
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div v-if="!items.length" class="text-sm text-slate-400 py-8 text-center">暂无使用数据</div>
|
||||
<div v-if="!items.length" class="text-sm text-[var(--text-3)] py-8 text-center">暂无使用数据</div>
|
||||
<div v-for="(it, i) in items" :key="it.tool_key" class="flex items-center gap-3">
|
||||
<span class="w-5 text-xs text-slate-400 text-right">{{ i + 1 }}</span>
|
||||
<span class="w-24 shrink-0 truncate text-sm text-stone-700" :title="it.name">{{ it.name || it.tool_key }}</span>
|
||||
<div class="flex-1 h-5 bg-[#FFF3EC] rounded-full overflow-hidden">
|
||||
<span class="w-5 text-xs text-[var(--text-3)] text-right">{{ i + 1 }}</span>
|
||||
<span class="w-24 shrink-0 truncate text-sm text-[var(--text-2)]" :title="it.name">{{ it.name || it.tool_key }}</span>
|
||||
<div class="flex-1 h-5 bg-[var(--color-primary-weak)] rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full rounded-full transition-all"
|
||||
:style="{ width: width(it.count), background: `linear-gradient(90deg, ${barColor(i)}CC, ${barColor(i)})` }"
|
||||
/>
|
||||
</div>
|
||||
<span class="w-12 text-right text-sm font-semibold text-[#E8531A]">{{ it.count }}</span>
|
||||
<span class="w-12 text-right text-sm font-semibold text-[var(--color-primary)]">{{ it.count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -41,25 +41,31 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!layout.coords.length" class="text-sm text-slate-400 py-8 text-center">暂无趋势数据</div>
|
||||
<div v-if="!layout.coords.length" class="text-sm text-[var(--text-3)] py-8 text-center">暂无趋势数据</div>
|
||||
<svg v-else :viewBox="`0 0 ${layout.W} ${layout.H}`" class="w-full">
|
||||
<!-- 横向网格线 -->
|
||||
<!-- 横向网格线(走 --divider,亮/暗自动适配) -->
|
||||
<line v-for="i in 4" :key="i" :x1="layout.pad.l" :x2="layout.W - layout.pad.r"
|
||||
:y1="layout.pad.t + ((layout.H - layout.pad.t - 28) * (i - 1)) / 3"
|
||||
:y2="layout.pad.t + ((layout.H - layout.pad.t - 28) * (i - 1)) / 3" stroke="#E2E8F0" stroke-width="1" />
|
||||
:y2="layout.pad.t + ((layout.H - layout.pad.t - 28) * (i - 1)) / 3" class="chart-grid" stroke-width="1" />
|
||||
<!-- 折线 -->
|
||||
<polyline :points="layout.coords.map((c) => `${c.x.toFixed(1)},${c.y.toFixed(1)}`).join(' ')"
|
||||
fill="none" :stroke="color" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round" />
|
||||
<circle v-for="c in layout.coords" :key="c.date" :cx="c.x" :cy="c.y" r="3.5" :fill="color" />
|
||||
<!-- 数值标签 -->
|
||||
<text v-for="c in layout.coords" :key="'v' + c.date" :x="c.x" :y="c.y - 10" text-anchor="middle"
|
||||
class="fill-slate-500" font-size="11">{{ fmt(c.count) }}</text>
|
||||
class="fill-[var(--text-3)]" font-size="11">{{ fmt(c.count) }}</text>
|
||||
<!-- 日期标签 -->
|
||||
<text v-for="c in layout.coords" :key="'d' + c.date" :x="c.x" :y="layout.H - 8" text-anchor="middle"
|
||||
class="fill-slate-400" font-size="10">{{ c.date.slice(5) }}</text>
|
||||
class="fill-[var(--text-4)]" font-size="10">{{ c.date.slice(5) }}</text>
|
||||
<!-- 最大值刻度 -->
|
||||
<text :x="layout.pad.l - 6" :y="layout.pad.t + 4" text-anchor="end" class="fill-slate-400" font-size="10">
|
||||
<text :x="layout.pad.l - 6" :y="layout.pad.t + 4" text-anchor="end" class="fill-[var(--text-4)]" font-size="10">
|
||||
{{ fmt(layout.max) }}
|
||||
</text>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chart-grid {
|
||||
stroke: var(--divider);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,56 +1,76 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watchEffect } from 'vue'
|
||||
import { computed, h, ref, watchEffect } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useUserStore } from '@/stores/user'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons-vue'
|
||||
import {
|
||||
Building2, CalendarDays, FileSearch, History, LayoutDashboard, LayoutGrid, LayoutTemplate,
|
||||
MapPin, Medal, MessageSquare, Monitor, Moon, PenTool, Receipt, Send, Settings, Sun, Ticket,
|
||||
Upload, UserCog, Users, Wrench,
|
||||
} from 'lucide-vue-next'
|
||||
import { hasPermission, PERM } from '@/utils/permissions'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
const appStore = useAppStore()
|
||||
|
||||
// 主题三态图标(浅色/深色/跟随系统)
|
||||
const themeIcon = computed(() => (appStore.mode === 'light' ? Sun : appStore.mode === 'dark' ? Moon : Monitor))
|
||||
const themeLabel = computed(
|
||||
() => ({ light: '浅色', dark: '深色', auto: '跟随系统' })[appStore.mode] || '浅色',
|
||||
)
|
||||
|
||||
const collapsed = ref(false)
|
||||
|
||||
/** 菜单图标统一 16px(lucide 线性风与玻璃主题一致) */
|
||||
const icon = (Comp: Parameters<typeof h>[0]) => h(Comp, { size: 16 })
|
||||
|
||||
// 菜单:单页 + 分组(children 即子项)。key 与路由 path 对齐,便于高亮与跳转。
|
||||
// 带 perm 的条目按当前角色权限过滤(仅体验层,真正拦截在服务端)。
|
||||
// icon:侧栏折叠(64px)时 antd 自动只展示图标,子菜单以浮层弹出。
|
||||
const menus = computed(() => {
|
||||
const role = userStore.user?.role_value
|
||||
const items: any[] = [
|
||||
{ key: '/dashboard', label: '数据看板' },
|
||||
{ key: '/tools', label: '工具管理' },
|
||||
{ key: '/modules', label: '模块管理' },
|
||||
{ key: '/levels', label: '等级管理' },
|
||||
{ key: '/users', label: '用户管理' },
|
||||
{ key: '/orders', label: '订单管理', perm: PERM.ORDER_READ },
|
||||
{ key: '/feedbacks', label: '反馈管理' },
|
||||
{ key: '/dashboard', label: '数据看板', icon: icon(LayoutDashboard) },
|
||||
{ key: '/tools', label: '工具管理', icon: icon(Wrench) },
|
||||
{ key: '/modules', label: '模块管理', icon: icon(LayoutGrid) },
|
||||
{ key: '/levels', label: '等级管理', icon: icon(Medal) },
|
||||
{ key: '/users', label: '用户管理', icon: icon(Users) },
|
||||
{ key: '/orders', label: '订单管理', icon: icon(Receipt), perm: PERM.ORDER_READ },
|
||||
{ key: '/feedbacks', label: '反馈管理', icon: icon(MessageSquare) },
|
||||
{
|
||||
key: 'promo',
|
||||
label: '优惠',
|
||||
icon: icon(Ticket),
|
||||
children: [
|
||||
// N6 承前:优惠码 / 券模板 / 发券记录。挂 quota:write(与后端 admin_perm.go 对齐),
|
||||
// 只读角色无该权限点 → 整组隐藏。
|
||||
{ key: '/promo/list', label: '优惠码', perm: PERM.QUOTA_WRITE },
|
||||
{ key: '/coupon/template', label: '券模板', perm: PERM.QUOTA_WRITE },
|
||||
{ key: '/coupon/record', label: '发券记录', perm: PERM.QUOTA_WRITE },
|
||||
{ key: '/promo/list', label: '优惠码', icon: icon(Ticket), perm: PERM.QUOTA_WRITE },
|
||||
{ key: '/coupon/template', label: '券模板', icon: icon(LayoutTemplate), perm: PERM.QUOTA_WRITE },
|
||||
{ key: '/coupon/record', label: '发券记录', icon: icon(Send), perm: PERM.QUOTA_WRITE },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'event',
|
||||
label: '年会座次',
|
||||
icon: icon(MapPin),
|
||||
children: [
|
||||
{ key: '/event/enterprises', label: '企业管理' },
|
||||
{ key: '/event/list', label: '活动管理' },
|
||||
{ key: '/event/designer', label: '座次设计器' },
|
||||
{ key: '/event/import', label: '员工导入' },
|
||||
{ key: '/event/query-log', label: '查座审计' },
|
||||
{ key: '/event/enterprises', label: '企业管理', icon: icon(Building2) },
|
||||
{ key: '/event/list', label: '活动管理', icon: icon(CalendarDays) },
|
||||
{ key: '/event/designer', label: '座次设计器', icon: icon(PenTool) },
|
||||
{ key: '/event/import', label: '员工导入', icon: icon(Upload) },
|
||||
{ key: '/event/query-log', label: '查座审计', icon: icon(FileSearch) },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'system',
|
||||
label: '系统',
|
||||
icon: icon(Settings),
|
||||
children: [
|
||||
{ key: '/admins', label: '管理员', perm: PERM.ADMIN_MANAGE },
|
||||
{ key: '/audit', label: '操作审计', perm: PERM.AUDIT_READ },
|
||||
{ key: '/admins', label: '管理员', icon: icon(UserCog), perm: PERM.ADMIN_MANAGE },
|
||||
{ key: '/audit', label: '操作审计', icon: icon(History), perm: PERM.AUDIT_READ },
|
||||
],
|
||||
},
|
||||
]
|
||||
@@ -129,8 +149,9 @@ function logout() {
|
||||
<a-menu
|
||||
mode="inline"
|
||||
theme="light"
|
||||
:inline-collapsed="collapsed"
|
||||
:selected-keys="selectedKeys"
|
||||
:open-keys="openKeys"
|
||||
:open-keys="collapsed ? [] : openKeys"
|
||||
:items="menus"
|
||||
@click="onMenuClick"
|
||||
@openChange="onOpenChange"
|
||||
@@ -152,6 +173,18 @@ function logout() {
|
||||
</a-breadcrumb>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<a-dropdown placement="bottomRight">
|
||||
<a-button size="small" type="text" :title="`主题:${themeLabel}`">
|
||||
<component :is="themeIcon" :size="16" />
|
||||
</a-button>
|
||||
<template #overlay>
|
||||
<a-menu :selected-keys="[appStore.mode]" @click="({ key }) => appStore.setTheme(key)">
|
||||
<a-menu-item key="light"><Sun :size="14" class="mr-1 align-[-2px]" /> 浅色</a-menu-item>
|
||||
<a-menu-item key="dark"><Moon :size="14" class="mr-1 align-[-2px]" /> 深色</a-menu-item>
|
||||
<a-menu-item key="auto"><Monitor :size="14" class="mr-1 align-[-2px]" /> 跟随系统</a-menu-item>
|
||||
</a-menu>
|
||||
</template>
|
||||
</a-dropdown>
|
||||
<span class="app-shell__user">{{ nickname }}</span>
|
||||
<a-button size="small" @click="logout">退出</a-button>
|
||||
</div>
|
||||
|
||||
39
src/stores/app.js
Normal file
39
src/stores/app.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
// 主题三态:light / dark / auto(跟随系统)。持久化键与 index.html 的防闪白脚本约定一致。
|
||||
const THEME_KEY = 'tb:theme'
|
||||
const THEMES = ['light', 'dark', 'auto']
|
||||
|
||||
function systemPrefersDark() {
|
||||
return !!window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches
|
||||
}
|
||||
|
||||
// 把模式落到 <html> 的 dark class 上(tokens.css 的 html.dark 块消费它),返回是否暗色
|
||||
function applyTheme(mode) {
|
||||
const dark = mode === 'dark' || (mode === 'auto' && systemPrefersDark())
|
||||
document.documentElement.classList.toggle('dark', dark)
|
||||
return dark
|
||||
}
|
||||
|
||||
export const useAppStore = defineStore('app', () => {
|
||||
const saved = localStorage.getItem(THEME_KEY)
|
||||
const mode = ref(THEMES.includes(saved) ? saved : 'light')
|
||||
const isDark = ref(applyTheme(mode.value))
|
||||
|
||||
// auto 模式下跟随系统实时切换
|
||||
window.matchMedia?.('(prefers-color-scheme: dark)')?.addEventListener?.('change', () => {
|
||||
if (mode.value === 'auto') isDark.value = applyTheme('auto')
|
||||
})
|
||||
|
||||
watch(mode, (m) => {
|
||||
localStorage.setItem(THEME_KEY, m)
|
||||
isDark.value = applyTheme(m)
|
||||
})
|
||||
|
||||
function setTheme(next) {
|
||||
if (THEMES.includes(next)) mode.value = next
|
||||
}
|
||||
|
||||
return { mode, isDark, setTheme }
|
||||
})
|
||||
@@ -452,4 +452,63 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* 暗色模式(html.dark)
|
||||
* 全部页面/组件只消费 var(--xx),改这里即整体换肤;品牌橙保持不变。
|
||||
* AntD 组件本体由 App.vue 的 theme.darkAlgorithm 负责,本块只管自研层。
|
||||
* ========================================================================== */
|
||||
html.dark {
|
||||
/* 品牌阶的浅色端(primary-soft/weak 的底、登录页渐变):换为暗底上的半透明橙 */
|
||||
--brand-50: rgba(251, 106, 30, 0.1);
|
||||
--brand-100: rgba(251, 106, 30, 0.18);
|
||||
--brand-200: rgba(255, 217, 194, 0.32);
|
||||
--brand-300: #ffb98c;
|
||||
|
||||
/* 语义色:暗底上整体提亮一档,soft 换同色半透明 */
|
||||
--color-success: #4ade80;
|
||||
--color-success-soft: rgba(74, 222, 128, 0.14);
|
||||
--color-warning: #fbbf24;
|
||||
--color-warning-soft: rgba(251, 191, 36, 0.14);
|
||||
--color-danger: #f87171;
|
||||
--color-danger-soft: rgba(248, 113, 113, 0.14);
|
||||
--color-info: #60a5fa;
|
||||
--color-info-soft: rgba(96, 165, 250, 0.14);
|
||||
|
||||
/* 中性色阶:暖深棕系(延续「橘子汽水」的暖调,不用冷灰) */
|
||||
--text-1: #f3eae2;
|
||||
--text-2: #c6b4a4;
|
||||
--text-3: #93816f;
|
||||
--text-4: #6b5c4e;
|
||||
|
||||
--border-1: rgba(255, 220, 195, 0.12);
|
||||
--border-2: rgba(255, 220, 195, 0.2);
|
||||
--divider: rgba(255, 220, 195, 0.08);
|
||||
|
||||
--bg-page: #171009;
|
||||
--bg-surface: #241a12;
|
||||
--bg-surface-2: #2c2117;
|
||||
--bg-hover: #35281c;
|
||||
--bg-mask: rgba(0, 0, 0, 0.62);
|
||||
|
||||
/* 阴影:暗色下改黑基并加深 */
|
||||
--shadow-1: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
--shadow-2: 0 4px 12px rgba(0, 0, 0, 0.38);
|
||||
--shadow-3: 0 10px 30px rgba(0, 0, 0, 0.5);
|
||||
|
||||
/* 玻璃面:白色半透明 → 暖深棕半透明;高光压暗 */
|
||||
--glass-bg: rgba(36, 26, 18, 0.62);
|
||||
--glass-bg-strong: rgba(30, 22, 15, 0.8);
|
||||
--glass-bg-soft: rgba(36, 26, 18, 0.42);
|
||||
--glass-border: rgba(255, 214, 183, 0.16);
|
||||
--glass-border-soft: rgba(255, 214, 183, 0.1);
|
||||
--glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
|
||||
--glass-shadow-hover: 0 14px 40px rgba(0, 0, 0, 0.5);
|
||||
--glass-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.07);
|
||||
|
||||
/* 光斑:暗色下降低透明度,避免彩色光斑盖过内容 */
|
||||
--bg-blob-1: rgba(251, 106, 30, 0.16);
|
||||
--bg-blob-2: rgba(62, 207, 178, 0.1);
|
||||
--bg-blob-3: rgba(255, 123, 169, 0.08);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1522,7 +1522,8 @@ function onGlobalPointerDown(e) {
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--color-warning);
|
||||
color: #fff;
|
||||
/* 深暖棕文字:暗色下 warning 提亮为浅琥珀,白字会看不清 */
|
||||
color: #2b1a10;
|
||||
font-size: 10px;
|
||||
line-height: 1.4;
|
||||
padding: 0 5px;
|
||||
@@ -1650,7 +1651,7 @@ function onGlobalPointerDown(e) {
|
||||
margin: 0;
|
||||
padding: 4px;
|
||||
list-style: none;
|
||||
background: var(--glass-bg-strong, #fff);
|
||||
background: var(--glass-bg-strong);
|
||||
border: 1px solid var(--border-1);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-2);
|
||||
|
||||
@@ -5,6 +5,12 @@ import { feedbackListApi, feedbackHandleApi } from '@/api/request/feedback'
|
||||
import PageShell from '@/components/PageShell.vue'
|
||||
import GlassCard from '@/components/GlassCard.vue'
|
||||
import StateBlock from '@/components/StateBlock.vue'
|
||||
import AppModal from '@/components/AppModal.vue'
|
||||
|
||||
// ============================================================================
|
||||
// 反馈管理:查看用户反馈 + 处理(标记已处理,可填写官方回复)。
|
||||
// 回复会写入 feedbacks.reply_*,用户端「我的反馈」展示并以红点提醒新回复。
|
||||
// ============================================================================
|
||||
|
||||
const loading = ref(false)
|
||||
const loadError = ref('')
|
||||
@@ -14,10 +20,11 @@ const query = reactive({ page: 1, pageSize: 20, status: undefined })
|
||||
|
||||
const columns = [
|
||||
{ title: '内容', dataIndex: 'content', key: 'content', ellipsis: true },
|
||||
{ title: '联系方式', dataIndex: 'contact', key: 'contact', width: 160, ellipsis: true },
|
||||
{ title: '状态', key: 'status', width: 100 },
|
||||
{ title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 180 },
|
||||
{ title: '操作', key: 'action', width: 120, align: 'right' },
|
||||
{ title: '联系方式', dataIndex: 'contact', key: 'contact', width: 150, ellipsis: true },
|
||||
{ title: '回复', key: 'reply', width: 200, ellipsis: true },
|
||||
{ title: '状态', key: 'status', width: 92 },
|
||||
{ title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 170 },
|
||||
{ title: '操作', key: 'action', width: 96, align: 'right' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
@@ -38,13 +45,30 @@ async function load() {
|
||||
|
||||
onMounted(load)
|
||||
|
||||
async function handle(record) {
|
||||
// ===== 处理(弹窗:可填官方回复)=====
|
||||
const handleOpen = ref(false)
|
||||
const handleSaving = ref(false)
|
||||
const handleTarget = ref(null)
|
||||
const replyText = ref('')
|
||||
|
||||
function openHandle(record) {
|
||||
handleTarget.value = record
|
||||
replyText.value = record.reply || ''
|
||||
handleOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmHandle() {
|
||||
if (!handleTarget.value) return
|
||||
handleSaving.value = true
|
||||
try {
|
||||
await feedbackHandleApi(record.id)
|
||||
record.status = 1
|
||||
message.success('已标记处理')
|
||||
await feedbackHandleApi(handleTarget.value.id, replyText.value.trim())
|
||||
message.success(replyText.value.trim() ? '已回复并标记处理' : '已标记处理')
|
||||
handleOpen.value = false
|
||||
load()
|
||||
} catch {
|
||||
/* 拦截器已提示 */
|
||||
} finally {
|
||||
handleSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +85,7 @@ function onTableChange(pag) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PageShell title="反馈管理" desc="查看用户反馈并跟踪处理状态">
|
||||
<PageShell title="反馈管理" desc="查看用户反馈,回复后将在用户端「我的反馈」展示并红点提醒">
|
||||
<template #actions>
|
||||
<a-radio-group v-model:value="query.status" button-style="solid" @change="onFilterChange">
|
||||
<a-radio-button :value="undefined">全部</a-radio-button>
|
||||
@@ -86,7 +110,15 @@ function onTableChange(pag) {
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'content'">
|
||||
<span style="color: var(--text-1)">{{ record.content }}</span>
|
||||
<a-tooltip :title="record.content">
|
||||
<span style="color: var(--text-1)">{{ record.content }}</span>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'reply'">
|
||||
<a-tooltip v-if="record.reply" :title="record.reply">
|
||||
<span style="color: var(--text-2)">{{ record.reply }}</span>
|
||||
</a-tooltip>
|
||||
<span v-else class="app-muted">—</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="record.status === 1 ? 'default' : 'warning'">
|
||||
@@ -94,14 +126,45 @@ function onTableChange(pag) {
|
||||
</a-tag>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button v-if="record.status === 0" type="link" size="small" @click="handle(record)">
|
||||
标记已处理
|
||||
<a-button type="link" size="small" @click="openHandle(record)">
|
||||
{{ record.reply ? '查看/回复' : '处理/回复' }}
|
||||
</a-button>
|
||||
<span v-else class="app-muted">—</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</StateBlock>
|
||||
</GlassCard>
|
||||
|
||||
<AppModal v-model:open="handleOpen" title="处理反馈" :width="520" :confirm-loading="handleSaving"
|
||||
ok-text="提交" @ok="confirmHandle">
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="用户反馈">{{ handleTarget?.content }}</a-descriptions-item>
|
||||
<a-descriptions-item label="联系方式">{{ handleTarget?.contact || '未留' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="提交时间">{{ handleTarget?.createdAt }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<a-form-item class="mt-4">
|
||||
<template #label>
|
||||
<span class="reply-label">官方回复(选填,用户端可见并红点提醒)</span>
|
||||
</template>
|
||||
<a-textarea v-model:value="replyText" :rows="4" :maxlength="1000" show-count
|
||||
placeholder="回复内容将在用户端「意见反馈 → 我的反馈」中展示,填写后自动标记为已处理" />
|
||||
</a-form-item>
|
||||
<div class="reply-hint">
|
||||
仅提交回复不填也视为「已处理」;再次回复会重新提醒用户(红点重置)。
|
||||
</div>
|
||||
</AppModal>
|
||||
</PageShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reply-label {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-2);
|
||||
}
|
||||
.reply-hint {
|
||||
margin-top: 4px;
|
||||
color: var(--text-3);
|
||||
font-size: var(--font-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,7 +13,10 @@ import AppModal from '@/components/AppModal.vue'
|
||||
// 订单管理 + 退款
|
||||
// 契约:GET /order/list(snake_case 响应)、POST /order/refund(camelCase 请求)。
|
||||
// 状态:0待支付 1已发货 2已退款 3已关闭;类型:1会员套餐 2次数包。
|
||||
// 退款为不可逆资金动作:行内按钮 → 二次确认弹窗(可勾选「同时取消订单购买的服务」)。
|
||||
// 退款状态机(refund_status):0未退款 1退款中 2退款成功 3退款失败。
|
||||
// - wxpay 渠道:确认退款 → 平台退款(refund_status=1 退款中)→ 平台推送/轮询确认
|
||||
// → 成功(status=2,按发起时勾选回收权益)或失败(refund_status=3,可重试)。
|
||||
// - apple 渠道(iOS):开发者无法主动退款,按钮变为「手工退款登记」(必填原因,当场落终态)。
|
||||
// ============================================================================
|
||||
|
||||
const userStore = useUserStore()
|
||||
@@ -42,22 +45,35 @@ const STATUS_META = {
|
||||
2: { label: '已退款', color: 'red' },
|
||||
3: { label: '已关闭', color: 'default' },
|
||||
}
|
||||
const REFUND_STATUS_META = {
|
||||
0: { label: '未退款', color: 'default' },
|
||||
1: { label: '退款中', color: 'processing' },
|
||||
2: { label: '退款成功', color: 'red' },
|
||||
3: { label: '退款失败', color: 'volcano' },
|
||||
}
|
||||
const TYPE_META = { 1: '会员套餐', 2: '次数包' }
|
||||
const PROMO_META = { 0: '无', 1: '优惠码', 2: '免单券' }
|
||||
const CHANNEL_META = {
|
||||
wxpay: { label: '微信支付', color: 'green' },
|
||||
apple: { label: 'iOS·App Store', color: 'purple' },
|
||||
}
|
||||
const statusMeta = (v) => STATUS_META[v] || { label: String(v), color: 'default' }
|
||||
const refundStatusMeta = (v) => REFUND_STATUS_META[v] || { label: String(v), color: 'default' }
|
||||
const typeLabel = (v) => TYPE_META[v] || '-'
|
||||
const promoLabel = (v) => PROMO_META[v] || '-'
|
||||
const channelMeta = (v) => CHANNEL_META[v] || null
|
||||
const money = (cents) => `¥${((Number(cents) || 0) / 100).toFixed(2)}`
|
||||
|
||||
const columns = [
|
||||
{ title: '订单号', dataIndex: 'out_trade_no', key: 'out_trade_no', width: 190, ellipsis: true },
|
||||
{ title: '用户', key: 'user', width: 150, ellipsis: true },
|
||||
{ title: '用户', key: 'user', width: 140, ellipsis: true },
|
||||
{ title: '商品', key: 'title', ellipsis: true },
|
||||
{ title: '类型', key: 'order_type', width: 100 },
|
||||
{ title: '类型', key: 'order_type', width: 92 },
|
||||
{ title: '实付', key: 'paid_price_cents', width: 100 },
|
||||
{ title: '状态', key: 'status', width: 100 },
|
||||
{ title: '渠道', key: 'pay_channel', width: 96 },
|
||||
{ title: '状态', key: 'status', width: 118 },
|
||||
{ title: '创建时间', dataIndex: 'created_at', key: 'created_at', width: 170 },
|
||||
{ title: '操作', key: 'action', width: 90, align: 'right' },
|
||||
{ title: '操作', key: 'action', width: 108, align: 'right' },
|
||||
]
|
||||
|
||||
async function load() {
|
||||
@@ -102,33 +118,55 @@ function onTableChange(pag) {
|
||||
const isEmpty = computed(() => !loading.value && !loadError.value && !list.value.length)
|
||||
const hasFilter = computed(() => !!query.keyword || query.status !== undefined)
|
||||
|
||||
// ===== 退款(二次确认 + 可选回收服务)=====
|
||||
// ===== 退款(二次确认 + 可选回收服务 + 原因 + 手工登记)=====
|
||||
const refundOpen = ref(false)
|
||||
const refundSaving = ref(false)
|
||||
const refundTarget = ref(null)
|
||||
const revokeService = ref(false)
|
||||
const refundReason = ref('')
|
||||
const refundManual = ref(false)
|
||||
|
||||
// apple 渠道订单:开发者无法主动退款,只能登记(用户向 App Store 申请)
|
||||
const isAppleOrder = computed(() => refundTarget.value?.pay_channel === 'apple')
|
||||
|
||||
// 行内按钮文案:apple → 手工退款;退款失败(3) → 重试
|
||||
function refundActionLabel(record) {
|
||||
if (record.refund_status === 3) return '重试退款'
|
||||
return record.pay_channel === 'apple' ? '手工退款' : '退款'
|
||||
}
|
||||
|
||||
function openRefund(record) {
|
||||
if (record.status !== 1) {
|
||||
message.warning('仅「已开通」的订单可退款')
|
||||
// 允许发起:已开通(1) 且(未退款 / 退款失败可重试)
|
||||
if (record.status !== 1 || (record.refund_status !== 0 && record.refund_status !== 3)) {
|
||||
message.warning('仅「已开通」且未在退款流程中的订单可退款')
|
||||
return
|
||||
}
|
||||
refundTarget.value = record
|
||||
revokeService.value = false // 默认不勾选:默认保留用户已购服务
|
||||
refundReason.value = ''
|
||||
refundManual.value = record.pay_channel === 'apple'
|
||||
refundOpen.value = true
|
||||
}
|
||||
|
||||
async function confirmRefund() {
|
||||
if (!refundTarget.value) return
|
||||
// 手工登记(显式勾选或 apple 渠道)必填原因
|
||||
const needReason = refundManual.value || isAppleOrder.value
|
||||
if (needReason && !refundReason.value.trim()) {
|
||||
message.warning('手工退款登记必须填写退款原因')
|
||||
return
|
||||
}
|
||||
refundSaving.value = true
|
||||
try {
|
||||
const res = await orderRefundApi({
|
||||
outTradeNo: refundTarget.value.out_trade_no,
|
||||
revokeService: revokeService.value,
|
||||
reason: refundReason.value.trim() || undefined,
|
||||
manual: refundManual.value || undefined,
|
||||
})
|
||||
// 拦截器对业务错误(非 0 code)已弹提示并返回 undefined;成功返回 result 对象
|
||||
if (res) {
|
||||
message.success(res.message || '退款成功')
|
||||
message.success(res.message || '退款已受理')
|
||||
refundOpen.value = false
|
||||
load()
|
||||
}
|
||||
@@ -184,6 +222,12 @@ async function confirmRefund() {
|
||||
<span class="cell-money">{{ money(record.paid_price_cents) }}</span>
|
||||
<span v-if="record.discount_cents > 0" class="cell-origin">{{ money(record.origin_price_cents) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'pay_channel'">
|
||||
<a-tag v-if="channelMeta(record.pay_channel)" :color="channelMeta(record.pay_channel).color">
|
||||
{{ channelMeta(record.pay_channel).label }}
|
||||
</a-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'status'">
|
||||
<a-tag :color="statusMeta(record.status).color">{{ record.status_text || statusMeta(record.status).label }}</a-tag>
|
||||
<a-tooltip v-if="record.status === 2" :title="record.service_revoked ? '退款时已回收所购服务' : '退款时未回收所购服务'">
|
||||
@@ -191,11 +235,19 @@ async function confirmRefund() {
|
||||
{{ record.service_revoked ? '已回收' : '未回收' }}
|
||||
</a-tag>
|
||||
</a-tooltip>
|
||||
<!-- 退款子状态:退款中 / 退款失败(失败原因悬停可见) -->
|
||||
<a-tooltip v-if="record.refund_status === 1 || record.refund_status === 3"
|
||||
:title="record.refund_status === 3 ? (record.refund_reason ? '退款原因:' + record.refund_reason : '可点击「重试退款」重新发起') : '已发起平台退款,等待确认后自动回收权益'">
|
||||
<a-tag :color="refundStatusMeta(record.refund_status).color" class="ml-1">
|
||||
{{ refundStatusMeta(record.refund_status).label }}
|
||||
</a-tag>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button v-if="canRefund" type="link" size="small" danger
|
||||
:disabled="record.status !== 1" @click="openRefund(record)">
|
||||
退款
|
||||
<a-button v-if="canRefund" type="link" size="small" :danger="record.pay_channel !== 'apple'"
|
||||
:disabled="record.status !== 1 || (record.refund_status !== 0 && record.refund_status !== 3)"
|
||||
@click="openRefund(record)">
|
||||
{{ refundActionLabel(record) }}
|
||||
</a-button>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
@@ -208,6 +260,8 @@ async function confirmRefund() {
|
||||
ok-text="确认退款" ok-type="danger" @ok="confirmRefund">
|
||||
<a-alert type="warning" show-icon class="mb-3"
|
||||
message="退款为不可逆操作,请确认订单信息无误后再提交。" />
|
||||
<a-alert v-if="isAppleOrder" type="info" show-icon class="mb-3"
|
||||
message="iOS(App Store IAP)订单无法由开发者主动退款:请引导用户向 App Store 申请,退款完成后在此登记。" />
|
||||
<a-descriptions :column="1" size="small" bordered>
|
||||
<a-descriptions-item label="订单号">{{ refundTarget?.out_trade_no }}</a-descriptions-item>
|
||||
<a-descriptions-item label="商品">{{ refundTarget?.title || '-' }}</a-descriptions-item>
|
||||
@@ -215,13 +269,30 @@ async function confirmRefund() {
|
||||
{{ refundTarget?.nickname || refundTarget?.username || ('用户#' + refundTarget?.user_id) }}
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="实付金额">{{ money(refundTarget?.paid_price_cents) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="支付渠道">
|
||||
{{ isAppleOrder ? 'iOS · App Store' : '微信虚拟支付' }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<a-checkbox v-model:checked="revokeService" class="mt-4">
|
||||
同时取消订单购买的服务
|
||||
</a-checkbox>
|
||||
<div class="revoke-hint">
|
||||
勾选后将<strong>回收用户已获权益</strong>:会员套餐将回退对应有效期,次数包将扣回对应付费次数;
|
||||
不勾选则仅将订单置为「已退款」,用户权益保留。
|
||||
不勾选则仅退款,用户权益保留。微信支付订单在平台确认退款成功后自动执行回收。
|
||||
</div>
|
||||
<a-checkbox v-model:checked="refundManual" class="mt-3" :disabled="isAppleOrder">
|
||||
手工退款登记(已在微信支付商户平台 / App Store 后台完成退款,此处仅记录)
|
||||
</a-checkbox>
|
||||
<a-form-item class="mt-3" required>
|
||||
<template #label>
|
||||
<span class="refund-reason-label">退款原因{{ (refundManual || isAppleOrder) ? '(必填)' : '(选填)' }}</span>
|
||||
</template>
|
||||
<a-textarea v-model:value="refundReason" :rows="2" :maxlength="200" show-count
|
||||
placeholder="如:用户重复购买 / 误操作购买 / App Store 已退款(登记用)" />
|
||||
</a-form-item>
|
||||
<div v-if="!refundManual && !isAppleOrder" class="revoke-hint">
|
||||
提交后将调用微信退款接口(支付 365 天内可退),订单进入「退款中」;
|
||||
平台确认成功后自动置为「已退款」并按上方勾选回收权益,失败则标记可重试。
|
||||
</div>
|
||||
</AppModal>
|
||||
</PageShell>
|
||||
@@ -262,4 +333,8 @@ async function confirmRefund() {
|
||||
font-size: var(--font-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.refund-reason-label {
|
||||
font-size: var(--font-sm);
|
||||
color: var(--text-2);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user