功能更新

This commit is contained in:
李琦
2026-09-29 15:49:28 +08:00
parent 1fa62d10e1
commit 1768c533f9
11 changed files with 367 additions and 75 deletions

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 })
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
View 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 }
})

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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>

View File

@@ -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>