fix(channel-monitor): drop soft delete, refactor feature flag to declarative form

### 后端修复:日志表不该用软删除

channel_monitor_histories / channel_monitor_daily_rollups 都是日志/聚合表,
没有恢复需求。110 里加的 SoftDeleteMixin 会让 DELETE 自动变成 UPDATE deleted_at,
导致行和索引只增不减,徒增磁盘占用和查询成本。

改回分批物理删(参考 OpsCleanupService.deleteOldRowsByID 模板):

- ent schema 移除 SoftDeleteMixin,重新 go generate
- repo 新增 deleteChannelMonitorBatched 辅助 + 两条 prune SQL 常量
  (WITH batch AS SELECT id LIMIT 5000 → DELETE IN batch)
- DeleteHistoryBefore / DeleteRollupsBefore 改调分批 raw SQL
- 移除 ComputeAvailability / ComputeAvailabilityForMonitors / UpsertDailyRollupsFor /
  ListLatestPerModel / ListLatestForMonitorIDs / ListRecentHistoryForMonitors 等
  raw SQL 中的 deleted_at IS NULL 过滤
- UpsertDailyRollupsFor 的 ON CONFLICT 去掉 deleted_at = NULL 重置
- migration 111 DROP COLUMN deleted_at + 对应索引(110 已部署但 maintenance
  首跑在次日 02:00,此时尚无业务数据在依赖软删除)

### 前端重构:feature flag 声明式 + 复用

