diff --git a/cmd/gateway-api/main.go b/cmd/gateway-api/main.go index e31bb9c..5cda906 100644 --- a/cmd/gateway-api/main.go +++ b/cmd/gateway-api/main.go @@ -362,6 +362,7 @@ func main() { portalService.SetApplicationRuntime(portal.NewRuntimeCredentials(db, apiKeyRepository, applicationKeyCipher), workbenchRuntime) portalService.SetGateway(governedGateway) portalService.SetMarketplace(marketplaceService) + portalService.SetChannelService(channelService) portalHandler := portal.NewHTTPHandler(portalService, identityService) portalAdminHandler := portal.NewAdminHTTPHandler(portalService, identityService) // License 授权:文件校验 + 账号数管控 + 管理端查看/上传。 @@ -489,6 +490,7 @@ func main() { controlMux.Handle("/api/v1/portal/agent-policy", agentPolicyHandler) controlMux.Handle("/api/v1/portal/personal-channels", portalHandler) controlMux.Handle("/api/v1/portal/personal-channels/", portalHandler) + controlMux.Handle("/api/v1/portal/channels", portalHandler) controlMux.Handle("/api/v1/portal/digital-employees", portalHandler) controlMux.Handle("/api/v1/portal/digital-employees/", portalHandler) controlMux.Handle("/api/v1/portal/resource-requests", portalHandler) diff --git a/internal/channel/http.go b/internal/channel/http.go index 609edd6..9628b9a 100644 --- a/internal/channel/http.go +++ b/internal/channel/http.go @@ -24,6 +24,9 @@ func NewHTTPHandler(service *Service, identityService *identity.Service) *HTTPHa h.mux.HandleFunc("PUT /api/v1/admin/channels/{id}", h.save) h.mux.HandleFunc("DELETE /api/v1/admin/channels/{id}", h.delete) h.mux.HandleFunc("POST /api/v1/admin/channels/{id}/test", h.test) + h.mux.HandleFunc("GET /api/v1/admin/channels/{id}/grants", h.listGrants) + h.mux.HandleFunc("POST /api/v1/admin/channels/{id}/grants", h.grant) + h.mux.HandleFunc("DELETE /api/v1/admin/channels/{id}/grants/{user_id}", h.revokeGrant) return h } @@ -55,13 +58,14 @@ func (h *HTTPHandler) list(w http.ResponseWriter, r *http.Request) { } type channelInput struct { - Code string `json:"code"` - Name string `json:"name"` - Kind string `json:"kind"` - Config json.RawMessage `json:"config"` - ModelBinding json.RawMessage `json:"model_binding"` - APIKey string `json:"api_key"` - Enabled *bool `json:"enabled"` + Code string `json:"code"` + Name string `json:"name"` + Kind string `json:"kind"` + Config json.RawMessage `json:"config"` + ModelBinding json.RawMessage `json:"model_binding"` + DepartmentIDs []string `json:"department_ids"` + APIKey string `json:"api_key"` + Enabled *bool `json:"enabled"` } func (h *HTTPHandler) save(w http.ResponseWriter, r *http.Request) { @@ -87,7 +91,7 @@ func (h *HTTPHandler) save(w http.ResponseWriter, r *http.Request) { if input.Enabled != nil { enabled = *input.Enabled } - item, err := h.service.Save(r.Context(), r.PathValue("id"), input.Code, input.Name, input.Kind, cfg, input.ModelBinding, input.APIKey, enabled, actor.ID) + item, err := h.service.Save(r.Context(), r.PathValue("id"), input.Code, input.Name, input.Kind, cfg, input.ModelBinding, input.DepartmentIDs, input.APIKey, enabled, actor.ID) if err != nil { apiresponse.Error(w, http.StatusBadRequest, err.Error()) return @@ -212,3 +216,50 @@ func (h *InboundHTTPHandler) inbound(w http.ResponseWriter, r *http.Request) { } apiresponse.OK(w, map[string]bool{"accepted": true}) } + +// listGrants 渠道用户授权列表。 +func (h *HTTPHandler) listGrants(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionNotificationRead); !ok { + return + } + items, err := h.service.ListGrants(r.Context(), r.PathValue("id")) + if err != nil { + apiresponse.Error(w, http.StatusServiceUnavailable, "渠道授权查询失败") + return + } + apiresponse.OK(w, items) +} + +// grant 直接授予用户渠道使用权限(管理员显式授权,无需走申请流)。 +func (h *HTTPHandler) grant(w http.ResponseWriter, r *http.Request) { + actor, ok := h.require(w, r, identity.PermissionNotificationManage) + if !ok { + return + } + var input struct { + PortalUserID string `json:"portal_user_id"` + } + decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + decoder.DisallowUnknownFields() + if decoder.Decode(&input) != nil || strings.TrimSpace(input.PortalUserID) == "" { + apiresponse.Error(w, http.StatusBadRequest, "请求格式无效") + return + } + if err := h.service.Grant(r.Context(), r.PathValue("id"), input.PortalUserID, actor.ID, "manual"); err != nil { + apiresponse.Error(w, http.StatusBadRequest, err.Error()) + return + } + apiresponse.OK(w, map[string]bool{"granted": true}) +} + +// revokeGrant 撤销用户的渠道使用权限。 +func (h *HTTPHandler) revokeGrant(w http.ResponseWriter, r *http.Request) { + if _, ok := h.require(w, r, identity.PermissionNotificationManage); !ok { + return + } + if err := h.service.RevokeGrant(r.Context(), r.PathValue("id"), r.PathValue("user_id")); err != nil { + apiresponse.Error(w, http.StatusBadRequest, err.Error()) + return + } + apiresponse.OK(w, map[string]bool{"revoked": true}) +} diff --git a/internal/channel/service.go b/internal/channel/service.go index 3a3da2e..8cb72d5 100644 --- a/internal/channel/service.go +++ b/internal/channel/service.go @@ -39,6 +39,7 @@ type Channel struct { EncryptedConfig []byte `json:"-"` ConfigKEKVersion int `json:"-"` ModelBinding json.RawMessage `json:"model_binding"` + DepartmentIDs []string `json:"department_ids"` HasAPIKey bool `json:"has_api_key"` Enabled bool `json:"enabled"` CreatedBy *string `json:"created_by,omitempty"` @@ -84,12 +85,12 @@ func NewService(pool *pgxpool.Pool, gatewayURL string, cipher interface { } } -const channelSelect = `SELECT id::text,code,name,kind,encrypted_config,config_kek_version,model_binding,octet_length(encrypted_api_key)>0,enabled,created_by::text,created_at,updated_at FROM gateway.channels` +const channelSelect = `SELECT id::text,code,name,kind,encrypted_config,config_kek_version,model_binding,department_ids::text[],octet_length(encrypted_api_key)>0,enabled,created_by::text,created_at,updated_at FROM gateway.channels` func (s *Service) scan(row pgx.Row) (Channel, error) { var c Channel var createdBy *string - err := row.Scan(&c.ID, &c.Code, &c.Name, &c.Kind, &c.EncryptedConfig, &c.ConfigKEKVersion, &c.ModelBinding, &c.HasAPIKey, &c.Enabled, &createdBy, &c.CreatedAt, &c.UpdatedAt) + err := row.Scan(&c.ID, &c.Code, &c.Name, &c.Kind, &c.EncryptedConfig, &c.ConfigKEKVersion, &c.ModelBinding, &c.DepartmentIDs, &c.HasAPIKey, &c.Enabled, &createdBy, &c.CreatedAt, &c.UpdatedAt) if errors.Is(err, pgx.ErrNoRows) { return Channel{}, ErrNotFound } @@ -146,7 +147,7 @@ func (s *Service) DecryptConfig(c Channel) (Config, error) { } // Save 创建/更新渠道。 -func (s *Service) Save(ctx context.Context, id, code, name, kind string, cfg Config, modelBinding json.RawMessage, apiKey string, enabled bool, actorID string) (Channel, error) { +func (s *Service) Save(ctx context.Context, id, code, name, kind string, cfg Config, modelBinding json.RawMessage, departmentIDs []string, apiKey string, enabled bool, actorID string) (Channel, error) { if s == nil || s.pool == nil || s.cipher == nil { return Channel{}, ErrUnavailable } @@ -182,13 +183,13 @@ func (s *Service) Save(ctx context.Context, id, code, name, kind string, cfg Con if modelBinding == nil { modelBinding = json.RawMessage(`{}`) } - _, err = s.pool.Exec(ctx, `INSERT INTO gateway.channels(id,code,name,kind,encrypted_config,config_kek_version,encrypted_api_key,api_key_kek_version,model_binding,enabled,created_by) - VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + _, err = s.pool.Exec(ctx, `INSERT INTO gateway.channels(id,code,name,kind,encrypted_config,config_kek_version,encrypted_api_key,api_key_kek_version,model_binding,department_ids,enabled,created_by) + VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT(code) DO UPDATE SET name=$3,kind=$4,encrypted_config=$5,config_kek_version=$6, encrypted_api_key=CASE WHEN $7<>'' THEN $7 ELSE gateway.channels.encrypted_api_key END, api_key_kek_version=CASE WHEN $7<>'' THEN $8 ELSE gateway.channels.api_key_kek_version END, - model_binding=$9,enabled=$10,updated_at=clock_timestamp()`, - id, code, name, kind, encryptedConfig, configVersion, encryptedKey, keyVersion, modelBinding, enabled, actorID) + model_binding=$9,department_ids=$10,enabled=$11,updated_at=clock_timestamp()`, + id, code, name, kind, encryptedConfig, configVersion, encryptedKey, keyVersion, modelBinding, departmentIDs, enabled, actorID) if err != nil { return Channel{}, err } @@ -440,3 +441,119 @@ func DingSign(timestamp int64, secret string) string { sum := sha256.Sum256([]byte(fmt.Sprintf("%d\n%s", timestamp, secret))) return url.QueryEscape(hex.EncodeToString(sum[:])) } + +// ChannelGrant 是一条用户级渠道授权。 +type ChannelGrant struct { + ChannelID string `json:"channel_id"` + ChannelCode string `json:"channel_code"` + ChannelName string `json:"channel_name"` + UserID string `json:"portal_user_id"` + UserLogin string `json:"user_login"` + Source string `json:"source"` + GrantedAt time.Time `json:"created_at"` +} + +// ListGrants 返回渠道的用户授权列表。 +func (s *Service) ListGrants(ctx context.Context, channelID string) ([]ChannelGrant, error) { + if s == nil || s.pool == nil { + return nil, ErrUnavailable + } + rows, err := s.pool.Query(ctx, `SELECT g.channel_id::text,c.code,c.name,g.portal_user_id::text,u.account,g.source,g.created_at + FROM gateway.channel_grants g JOIN gateway.channels c ON c.id=g.channel_id JOIN gateway.portal_users u ON u.id=g.portal_user_id + WHERE g.channel_id=$1 ORDER BY g.created_at DESC`, channelID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []ChannelGrant{} + for rows.Next() { + var item ChannelGrant + if err := rows.Scan(&item.ChannelID, &item.ChannelCode, &item.ChannelName, &item.UserID, &item.UserLogin, &item.Source, &item.GrantedAt); err != nil { + return nil, err + } + items = append(items, item) + } + return items, rows.Err() +} + +// Grant 授予用户渠道使用权限(幂等)。source: manual(管理员直接授予)或 +// approval(资源申请审批通过自动写入)。 +func (s *Service) Grant(ctx context.Context, channelID, portalUserID, actorID, source string) error { + if s == nil || s.pool == nil { + return ErrUnavailable + } + if source != "manual" && source != "approval" { + source = "manual" + } + var exists bool + if err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM gateway.portal_users WHERE id=$1)`, portalUserID).Scan(&exists); err != nil { + return err + } + if !exists { + return ErrNotFound + } + _, err := s.pool.Exec(ctx, `INSERT INTO gateway.channel_grants(channel_id,portal_user_id,granted_by,source) VALUES($1,$2,nullif($3,'')::uuid,$4) ON CONFLICT DO NOTHING`, channelID, portalUserID, actorID, source) + return err +} + +// RevokeGrant 撤销用户的渠道使用权限。 +func (s *Service) RevokeGrant(ctx context.Context, channelID, portalUserID string) error { + if s == nil || s.pool == nil { + return ErrUnavailable + } + tag, err := s.pool.Exec(ctx, `DELETE FROM gateway.channel_grants WHERE channel_id=$1 AND portal_user_id=$2`, channelID, portalUserID) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +// GrantsForUser 返回用户有明确授权的渠道(供门户"我的渠道")。 +func (s *Service) GrantsForUser(ctx context.Context, portalUserID string) ([]Channel, error) { + if s == nil || s.pool == nil { + return nil, ErrUnavailable + } + rows, err := s.pool.Query(ctx, `SELECT c.* FROM (`+channelSelect+`) c JOIN gateway.channel_grants g ON g.channel_id=c.id::uuid AND g.portal_user_id=$1::uuid WHERE c.enabled ORDER BY c.updated_at DESC`, portalUserID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Channel{} + for rows.Next() { + c, err := s.scan(rows) + if err != nil { + return nil, err + } + items = append(items, c) + } + return items, rows.Err() +} + +// VisibleChannelsForUser 返回用户可见渠道:部门可见(全局或本部门)或明确授权。 +func (s *Service) VisibleChannelsForUser(ctx context.Context, portalUserID string, departmentID *string) ([]Channel, error) { + if s == nil || s.pool == nil { + return nil, ErrUnavailable + } + rows, err := s.pool.Query(ctx, `SELECT c.* FROM (`+channelSelect+`) c + WHERE c.enabled AND ( + cardinality(c.department_ids)=0 + OR ($2::text = ANY(c.department_ids)) + OR EXISTS(SELECT 1 FROM gateway.channel_grants g WHERE g.channel_id=c.id::uuid AND g.portal_user_id=$1::uuid) + ) ORDER BY c.updated_at DESC`, portalUserID, departmentID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []Channel{} + for rows.Next() { + c, err := s.scan(rows) + if err != nil { + return nil, err + } + items = append(items, c) + } + return items, rows.Err() +} diff --git a/internal/portal/http.go b/internal/portal/http.go index b82b123..b4aaba6 100644 --- a/internal/portal/http.go +++ b/internal/portal/http.go @@ -63,6 +63,8 @@ func NewHTTPHandler(service *Service, identityService *identity.Service) *HTTPHa h.mux.HandleFunc("POST /api/v1/portal/personal-channels", h.createPersonalChannel) h.mux.HandleFunc("POST /api/v1/portal/personal-channels/{id}/token", h.regeneratePersonalToken) h.mux.HandleFunc("DELETE /api/v1/portal/personal-channels/{id}", h.deletePersonalChannel) + // 我的渠道:部门可见或已授权。 + h.mux.HandleFunc("GET /api/v1/portal/channels", h.myChannels) // 数字员工:会话入口 + 调用记录。 h.mux.HandleFunc("GET /api/v1/portal/digital-employees", h.digitalEmployees) h.mux.HandleFunc("POST /api/v1/portal/digital-employees/{code}/chat", h.runDigitalEmployee) @@ -925,3 +927,31 @@ func (h *HTTPHandler) myEmployeeRuns(w http.ResponseWriter, r *http.Request) { } apiresponse.OK(w, items) } + +// --- 我的渠道:部门可见或已授权 --- + +func (h *HTTPHandler) myChannels(w http.ResponseWriter, r *http.Request) { + a, ok := h.account(w, r) + if !ok { + return + } + if h.service.channels == nil { + apiresponse.OK(w, map[string]any{"channels": []any{}, "granted_codes": []string{}}) + return + } + visible, err := h.service.channels.VisibleChannelsForUser(r.Context(), a.ID, a.DepartmentID) + if err != nil { + portalError(w, err) + return + } + granted, err := h.service.channels.GrantsForUser(r.Context(), a.ID) + if err != nil { + portalError(w, err) + return + } + grantedCodes := make([]string, 0, len(granted)) + for _, item := range granted { + grantedCodes = append(grantedCodes, item.Code) + } + apiresponse.OK(w, map[string]any{"channels": visible, "granted_codes": grantedCodes}) +} diff --git a/internal/portal/requests.go b/internal/portal/requests.go index 74a751c..4aede22 100644 --- a/internal/portal/requests.go +++ b/internal/portal/requests.go @@ -180,10 +180,26 @@ func (s *Service) DecideResourceRequest(ctx context.Context, id, status, note, a if err = tx.QueryRow(ctx, `SELECT portal_user_id::text,resource_type,resource_code FROM gateway.resource_access_requests WHERE id=$1`, id).Scan(&userID, &resourceType, &resourceCode); err != nil { return ResourceRequest{}, err } - if status == "approved" && resourceType != "channel" && s.market != nil { - // 自动安装到申请用户工作区(use 等级)。 - if _, err = s.market.Install(ctx, resourceType, resourceCode, userID, "use"); err != nil { - return ResourceRequest{}, err + if status == "approved" { + switch resourceType { + case "mcp_server", "skill", "digital_employee": + if s.market != nil { + // 自动安装到申请用户工作区(use 等级)。 + if _, err = s.market.Install(ctx, resourceType, resourceCode, userID, "use"); err != nil { + return ResourceRequest{}, err + } + } + case "channel": + // 渠道审批通过 = 写入 channel_grants 用户级授权。 + if s.channels != nil { + var channelID string + if err = tx.QueryRow(ctx, `SELECT id::text FROM gateway.channels WHERE code=$1`, resourceCode).Scan(&channelID); err != nil { + return ResourceRequest{}, err + } + if err = s.channels.Grant(ctx, channelID, userID, actorID, "approval"); err != nil { + return ResourceRequest{}, err + } + } } } payload, _ := json.Marshal(map[string]any{"request_id": id, "portal_user_id": userID, "resource_type": resourceType, "resource_code": resourceCode, "status": status, "actor_id": actorID}) diff --git a/internal/portal/service.go b/internal/portal/service.go index fcac028..06540ba 100644 --- a/internal/portal/service.go +++ b/internal/portal/service.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "aigateway.local/core/internal/channel" "aigateway.local/core/internal/identity" platformid "aigateway.local/core/internal/platform/id" "aigateway.local/core/internal/workbench" @@ -28,6 +29,7 @@ type Service struct { runtime http.Handler gateway http.Handler market *workbench.MarketplaceService + channels *channel.Service } func NewService(pool *pgxpool.Pool, assets *workbench.Service, tools *workbench.ToolService, identityService *identity.Service) *Service { @@ -46,6 +48,10 @@ func (s *Service) SetApplicationRuntime(credentials *RuntimeCredentials, runtime // /v1/chat/completions with the user's own runtime credential. func (s *Service) SetGateway(gateway http.Handler) { s.gateway = gateway } +// SetChannelService wires the channel service for approval auto-grant and the +// portal "my channels" visibility endpoint. +func (s *Service) SetChannelService(service *channel.Service) { s.channels = service } + // SetMarketplace wires the resource-marketplace service into the portal so the // marketplace pages can browse, install and manage resources. func (s *Service) SetMarketplace(market *workbench.MarketplaceService) { diff --git a/migrations/000047_channel_grants.sql b/migrations/000047_channel_grants.sql new file mode 100644 index 0000000..e3af1ff --- /dev/null +++ b/migrations/000047_channel_grants.sql @@ -0,0 +1,22 @@ +-- 000047_channel_grants.sql — 渠道权限管控:部门范围 + 用户级授权。 +-- channels.department_ids:空数组 = 全局渠道(所有部门可见);非空 = 仅列出的 +-- 部门可见。channel_grants:渠道对具体门户用户的显式授权(管理员直接授予或 +-- 审批资源申请通过后自动写入)。 + +ALTER TABLE gateway.channels + ADD COLUMN IF NOT EXISTS department_ids uuid[] NOT NULL DEFAULT '{}'::uuid[]; + +COMMENT ON COLUMN gateway.channels.department_ids IS + 'Departments allowed to see and use this channel. Empty array = global channel.'; + +CREATE TABLE IF NOT EXISTS gateway.channel_grants ( + channel_id uuid NOT NULL REFERENCES gateway.channels(id) ON DELETE CASCADE, + portal_user_id uuid NOT NULL REFERENCES gateway.portal_users(id) ON DELETE CASCADE, + granted_by uuid REFERENCES gateway.admin_accounts(id) ON DELETE SET NULL, + source varchar(16) NOT NULL DEFAULT 'manual' CHECK (source IN ('manual', 'approval')), + created_at timestamptz NOT NULL DEFAULT clock_timestamp(), + PRIMARY KEY (channel_id, portal_user_id) +); + +CREATE INDEX IF NOT EXISTS channel_grants_user_idx + ON gateway.channel_grants (portal_user_id, created_at DESC); diff --git a/web/apps/admin/src/views/gateway/channels/index.vue b/web/apps/admin/src/views/gateway/channels/index.vue index 579a923..acebcd2 100644 --- a/web/apps/admin/src/views/gateway/channels/index.vue +++ b/web/apps/admin/src/views/gateway/channels/index.vue @@ -24,6 +24,12 @@ {{ row.has_api_key ? '已配置' : '未配置' }} + + + 全局 + {{ row.department_ids.length }} 个部门 + + {{ row.enabled ? '启用' : '停用' }} @@ -32,6 +38,7 @@ 测试 + 授权 编辑 删除 @@ -58,6 +65,12 @@ + + + + + 仅列出的部门可见此渠道;用户级授权在「授权」中单独管理 + @@ -82,6 +95,25 @@ 保存 + + + + + + + 授予 + + + + + {{ row.source === 'approval' ? '审批' : '手动' }} + + + + 撤销 + + + @@ -95,9 +127,13 @@ name: string kind: string model_binding: Record + department_ids: string[] has_api_key: boolean enabled: boolean } + interface ChannelGrant { channel_id: string; channel_code: string; channel_name: string; portal_user_id: string; user_login: string; source: string; created_at: string } + interface Department { id: string; name: string } + interface PortalUser { id: string; login: string; display_name: string } const kindMap: Record = { webhook: 'Webhook', wecom: '企业微信', dingtalk: '钉钉', feishu: '飞书' } const kindLabel = (kind: string) => kindMap[kind] || kind @@ -109,15 +145,30 @@ const testingId = ref('') const dialogVisible = ref(false) const editingId = ref('') + const departments = ref([]) + const portalUsers = ref([]) + const grantsVisible = ref(false) + const grantsChannel = ref() + const grants = ref([]) + const grantUserID = ref('') + const granting = ref(false) const form = reactive({ code: '', name: '', kind: 'webhook', binding_provider: '', binding_model: '', api_key: '', - inbound_token: '', corp_id: '', secret: '', agent_id: '', ding_robot_token: '', feishu_app_id: '', feishu_app_secret: '' + inbound_token: '', corp_id: '', secret: '', agent_id: '', ding_robot_token: '', feishu_app_id: '', feishu_app_secret: '', + department_ids: [] as string[] }) async function load() { loading.value = true try { - channels.value = await request.get({ url: '/api/v1/admin/channels' }) + const [channelList, departmentList, userList] = await Promise.all([ + request.get({ url: '/api/v1/admin/channels' }), + request.get({ url: '/api/v1/admin/departments' }), + request.get({ url: '/api/v1/admin/identities/portal-users' }) + ]) + channels.value = channelList + departments.value = departmentList + portalUsers.value = userList } finally { loading.value = false } @@ -125,7 +176,7 @@ function openCreate() { editingId.value = '' - Object.assign(form, { code: '', name: '', kind: 'webhook', binding_provider: '', binding_model: '', api_key: '', inbound_token: '', corp_id: '', secret: '', agent_id: '', ding_robot_token: '', feishu_app_id: '', feishu_app_secret: '' }) + Object.assign(form, { code: '', name: '', kind: 'webhook', binding_provider: '', binding_model: '', api_key: '', inbound_token: '', corp_id: '', secret: '', agent_id: '', ding_robot_token: '', feishu_app_id: '', feishu_app_secret: '', department_ids: [] }) dialogVisible.value = true } @@ -133,7 +184,8 @@ editingId.value = row.id Object.assign(form, { code: row.code, name: row.name, kind: row.kind, api_key: '', - binding_provider: row.model_binding?.provider || '', binding_model: row.model_binding?.model || '' + binding_provider: row.model_binding?.provider || '', binding_model: row.model_binding?.model || '', + department_ids: [...(row.department_ids || [])] }) dialogVisible.value = true } @@ -160,6 +212,7 @@ const payload = { code: form.code, name: form.name, kind: form.kind, config, model_binding: { provider: form.binding_provider || undefined, model: form.binding_model || undefined }, + department_ids: form.department_ids, api_key: form.api_key } if (editingId.value) { @@ -186,6 +239,38 @@ } } + async function openGrants(row: Channel) { + grantsChannel.value = row + grantUserID.value = '' + grants.value = await request.get({ url: `/api/v1/admin/channels/${row.id}/grants` }) + grantsVisible.value = true + } + + async function grant() { + if (!grantUserID.value || !grantsChannel.value) return + granting.value = true + try { + await request.post({ url: `/api/v1/admin/channels/${grantsChannel.value.id}/grants`, params: { portal_user_id: grantUserID.value } }) + ElMessage.success('已授予') + grantUserID.value = '' + grants.value = await request.get({ url: `/api/v1/admin/channels/${grantsChannel.value.id}/grants` }) + } finally { + granting.value = false + } + } + + async function revoke(row: ChannelGrant) { + try { + await ElMessageBox.confirm(`撤销「${row.user_login}」的渠道使用权限?`, '撤销授权', { type: 'warning' }) + } catch { + return + } + if (!grantsChannel.value) return + await request.del({ url: `/api/v1/admin/channels/${grantsChannel.value.id}/grants/${row.portal_user_id}` }) + grants.value = grants.value.filter((item) => item.portal_user_id !== row.portal_user_id) + ElMessage.success('已撤销') + } + async function remove(row: Channel) { try { await ElMessageBox.confirm(`确认删除渠道「${row.name}」?`, '删除渠道', { type: 'warning' }) diff --git a/web/apps/portal/src/api/portal.ts b/web/apps/portal/src/api/portal.ts index 95a4f03..c4034a7 100644 --- a/web/apps/portal/src/api/portal.ts +++ b/web/apps/portal/src/api/portal.ts @@ -114,3 +114,7 @@ export const fetchMyEmployeeRuns=(limit=20)=>request.get({url:'/a export interface AgentPolicy { auto_approve_tools:boolean;rate_limit_multiplier:number } export const fetchAgentPolicy=()=>request.get({url:'/api/v1/portal/agent-policy'}) export const setAgentPolicy=(params:AgentPolicy)=>request.put<{saved:boolean}>({url:'/api/v1/portal/agent-policy',params}) + +// --- 我的渠道(部门可见或已授权) --- +export interface VisibleChannel { id:string;code:string;name:string;kind:string;model_binding?:Record;department_ids:string[];enabled:boolean } +export const fetchMyChannels=()=>request.get<{channels:VisibleChannel[];granted_codes:string[]}>({url:'/api/v1/portal/channels'}) diff --git a/web/apps/portal/src/views/portal/personal-channels/index.vue b/web/apps/portal/src/views/portal/personal-channels/index.vue index 6207b48..18a4fb8 100644 --- a/web/apps/portal/src/views/portal/personal-channels/index.vue +++ b/web/apps/portal/src/views/portal/personal-channels/index.vue @@ -28,6 +28,31 @@ + + + + 可使用渠道 + 部门可见或已获授权;需要更多渠道请到「我的申请」发起申请 + + + + + + + {{ kindLabel(row.kind) }} + + + {{ row.model_binding?.model || '—' }} + + + + {{ grantedCodes.includes(row.code) ? '已授权' : '部门可见' }} + + + + + + @@ -69,9 +94,9 @@