Files
Henry_Du 3eff710d5d feat(微信订阅): 接入歌曲排期模板并加固部署流程
订阅消息(小程序侧唯一能主动触达用户的通道):
- 新增 server/utils/wechatSubscribeFields.ts:按微信字段类型裁剪值(thing≤20、
  phrase 必须 5 个汉字、time/date/character_string 各自上限),空白压成一行,
  北京时间 time 由 playTime 的开始时间生成,解析不出退化为当天 00:00。
- wechatSubscribeService 内置 SONG_SELECTED 模板(站长新给的那个模板 ID),
  并转发上述纯函数;notificationService.createSongSelectedNotification 现在会带上
  playDate/playTime/sequence,三个排期接口(schedule / publish / bulk-publish)补传 sequence。
- 新增 test/wechatSubscribeFields.test.mjs(10 例,node --test 全绿)。
- test-subscribe 管理接口支持 vars 覆盖并返回真实发送值,方便站长核对字段名。

部署流程(Gitea Actions 容器里没有 buildx 插件,旧流程在此硬失败):
- 每个 job 各自安装/探测 docker-buildx(v0.19.3,走 gh-proxy 再退直连),
  不把 `buildx version` 当唯一依据;装不上时降级为普通 `docker build` 继续发布,
  不再因为缺插件整条流水线失败。

注意:SONG_SELECTED 的字段名(thing2/date4/time1/character_string3/phrase5)是按常见
命名推断的,需在公众平台后台核对;不一致时用环境变量覆盖,模板 ID 变更后老用户需重新授权。
2026-10-11 00:04:14 +08:00

289 lines
8.8 KiB
Vue

<!-- 侧边栏组件 -->
<template>
<aside
:class="[
'fixed inset-y-0 left-0 z-50 w-64 bg-[#ffffff] border-r border-zinc-200 shadow-sm transform transition-transform duration-300 ease-in-out lg:translate-x-0',
isOpen ? 'translate-x-0' : '-translate-x-full'
]"
>
<div class="flex flex-col h-full p-4">
<!-- 品牌标识区域 -->
<div class="flex items-center px-2 mb-6 mt-2">
<NuxtLink to="/" class="flex items-center gap-2.5 group">
<!-- Logo 图标 -->
<div class="flex-shrink-0 group-hover:scale-110 transition-all duration-300">
<img
:src="logo"
alt="宝一外校园点歌系统 Logo"
class="w-8 h-8 object-contain"
width="32"
height="32"
decoding="async"
loading="lazy"
>
</div>
<!-- 品牌文字 -->
<div class="flex flex-col justify-center">
<h1 class="font-bold text-lg text-zinc-900 leading-none tracking-tight">宝一外校园点歌系统</h1>
<p
class="text-[10px] text-zinc-500 mt-1.5 uppercase tracking-widest font-bold leading-none"
>
管理控制台
</p>
</div>
</NuxtLink>
</div>
<!-- 导航菜单区域 -->
<nav class="admin-sidebar-nav flex-1 space-y-6 overflow-y-auto pr-2 custom-scrollbar">
<div v-for="(group, idx) in menuGroups" :key="idx" class="space-y-1">
<template v-if="shouldShowGroup(group)">
<!-- 分组标题 -->
<h3 class="px-3 text-[10px] font-bold text-zinc-600 uppercase tracking-[0.2em] mb-2">
{{ group.section }}
</h3>
<!-- 菜单项列表 -->
<template v-for="item in group.items" :key="item.id">
<button
v-if="permissions.canAccessPage(item.permissionId || item.id)"
:class="[
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-[13px] font-bold transition-all group border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/80 focus-visible:ring-inset',
activeTab === item.id
? 'bg-blue-50 text-blue-700 border-blue-200'
: 'text-zinc-600 hover:text-zinc-900 hover:bg-zinc-100 border-transparent',
getTourClass(item.id)
]"
@click="onNavigate(item.id)"
>
<!-- 菜单图标 -->
<component
:is="item.icon"
:size="18"
:class="
activeTab === item.id
? 'text-blue-700'
: 'text-zinc-500 group-hover:text-zinc-700'
"
/>
<span class="truncate">{{ item.label }}</span>
<!-- 选中状态指示器 -->
<div
v-if="activeTab === item.id"
class="ml-auto w-1 h-1 bg-blue-400 rounded-full shadow-[0_0_8px_rgba(96,165,250,0.6)]"
/>
</button>
</template>
</template>
</div>
</nav>
<!-- 用户信息及退出登录 -->
<div class="mt-4 pt-4 border-t border-zinc-200">
<div
class="flex items-center gap-3 p-3 rounded-lg bg-zinc-50 border border-zinc-200 hover:bg-zinc-100 transition-colors"
>
<!-- 用户头像/首字母 -->
<img
v-if="currentUser?.avatar && !avatarError"
:src="currentUser.avatar"
class="w-10 h-10 rounded-lg object-cover border border-zinc-200 shrink-0"
@error="avatarError = true"
>
<div
v-else
class="w-10 h-10 rounded-lg bg-zinc-100 flex items-center justify-center text-zinc-600 font-bold border border-zinc-200 shrink-0"
>
{{ (currentUser?.name || '管').charAt(0) }}
</div>
<!-- 用户详细信息 -->
<div class="flex-1 min-w-0">
<p class="text-xs font-black truncate text-zinc-900">
{{ currentUser?.name || '管理员' }}
</p>
<p
class="text-[10px] text-zinc-500 truncate uppercase tracking-wider font-medium mt-0.5"
>
{{ currentUser?.role?.replace('_', ' ') || 'ADMIN' }}
</p>
</div>
<!-- 退出按钮 -->
<button
class="p-2 text-zinc-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all"
title="退出登录"
@click="$emit('logout')"
>
<LogOut :size="16" />
</button>
</div>
</div>
</div>
</aside>
</template>
<script setup>
/**
* 后台管理侧边栏组件
*/
import {
LayoutDashboard,
CalendarDays,
Printer,
Music2,
BarChart3,
Users,
Key,
Bell,
Mail,
LogOut,
Clock,
FileEdit,
ListChecks,
BookOpen,
Ban,
Globe,
Image as ImageIcon,
ScrollText,
Database,
Lock,
Shield
} from '@lucide/vue'
import logo from '~~/public/images/logo.png'
import { rolesConfig } from '~/composables/usePermissions'
const avatarError = ref(false)
const props = defineProps({
// 侧边栏是否打开(移动端)
isOpen: Boolean,
// 当前激活的标签页 ID
activeTab: String,
// 当前登录用户信息
currentUser: Object,
// 权限控制对象
permissions: Object,
// 站点标题
siteTitle: String
})
watch(
() => props.currentUser?.avatar,
() => {
avatarError.value = false
}
)
const emit = defineEmits(['navigate', 'close', 'logout'])
// 菜单分组配置
const menuGroups = [
{
section: '数据中心',
items: [
{ icon: LayoutDashboard, label: '数据概览', id: 'overview' },
{ icon: BookOpen, label: '流程与教程', id: 'workflow-tutorial' },
{ icon: BarChart3, label: '数据分析', id: 'data-analysis', permissionId: 'data-analysis' }
]
},
{
section: '播音管理',
items: [
{ icon: Music2, label: '歌曲管理', id: 'songs' },
{ icon: CalendarDays, label: '排期管理', id: 'schedule' },
{ icon: Printer, label: '打印排期', id: 'print' },
{ icon: ImageIcon, label: '导入酷狗', id: 'playlist-card' }
]
},
{
section: '业务设置',
items: [
{ icon: CalendarDays, label: '选曲日期', id: 'expected-play-dates', permissionId: 'settings' },
{ icon: FileEdit, label: '投稿管理', id: 'request-times' },
{ icon: BookOpen, label: '学期管理', id: 'semesters' },
{ icon: Clock, label: '播出时段', id: 'playtimes' }
]
},
{
section: '用户与权限',
items: [
{ icon: Users, label: '用户管理', id: 'users' },
{ icon: Shield, label: '权限组管理', id: 'roles', permissionId: 'roles' },
{ icon: Ban, label: '黑名单管理', id: 'blacklist' },
{ icon: Key, label: 'API密钥管理', id: 'api-keys' }
]
},
{
section: '系统运维',
items: [
{ icon: Globe, label: '站点配置', id: 'site-config' },
{ icon: ScrollText, label: '更新日志', id: 'changelog' },
{ icon: Bell, label: '通知管理', id: 'notifications' },
{ icon: Mail, label: '邮件配置', id: 'smtp-config' },
{ icon: Database, label: '数据库操作', id: 'database' }
]
},
{
section: '账户管理',
items: [{ icon: Lock, label: '修改密码', id: 'password' }]
}
]
/**
* 判断是否应该显示该菜单组
* @param {Object} group 菜单组对象
*/
const shouldShowGroup = (group) => {
if (!props.permissions) return true
return group.items.some((item) => props.permissions.canAccessPage(item.permissionId || item.id))
}
/**
* 导航点击处理
* @param {string} id 菜单项 ID
*/
const onNavigate = (id) => {
if (id === 'password') {
navigateTo('/change-password')
return
}
emit('navigate', id)
}
const getTourClass = (id) => `tour-nav-${id}`
/**
* 获取角色显示名称
* @param {string} role 角色标识
*/
const getRoleDisplayName = (role) => {
if (rolesConfig.value && rolesConfig.value[role]?.displayName) {
return rolesConfig.value[role].displayName
}
const roleNames = {
USER: '学生(普通用户)',
SONG_ADMIN: '工作人员',
VICE_MINISTER: '副部',
ADMIN: '部长',
RESIDENT: '常驻',
SUPER_ADMIN: '技术运维'
}
return roleNames[role] || role
}
</script>
<style scoped>
/* 自定义滚动条样式 */
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: var(--bg-tertiary, #27272a);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #3f3f46;
}
</style>