AppSidebar.vue 里 7 处 `...(flag ? [item] : [])` 样板代码删光,改为 NavItem 加
featureFlag?: () => boolean | undefined 字段,加一个 applyFeatureFlags 递归
过滤(含 children)。语义统一为 `!== false`(宽容策略,undefined 时默认显示,
避免 public settings 未加载完成时菜单闪烁消失 — 对应用户反馈"刷新后菜单消失
要去保存设置才回来")。

- 集中声明 4 个 flag getter:flagChannelMonitor / flagPayment /
  flagOpsMonitoring / flagAdminPayment
- 提取 buildSelfNavItems 复用用户端主菜单和管理员"我的账户"子菜单
- 未来新增开关:在统一位置加一个 flag getter + 给对应 NavItem 加字段
  (不用再动渲染逻辑)

bump 0.1.114.29
This commit is contained in:
erio
2026-04-21 10:45:30 +08:00
parent 8cf83c984e
commit ef6ec8a15a
21 changed files with 188 additions and 802 deletions

View File

@@ -199,6 +199,28 @@ interface NavItem {
* does NOT navigate to its `path`. The `path` is purely a stable key.
*/
expandOnly?: boolean
/**
* 可选的功能开关 getter。返回 false 时菜单项被隐藏;返回 undefined/true 时显示。
* 宽容策略undefined → 显示)避免 public settings 未加载完成时菜单闪烁消失。
* Getter 里访问的 reactive 来源store / composable会被 computed 自动追踪,
* 开关切换时菜单自动更新。
*/
featureFlag?: () => boolean | undefined
}
// applyFeatureFlags 递归过滤掉 featureFlag() === false 的节点(含子节点)。
// 使用 `!== false` 宽容语义undefined设置未加载或 true 都视为显示。
function applyFeatureFlags(items: NavItem[]): NavItem[] {
const out: NavItem[] = []
for (const item of items) {
if (item.featureFlag && item.featureFlag() === false) continue
if (item.children) {
out.push({ ...item, children: applyFeatureFlags(item.children) })
} else {
out.push(item)
}
}
return out
}
const { t } = useI18n()
@@ -605,36 +627,27 @@ const ChevronDownIcon = {
)
}
// User navigation items (for regular users)
const userNavItems = computed((): NavItem[] => {
const items: NavItem[] = [
{ path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
// 各个开关集中声明:所有菜单项引用这里的 getter未来加新开关只需在此加一个常量。
// getter 返回 false = 隐藏undefined/true = 显示(宽容策略,避免 public settings 未加载闪烁)。
const flagChannelMonitor = () => appStore.cachedPublicSettings?.channel_monitor_enabled
const flagPayment = () => appStore.cachedPublicSettings?.payment_enabled
const flagOpsMonitoring = () => adminSettingsStore.opsMonitoringEnabled
const flagAdminPayment = () => adminSettingsStore.paymentEnabled
// buildSelfNavItems 构造用户自己的导航项(用户端主菜单和管理员的"我的账户"子菜单共享这组声明)。
// withDashboard=true 时包含仪表盘用户端false 时不含(管理员的个人区已经有独立仪表盘入口)。
function buildSelfNavItems(withDashboard: boolean): NavItem[] {
const items: NavItem[] = []
if (withDashboard) {
items.push({ path: '/dashboard', label: t('nav.dashboard'), icon: DashboardIcon })
}
items.push(
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
...(appStore.cachedPublicSettings?.channel_monitor_enabled
? [{ path: '/monitor', label: t('nav.channelStatus'), icon: SignalIcon }]
: []),
{ path: '/monitor', label: t('nav.channelStatus'), icon: SignalIcon, featureFlag: flagChannelMonitor },
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
...(appStore.cachedPublicSettings?.payment_enabled
? [
{
path: '/purchase',
label: t('nav.buySubscription'),
icon: RechargeSubscriptionIcon,
hideInSimpleMode: true
},
]
: []),
...(appStore.cachedPublicSettings?.payment_enabled
? [
{
path: '/orders',
label: t('nav.myOrders'),
icon: OrderListIcon,
hideInSimpleMode: true
},
]
: []),
{ path: '/purchase', label: t('nav.buySubscription'), icon: RechargeSubscriptionIcon, hideInSimpleMode: true, featureFlag: flagPayment },
{ path: '/orders', label: t('nav.myOrders'), icon: OrderListIcon, hideInSimpleMode: true, featureFlag: flagPayment },
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
...customMenuItemsForUser.value.map((item): NavItem => ({
@@ -643,50 +656,21 @@ const userNavItems = computed((): NavItem[] => {
icon: null,
iconSvg: item.icon_svg,
})),
]
return authStore.isSimpleMode ? items.filter(item => !item.hideInSimpleMode) : items
})
)
return items
}
// finalizeNav 合并三重过滤featureFlag 过滤 + simple 模式过滤。
function finalizeNav(items: NavItem[]): NavItem[] {
const visible = applyFeatureFlags(items)
return authStore.isSimpleMode ? visible.filter(item => !item.hideInSimpleMode) : visible
}
// User navigation items (for regular users)
const userNavItems = computed((): NavItem[] => finalizeNav(buildSelfNavItems(true)))
// Personal navigation items (for admin's "My Account" section, without Dashboard)
const personalNavItems = computed((): NavItem[] => {
const items: NavItem[] = [
{ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon },
{ path: '/usage', label: t('nav.usage'), icon: ChartIcon, hideInSimpleMode: true },
...(appStore.cachedPublicSettings?.channel_monitor_enabled
? [{ path: '/monitor', label: t('nav.channelStatus'), icon: SignalIcon }]
: []),
{ path: '/subscriptions', label: t('nav.mySubscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
...(appStore.cachedPublicSettings?.payment_enabled
? [
{
path: '/purchase',
label: t('nav.buySubscription'),
icon: RechargeSubscriptionIcon,
hideInSimpleMode: true
},
]
: []),
...(appStore.cachedPublicSettings?.payment_enabled
? [
{
path: '/orders',
label: t('nav.myOrders'),
icon: OrderListIcon,
hideInSimpleMode: true
},
]
: []),
{ path: '/redeem', label: t('nav.redeem'), icon: GiftIcon, hideInSimpleMode: true },
{ path: '/profile', label: t('nav.profile'), icon: UserIcon },
...customMenuItemsForUser.value.map((item): NavItem => ({
path: `/custom/${item.id}`,
label: item.label,
icon: null,
iconSvg: item.icon_svg,
})),
]
return authStore.isSimpleMode ? items.filter(item => !item.hideInSimpleMode) : items
})
const personalNavItems = computed((): NavItem[] => finalizeNav(buildSelfNavItems(false)))
// Custom menu items filtered by visibility
const customMenuItemsForUser = computed(() => {
@@ -706,9 +690,7 @@ const customMenuItemsForAdmin = computed(() => {
const adminNavItems = computed((): NavItem[] => {
const baseItems: NavItem[] = [
{ path: '/admin/dashboard', label: t('nav.dashboard'), icon: DashboardIcon },
...(adminSettingsStore.opsMonitoringEnabled
? [{ path: '/admin/ops', label: t('nav.ops'), icon: ChartIcon }]
: []),
{ path: '/admin/ops', label: t('nav.ops'), icon: ChartIcon, featureFlag: flagOpsMonitoring },
{ path: '/admin/users', label: t('nav.users'), icon: UsersIcon, hideInSimpleMode: true },
{ path: '/admin/groups', label: t('nav.groups'), icon: FolderIcon, hideInSimpleMode: true },
{
@@ -719,9 +701,7 @@ const adminNavItems = computed((): NavItem[] => {
expandOnly: true,
children: [
{ path: '/admin/channels/pricing', label: t('nav.channelPricing'), icon: PriceTagIcon },
...(appStore.cachedPublicSettings?.channel_monitor_enabled
? [{ path: '/admin/channels/monitor', label: t('nav.channelMonitor'), icon: SignalIcon }]
: []),
{ path: '/admin/channels/monitor', label: t('nav.channelMonitor'), icon: SignalIcon, featureFlag: flagChannelMonitor },
],
},
{ path: '/admin/subscriptions', label: t('nav.subscriptions'), icon: CreditCardIcon, hideInSimpleMode: true },
@@ -730,43 +710,40 @@ const adminNavItems = computed((): NavItem[] => {
{ path: '/admin/proxies', label: t('nav.proxies'), icon: ServerIcon },
{ path: '/admin/redeem', label: t('nav.redeemCodes'), icon: TicketIcon, hideInSimpleMode: true },
{ path: '/admin/promo-codes', label: t('nav.promoCodes'), icon: GiftIcon, hideInSimpleMode: true },
...(adminSettingsStore.paymentEnabled
? [
{
path: '/admin/orders',
label: t('nav.orderManagement'),
icon: OrderIcon,
hideInSimpleMode: true,
expandOnly: true,
children: [
{ path: '/admin/orders/dashboard', label: t('nav.paymentDashboard'), icon: ChartIcon },
{ path: '/admin/orders', label: t('nav.orderManagement'), icon: OrderIcon },
{ path: '/admin/orders/plans', label: t('nav.paymentPlans'), icon: CreditCardIcon },
],
},
]
: []),
{
path: '/admin/orders',
label: t('nav.orderManagement'),
icon: OrderIcon,
hideInSimpleMode: true,
expandOnly: true,
featureFlag: flagAdminPayment,
children: [
{ path: '/admin/orders/dashboard', label: t('nav.paymentDashboard'), icon: ChartIcon },
{ path: '/admin/orders', label: t('nav.orderManagement'), icon: OrderIcon },
{ path: '/admin/orders/plans', label: t('nav.paymentPlans'), icon: CreditCardIcon },
],
},
{ path: '/admin/usage', label: t('nav.usage'), icon: ChartIcon }
]
const visible = applyFeatureFlags(baseItems)
// 简单模式下,在系统设置前插入 API密钥
if (authStore.isSimpleMode) {
const filtered = baseItems.filter(item => !item.hideInSimpleMode)
const filtered = visible.filter(item => !item.hideInSimpleMode)
filtered.push({ path: '/keys', label: t('nav.apiKeys'), icon: KeyIcon })
filtered.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
// Add admin custom menu items after settings
for (const cm of customMenuItemsForAdmin.value) {
filtered.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg })
}
return filtered
}
baseItems.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
// Add admin custom menu items after settings
visible.push({ path: '/admin/settings', label: t('nav.settings'), icon: CogIcon })
for (const cm of customMenuItemsForAdmin.value) {
baseItems.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg })
visible.push({ path: `/custom/${cm.id}`, label: cm.label, icon: null, iconSvg: cm.icon_svg })
}
return baseItems
return visible
})
function toggleSidebar() {