Commit 63f539b3 authored by erio's avatar erio
Browse files

fix: merge general improvements from release branch

Backend:
- gateway_handler: pass subject.UserID instead of int64(0) for user-level routing
- setting_handler: add missing BalanceLowNotifyRechargeURL to UpdateSettings response
- openai_gateway_service: use applyAccountStatsCost for account stats pricing integration
- embed_on: add local file override (data/public/) for embedded frontend assets

Frontend:
- useTableSelection: add batchUpdate method for batch operations
- AccountsView: virtual scrolling params, Set-based isSelected, swipe virtualization
- ProxiesView: add batchUpdate to selection and swipe-select
- BulkEditAccountModal: fix submit handler to prevent event object as argument
- SettingsView: move payload construction outside try block
- i18n: add general translation keys (saved, deleted, view, validation, allowUserRefund)
- api/client: reorder error fields for consistency
- stores/payment: clarify pollOrderStatus JSDoc
parent c14d7393
......@@ -1071,6 +1071,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
EnableCCHSigning: updatedSettings.EnableCCHSigning,
BalanceLowNotifyEnabled: updatedSettings.BalanceLowNotifyEnabled,
BalanceLowNotifyThreshold: updatedSettings.BalanceLowNotifyThreshold,
BalanceLowNotifyRechargeURL: updatedSettings.BalanceLowNotifyRechargeURL,
AccountQuotaNotifyEnabled: updatedSettings.AccountQuotaNotifyEnabled,
AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(updatedSettings.AccountQuotaNotifyEmails),
PaymentEnabled: updatedPaymentCfg.Enabled,
......
......@@ -522,7 +522,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
for {
// 选择支持该模型的账号
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), currentAPIKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, parsedReq.MetadataUserID, int64(0))
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), currentAPIKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, parsedReq.MetadataUserID, subject.UserID)
if err != nil {
if len(fs.FailedAccountIDs) == 0 {
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts: "+err.Error(), streamStarted)
......
......@@ -4575,14 +4575,9 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
// 计算账号统计定价费用(使用最终上游模型匹配自定义规则)
if apiKey.GroupID != nil {
statsModel := result.UpstreamModel
if statsModel == "" {
statsModel = result.Model
}
usageLog.AccountStatsCost = resolveAccountStatsCost(
ctx, s.channelService, s.billingService,
account.ID, *apiKey.GroupID, statsModel,
tokens, 1, cost.TotalCost,
applyAccountStatsCost(ctx, usageLog, s.channelService, s.billingService,
account.ID, *apiKey.GroupID, result.UpstreamModel, result.Model,
tokens, cost.TotalCost,
)
}
......
......@@ -10,6 +10,8 @@ import (
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"time"
......@@ -32,11 +34,12 @@ type PublicSettingsProvider interface {
// FrontendServer serves the embedded frontend with settings injection
type FrontendServer struct {
distFS fs.FS
fileServer http.Handler
baseHTML []byte
cache *HTMLCache
settings PublicSettingsProvider
distFS fs.FS
fileServer http.Handler
baseHTML []byte
cache *HTMLCache
settings PublicSettingsProvider
overrideDir string // local file override directory
}
// NewFrontendServer creates a new frontend server with settings injection
......@@ -62,11 +65,12 @@ func NewFrontendServer(settingsProvider PublicSettingsProvider) (*FrontendServer
cache.SetBaseHTML(baseHTML)
return &FrontendServer{
distFS: distFS,
fileServer: http.FileServer(http.FS(distFS)),
baseHTML: baseHTML,
cache: cache,
settings: settingsProvider,
distFS: distFS,
fileServer: http.FileServer(http.FS(distFS)),
baseHTML: baseHTML,
cache: cache,
settings: settingsProvider,
overrideDir: filepath.Join("data", "public"),
}, nil
}
......@@ -99,6 +103,11 @@ func (s *FrontendServer) Middleware() gin.HandlerFunc {
return
}
// Try local override first
if s.tryServeOverride(c, cleanPath) {
return
}
// Serve static files normally
s.fileServer.ServeHTTP(c.Writer, c.Request)
c.Abort()
......@@ -114,6 +123,22 @@ func (s *FrontendServer) fileExists(path string) bool {
return true
}
// tryServeOverride checks if a local override file exists and serves it.
// Files in overrideDir take precedence over embedded files.
func (s *FrontendServer) tryServeOverride(c *gin.Context, cleanPath string) bool {
if s.overrideDir == "" {
return false
}
filePath := filepath.Join(s.overrideDir, filepath.Clean("/"+cleanPath))
info, err := os.Stat(filePath)
if err != nil || info.IsDir() {
return false
}
c.File(filePath)
c.Abort()
return true
}
func (s *FrontendServer) serveIndexHTML(c *gin.Context) {
// Get nonce from context (generated by SecurityHeaders middleware)
nonce := middleware.GetNonceFromContext(c)
......@@ -226,6 +251,7 @@ func ServeEmbeddedFrontend() gin.HandlerFunc {
panic("failed to get dist subdirectory: " + err.Error())
}
fileServer := http.FileServer(http.FS(distFS))
overrideDir := filepath.Join("data", "public")
return func(c *gin.Context) {
path := c.Request.URL.Path
......@@ -242,6 +268,10 @@ func ServeEmbeddedFrontend() gin.HandlerFunc {
if file, err := distFS.Open(cleanPath); err == nil {
_ = file.Close()
// Try local override first
if tryServeOverrideFile(c, overrideDir, cleanPath) {
return
}
fileServer.ServeHTTP(c.Writer, c.Request)
c.Abort()
return
......@@ -251,6 +281,21 @@ func ServeEmbeddedFrontend() gin.HandlerFunc {
}
}
// tryServeOverrideFile is a standalone version of tryServeOverride for legacy usage.
func tryServeOverrideFile(c *gin.Context, overrideDir, cleanPath string) bool {
if overrideDir == "" {
return false
}
filePath := filepath.Join(overrideDir, filepath.Clean("/"+cleanPath))
info, err := os.Stat(filePath)
if err != nil || info.IsDir() {
return false
}
c.File(filePath)
c.Abort()
return true
}
func shouldBypassEmbeddedFrontend(path string) bool {
trimmed := strings.TrimSpace(path)
return strings.HasPrefix(trimmed, "/api/") ||
......
......@@ -270,9 +270,9 @@ apiClient.interceptors.response.use(
return Promise.reject({
status,
code: apiData.code,
reason: apiData.reason,
error: apiData.error,
message: apiData.message || apiData.detail || error.message,
reason: apiData.reason,
metadata: apiData.metadata,
})
}
......
......@@ -5,7 +5,7 @@
width="wide"
@close="handleClose"
>
<form id="bulk-edit-account-form" class="space-y-5" @submit.prevent="handleSubmit">
<form id="bulk-edit-account-form" class="space-y-5" @submit.prevent="() => handleSubmit()">
<!-- Info -->
<div class="rounded-lg bg-blue-50 p-4 dark:bg-blue-900/20">
<p class="text-sm text-blue-700 dark:text-blue-400">
......
......@@ -76,6 +76,12 @@ export function useTableSelection<T>({ rows, getId }: UseTableSelectionOptions<T
replaceSelectedSet(next)
}
const batchUpdate = (updater: (draft: Set<number>) => void) => {
const draft = new Set(selectedSet.value)
updater(draft)
replaceSelectedSet(draft)
}
const selectVisible = () => {
toggleVisible(true)
}
......@@ -93,6 +99,7 @@ export function useTableSelection<T>({ rows, getId }: UseTableSelectionOptions<T
clear,
removeMany,
toggleVisible,
selectVisible
selectVisible,
batchUpdate
}
}
......@@ -247,6 +247,8 @@ export default {
loading: 'Loading...',
justNow: 'just now',
save: 'Save',
saved: 'Saved successfully',
deleted: 'Deleted successfully',
cancel: 'Cancel',
delete: 'Delete',
edit: 'Edit',
......@@ -304,6 +306,7 @@ export default {
saving: 'Saving...',
selectedCount: '({count} selected)',
refresh: 'Refresh',
view: 'View',
settings: 'Settings',
chooseFile: 'Choose File',
notAvailable: 'N/A',
......@@ -5487,6 +5490,7 @@ export default {
refundSuccess: 'Refund successful',
refundInfo: 'Refund Info',
refundEnabled: 'Refund Enabled',
allowUserRefund: 'Allow User Refund',
alreadyRefunded: 'Already Refunded',
deductBalance: 'Deduct Balance',
deductBalanceHint: 'Subtract recharged amount from user balance',
......@@ -5556,6 +5560,9 @@ export default {
tabPlanConfig: 'Plan Configuration',
tabUserSubs: 'User Subscriptions',
selectGroup: 'Select a group',
groupRequired: 'Please select a subscription group',
priceRequired: 'Price must be greater than 0',
validityDaysRequired: 'Validity days must be greater than 0',
groupMissing: 'Missing',
groupInfo: 'Group Info',
platform: 'Platform',
......
......@@ -247,6 +247,8 @@ export default {
loading: '加载中...',
justNow: '刚刚',
save: '保存',
saved: '保存成功',
deleted: '删除成功',
cancel: '取消',
delete: '删除',
edit: '编辑',
......@@ -304,6 +306,7 @@ export default {
saving: '保存中...',
selectedCount: '(已选 {count} 个)',
refresh: '刷新',
view: '查看',
settings: '设置',
chooseFile: '选择文件',
notAvailable: '不可用',
......@@ -5744,6 +5747,9 @@ export default {
tabPlanConfig: '套餐配置',
tabUserSubs: '用户订阅',
selectGroup: '请选择分组',
groupRequired: '请选择订阅分组',
priceRequired: '价格必须大于 0',
validityDaysRequired: '有效期天数必须大于 0',
groupMissing: '缺失',
groupInfo: '分组信息',
platform: '平台',
......
......@@ -66,7 +66,7 @@ export const usePaymentStore = defineStore('payment', () => {
return response.data
}
/** Poll order status by ID */
/** Poll order status by ID (read-only, no upstream check) */
async function pollOrderStatus(orderId: number): Promise<PaymentOrder | null> {
try {
const response = await paymentAPI.getOrder(orderId)
......
......@@ -144,6 +144,7 @@
<AccountBulkActionsBar :selected-ids="selIds" @delete="handleBulkDelete" @reset-status="handleBulkResetStatus" @refresh-token="handleBulkRefreshToken" @edit="showBulkEdit = true" @clear="clearSelection" @select-page="selectPage" @toggle-schedulable="handleBulkToggleSchedulable" />
<div ref="accountTableRef" class="flex min-h-0 flex-1 flex-col overflow-hidden">
<DataTable
ref="dataTableRef"
:columns="cols"
:data="accounts"
:loading="loading"
......@@ -153,6 +154,8 @@
default-sort-key="name"
default-sort-order="asc"
:sort-storage-key="ACCOUNT_SORT_STORAGE_KEY"
:estimate-row-height="72"
:overscan="5"
>
<template #header-select>
<input
......@@ -164,7 +167,7 @@
/>
</template>
<template #cell-select="{ row }">
<input type="checkbox" :checked="selIds.includes(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
<input type="checkbox" :checked="isSelected(row.id)" @change="toggleSel(row.id)" class="rounded border-gray-300 text-primary-600 focus:ring-primary-500" />
</template>
<template #cell-name="{ row, value }">
<div class="flex flex-col">
......@@ -197,7 +200,9 @@
<AccountCapacityCell :account="row" />
</template>
<template #cell-status="{ row }">
<AccountStatusIndicator :account="row" @show-temp-unsched="handleShowTempUnsched" />
<div class="flex items-center gap-1.5">
<AccountStatusIndicator :account="row" @show-temp-unsched="handleShowTempUnsched" />
</div>
</template>
<template #cell-schedulable="{ row }">
<button @click="handleToggleSchedulable(row)" :disabled="togglingSchedulable === row.id" class="relative inline-flex h-5 w-9 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-dark-800" :class="[row.schedulable ? 'bg-primary-500 hover:bg-primary-600' : 'bg-gray-200 hover:bg-gray-300 dark:bg-dark-600 dark:hover:bg-dark-500']" :title="row.schedulable ? t('admin.accounts.schedulableEnabled') : t('admin.accounts.schedulableDisabled')">
......@@ -313,7 +318,7 @@ import { useAppStore } from '@/stores/app'
import { useAuthStore } from '@/stores/auth'
import { adminAPI } from '@/api/admin'
import { useTableLoader } from '@/composables/useTableLoader'
import { useSwipeSelect } from '@/composables/useSwipeSelect'
import { useSwipeSelect, type SwipeSelectVirtualContext } from '@/composables/useSwipeSelect'
import { useTableSelection } from '@/composables/useTableSelection'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
......@@ -351,6 +356,7 @@ const authStore = useAuthStore()
const proxies = ref<AccountProxy[]>([])
const groups = ref<AdminGroup[]>([])
const accountTableRef = ref<HTMLElement | null>(null)
const dataTableRef = ref<InstanceType<typeof DataTable> | null>(null)
const selPlatforms = computed<AccountPlatform[]>(() => {
const platforms = new Set(
accounts.value
......@@ -650,17 +656,25 @@ const {
clear: clearSelection,
removeMany: removeSelectedAccounts,
toggleVisible,
selectVisible: selectPage
selectVisible: selectPage,
batchUpdate
} = useTableSelection<Account>({
rows: accounts,
getId: (account) => account.id
})
const swipeVirtualContext: SwipeSelectVirtualContext = {
getVirtualizer: () => dataTableRef.value?.virtualizer ?? null,
getSortedData: () => dataTableRef.value?.sortedData ?? accounts.value,
getRowId: (row: any) => row.id,
}
useSwipeSelect(accountTableRef, {
isSelected,
select,
deselect
})
deselect,
batchUpdate
}, swipeVirtualContext)
const resetAutoRefreshCache = () => {
autoRefreshETag.value = null
......
......@@ -985,7 +985,8 @@ const {
deselect,
clear: clearSelectedProxies,
removeMany: removeSelectedProxies,
toggleVisible
toggleVisible,
batchUpdate
} = useTableSelection<Proxy>({
rows: proxies,
getId: (proxy) => proxy.id
......@@ -993,7 +994,8 @@ const {
useSwipeSelect(proxyTableRef, {
isSelected,
select,
deselect
deselect,
batchUpdate
})
const accountsProxy = ref<Proxy | null>(null)
const proxyAccounts = ref<ProxyAccountSummary[]>([])
......
......@@ -4116,12 +4116,13 @@ async function handleToggleField(provider: ProviderInstance, field: 'enabled' |
if (field === 'enabled') newValue = !provider.enabled
else if (field === 'refund_enabled') newValue = !provider.refund_enabled
else newValue = !provider.allow_user_refund
const payload: Record<string, boolean> = { [field]: newValue }
// Cascade: turning off refund_enabled also turns off allow_user_refund
if (field === 'refund_enabled' && !newValue) {
payload.allow_user_refund = false
}
try {
const payload: Record<string, boolean> = { [field]: newValue }
// Cascade: turning off refund_enabled also disables allow_user_refund
if (field === 'refund_enabled' && !newValue) {
payload.allow_user_refund = false
}
await adminAPI.payment.updateProvider(provider.id, payload)
if (field === 'enabled') provider.enabled = newValue
else if (field === 'refund_enabled') {
......
Markdown is supported
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment