diff --git a/controller/subscription.go b/controller/subscription.go
index c6095312b776..f30cb08c06f9 100644
--- a/controller/subscription.go
+++ b/controller/subscription.go
@@ -302,6 +302,28 @@ func AdminBindSubscription(c *gin.Context) {
// ---- Admin: user subscription management ----
+type AdminBatchActiveSubscriptionsRequest struct {
+ UserIds []int `json:"user_ids"`
+}
+
+func AdminBatchActiveSubscriptions(c *gin.Context) {
+ var req AdminBatchActiveSubscriptionsRequest
+ if err := c.ShouldBindJSON(&req); err != nil || len(req.UserIds) == 0 {
+ common.ApiErrorMsg(c, "参数错误")
+ return
+ }
+ if len(req.UserIds) > 100 {
+ common.ApiErrorMsg(c, "单次查询最多100个用户")
+ return
+ }
+ subsMap, err := model.GetActiveSubscriptionsByUserIds(req.UserIds)
+ if err != nil {
+ common.ApiError(c, err)
+ return
+ }
+ common.ApiSuccess(c, subsMap)
+}
+
func AdminListUserSubscriptions(c *gin.Context) {
userId, _ := strconv.Atoi(c.Param("id"))
if userId <= 0 {
diff --git a/controller/user.go b/controller/user.go
index 8229d0d2c2bc..2a9458a7edc6 100644
--- a/controller/user.go
+++ b/controller/user.go
@@ -244,8 +244,24 @@ func GetAllUsers(c *gin.Context) {
func SearchUsers(c *gin.Context) {
keyword := c.Query("keyword")
group := c.Query("group")
+ planIdStr := c.Query("plan_id")
pageInfo := common.GetPageQuery(c)
- users, total, err := model.SearchUsers(keyword, group, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
+
+ var users []*model.User
+ var total int64
+ var err error
+
+ if planIdStr != "" {
+ planId, parseErr := strconv.Atoi(planIdStr)
+ if parseErr != nil || planId <= 0 {
+ common.ApiErrorMsg(c, "无效的订阅套餐ID")
+ return
+ }
+ users, total, err = model.SearchUsers(keyword, group, pageInfo.GetStartIdx(), pageInfo.GetPageSize(), planId)
+ } else {
+ users, total, err = model.SearchUsers(keyword, group, pageInfo.GetStartIdx(), pageInfo.GetPageSize())
+ }
+
if err != nil {
common.ApiError(c, err)
return
diff --git a/model/subscription.go b/model/subscription.go
index 2d23a8b5bf2c..43cf18064d05 100644
--- a/model/subscription.go
+++ b/model/subscription.go
@@ -666,6 +666,27 @@ func GetAllActiveUserSubscriptions(userId int) ([]SubscriptionSummary, error) {
return buildSubscriptionSummaries(subs), nil
}
+// GetActiveSubscriptionsByUserIds returns all active subscriptions per user for a batch of user IDs.
+// Returns a map of userId -> []UserSubscription.
+func GetActiveSubscriptionsByUserIds(userIds []int) (map[int][]UserSubscription, error) {
+ if len(userIds) == 0 {
+ return map[int][]UserSubscription{}, nil
+ }
+ now := common.GetTimestamp()
+ var subs []UserSubscription
+ err := DB.Where("user_id IN ? AND status = ? AND end_time > ?", userIds, "active", now).
+ Order("end_time desc, id desc").
+ Find(&subs).Error
+ if err != nil {
+ return nil, err
+ }
+ result := make(map[int][]UserSubscription, len(userIds))
+ for i := range subs {
+ result[subs[i].UserId] = append(result[subs[i].UserId], subs[i])
+ }
+ return result, nil
+}
+
// HasActiveUserSubscription returns whether the user has any active subscription.
// This is a lightweight existence check to avoid heavy pre-consume transactions.
func HasActiveUserSubscription(userId int) (bool, error) {
diff --git a/model/user.go b/model/user.go
index 1210b5435d04..e5b5b26aadce 100644
--- a/model/user.go
+++ b/model/user.go
@@ -222,10 +222,11 @@ func GetAllUsers(pageInfo *common.PageInfo) (users []*User, total int64, err err
return users, total, nil
}
-func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User, int64, error) {
+func SearchUsers(keyword string, group string, startIdx int, num int, planId ...int) ([]*User, int64, error) {
var users []*User
var total int64
var err error
+ now := common.GetTimestamp()
// 开始事务
tx := DB.Begin()
@@ -241,6 +242,12 @@ func SearchUsers(keyword string, group string, startIdx int, num int) ([]*User,
// 构建基础查询
query := tx.Unscoped().Model(&User{})
+ // 如果指定了 planId,先筛选有该订阅计划的用户
+ if len(planId) > 0 && planId[0] > 0 {
+ query = query.Where("id IN (?)",
+ tx.Model(&UserSubscription{}).Select("user_id").Where("plan_id = ? AND status = ? AND end_time > ?", planId[0], "active", now))
+ }
+
// 构建搜索条件
likeCondition := "username LIKE ? OR email LIKE ? OR display_name LIKE ?"
diff --git a/router/api-router.go b/router/api-router.go
index bff158a819ce..cad4f3f83f0a 100644
--- a/router/api-router.go
+++ b/router/api-router.go
@@ -154,6 +154,7 @@ func SetApiRouter(router *gin.Engine) {
subscriptionAdminRoute.POST("/bind", controller.AdminBindSubscription)
// User subscription management (admin)
+ subscriptionAdminRoute.POST("/users/batch_active_subscriptions", controller.AdminBatchActiveSubscriptions)
subscriptionAdminRoute.GET("/users/:id/subscriptions", controller.AdminListUserSubscriptions)
subscriptionAdminRoute.POST("/users/:id/subscriptions", controller.AdminCreateUserSubscription)
subscriptionAdminRoute.POST("/user_subscriptions/:id/invalidate", controller.AdminInvalidateUserSubscription)
diff --git a/web/bun.lock b/web/bun.lock
index e3b293cb12a6..9a8419226a6e 100644
--- a/web/bun.lock
+++ b/web/bun.lock
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
+ "configVersion": 0,
"workspaces": {
"": {
"name": "react-template",
@@ -10,7 +11,7 @@
"@visactor/react-vchart": "~1.8.8",
"@visactor/vchart": "~1.8.8",
"@visactor/vchart-semi-theme": "~1.8.8",
- "axios": "1.12.0",
+ "axios": "1.13.5",
"clsx": "^2.1.1",
"dayjs": "^1.11.11",
"history": "^5.3.0",
@@ -776,7 +777,7 @@
"autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="],
- "axios": ["axios@1.12.0", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg=="],
+ "axios": ["axios@1.13.5", "", { "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q=="],
"babel-plugin-macros": ["babel-plugin-macros@3.1.0", "", { "dependencies": { "@babel/runtime": "^7.12.5", "cosmiconfig": "^7.0.0", "resolve": "^1.19.0" } }, "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg=="],
@@ -1104,13 +1105,13 @@
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
- "follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="],
+ "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"for-in": ["for-in@1.0.2", "", {}, "sha512-7EwmXrOjyL+ChxMhmG5lnW9MPt1aIeZEwKhQzoBUdTV0N3zuwWDZYVJatDvZ2OyzPUvdIAZDsCetk3coyMfcnQ=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
- "form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="],
+ "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="],
diff --git a/web/src/components/table/users/UsersColumnDefs.jsx b/web/src/components/table/users/UsersColumnDefs.jsx
index dc3e6f3413ec..47411a118c0b 100644
--- a/web/src/components/table/users/UsersColumnDefs.jsx
+++ b/web/src/components/table/users/UsersColumnDefs.jsx
@@ -170,6 +170,74 @@ const renderQuotaUsage = (text, record, t) => {
);
};
+const PLAN_TAG_COLORS = [
+ 'green', 'blue', 'cyan', 'violet', 'teal', 'lime', 'amber', 'indigo',
+];
+
+const getPlanColor = (planId) => {
+ if (!planId) return 'green';
+ return PLAN_TAG_COLORS[planId % PLAN_TAG_COLORS.length];
+};
+
+/**
+ * Render subscription information (supports multiple active subscriptions)
+ */
+const renderSubscriptionInfo = (record, userSubscriptions, planOptions, t) => {
+ if (userSubscriptions === null) {
+ return -;
+ }
+ const subs = userSubscriptions[record.id];
+ if (!subs || !Array.isArray(subs) || subs.length === 0) {
+ return (
+
+ {t('无订阅')}
+
+ );
+ }
+
+ const now = Date.now() / 1000;
+
+ return (
+
+ {subs.map((sub) => {
+ const isExpired = sub.end_time > 0 && sub.end_time < now;
+ const isActive = sub.status === 'active' && !isExpired;
+ const planName = planOptions?.find(
+ (p) => p.value === sub.plan_id,
+ )?.label;
+ const endDate = sub.end_time
+ ? new Date(sub.end_time * 1000).toLocaleString()
+ : '-';
+
+ const tooltipContent = (
+
+
+ {t('到期')}: {endDate}
+
+ {sub.amount_total > 0 && (
+
+ {t('额度')}: {sub.amount_used}/{sub.amount_total}
+
+ )}
+
+ );
+
+ return (
+
+
+ {planName || (sub.plan_id ? `#${sub.plan_id}` : t('订阅中'))}
+
+
+ );
+ })}
+
+ );
+};
+
/**
* Render invite information
*/
@@ -309,6 +377,8 @@ export const getUsersColumns = ({
showResetPasskeyModal,
showResetTwoFAModal,
showUserSubscriptionsModal,
+ userSubscriptions,
+ planOptions,
}) => {
return [
{
@@ -345,6 +415,12 @@ export const getUsersColumns = ({
return
{renderRole(text, t)}
;
},
},
+ {
+ title: t('订阅信息'),
+ dataIndex: 'subscription',
+ render: (text, record) =>
+ renderSubscriptionInfo(record, userSubscriptions, planOptions, t),
+ },
{
title: t('邀请信息'),
dataIndex: 'invite',
diff --git a/web/src/components/table/users/UsersFilters.jsx b/web/src/components/table/users/UsersFilters.jsx
index 7c4c7486b2eb..66864d34e761 100644
--- a/web/src/components/table/users/UsersFilters.jsx
+++ b/web/src/components/table/users/UsersFilters.jsx
@@ -29,6 +29,7 @@ const UsersFilters = ({
activePage,
pageSize,
groupOptions,
+ planOptions,
loading,
searching,
t,
@@ -88,6 +89,24 @@ const UsersFilters = ({
size='small'
/>
+ {planOptions && planOptions.length > 0 && (
+
+
{
+ setTimeout(() => {
+ searchUsers(1, pageSize);
+ }, 100);
+ }}
+ className='w-full'
+ showClear
+ pure
+ size='small'
+ />
+
+ )}