diff --git a/Dockerfile.app b/Dockerfile.app index 728c519..7b852fe 100644 --- a/Dockerfile.app +++ b/Dockerfile.app @@ -51,6 +51,15 @@ RUN printf '%s\n' \ ' "" $remote_addr;' \ '}' \ '' \ +'# gzip 压缩配置(对 base64 图片等非流式响应有效)' \ +'gzip on;' \ +'gzip_min_length 256;' \ +'gzip_comp_level 5;' \ +'gzip_vary on;' \ +'gzip_proxied any;' \ +'gzip_types application/json text/plain text/css text/javascript application/javascript application/octet-stream;' \ +'gzip_disable "msie6";' \ +'' \ 'server {' \ ' listen 80;' \ ' server_name _;' \ diff --git a/Dockerfile.app.local b/Dockerfile.app.local index ed48553..b1045e8 100644 --- a/Dockerfile.app.local +++ b/Dockerfile.app.local @@ -52,6 +52,15 @@ RUN printf '%s\n' \ ' "" $remote_addr;' \ '}' \ '' \ +'# gzip 压缩配置(对 base64 图片等非流式响应有效)' \ +'gzip on;' \ +'gzip_min_length 256;' \ +'gzip_comp_level 5;' \ +'gzip_vary on;' \ +'gzip_proxied any;' \ +'gzip_types application/json text/plain text/css text/javascript application/javascript application/octet-stream;' \ +'gzip_disable "msie6";' \ +'' \ 'server {' \ ' listen 80;' \ ' server_name _;' \ diff --git a/alembic/versions/20260106_1524_ad55f1d008b7_add_management_tokens_table.py b/alembic/versions/20260106_1524_ad55f1d008b7_add_management_tokens_table.py new file mode 100644 index 0000000..fe75c6e --- /dev/null +++ b/alembic/versions/20260106_1524_ad55f1d008b7_add_management_tokens_table.py @@ -0,0 +1,131 @@ +"""add_management_tokens_table + +Revision ID: ad55f1d008b7 +Revises: c3d4e5f6g7h8 +Create Date: 2026-01-06 15:24:10.660394+00:00 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + +# revision identifiers, used by Alembic. +revision = 'ad55f1d008b7' +down_revision = 'c3d4e5f6g7h8' +branch_labels = None +depends_on = None + + +def table_exists(table_name: str) -> bool: + """检查表是否存在""" + conn = op.get_bind() + inspector = inspect(conn) + return table_name in inspector.get_table_names() + + +def index_exists(table_name: str, index_name: str) -> bool: + """检查索引是否存在""" + conn = op.get_bind() + inspector = inspect(conn) + try: + indexes = inspector.get_indexes(table_name) + return any(idx["name"] == index_name for idx in indexes) + except Exception: + return False + + +def constraint_exists(table_name: str, constraint_name: str) -> bool: + """检查约束是否存在""" + conn = op.get_bind() + inspector = inspect(conn) + try: + constraints = inspector.get_unique_constraints(table_name) + if any(c["name"] == constraint_name for c in constraints): + return True + # 也检查 check 约束 + check_constraints = inspector.get_check_constraints(table_name) + if any(c["name"] == constraint_name for c in check_constraints): + return True + return False + except Exception: + return False + + +def upgrade() -> None: + """应用迁移:创建 management_tokens 表""" + # 幂等性检查 + if table_exists("management_tokens"): + # 表已存在,检查是否需要添加约束 + if not constraint_exists("management_tokens", "uq_management_tokens_user_name"): + op.create_unique_constraint( + "uq_management_tokens_user_name", + "management_tokens", + ["user_id", "name"], + ) + # 添加 IP 白名单非空检查约束 + if not constraint_exists("management_tokens", "check_allowed_ips_not_empty"): + op.create_check_constraint( + "check_allowed_ips_not_empty", + "management_tokens", + "allowed_ips IS NULL OR allowed_ips::text = 'null' OR json_array_length(allowed_ips) > 0", + ) + return + + op.create_table('management_tokens', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('token_prefix', sa.String(length=12), nullable=True), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('allowed_ips', sa.JSON(), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('last_used_ip', sa.String(length=45), nullable=True), + sa.Column('usage_count', sa.Integer(), server_default='0', nullable=False), + sa.Column('is_active', sa.Boolean(), server_default='true', nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index('idx_management_tokens_is_active', 'management_tokens', ['is_active'], unique=False) + op.create_index('idx_management_tokens_user_id', 'management_tokens', ['user_id'], unique=False) + op.create_index(op.f('ix_management_tokens_token_hash'), 'management_tokens', ['token_hash'], unique=True) + # 添加用户名称唯一约束 + op.create_unique_constraint( + "uq_management_tokens_user_name", + "management_tokens", + ["user_id", "name"], + ) + # 添加 IP 白名单非空检查约束 + # 注意:JSON 类型的 NULL 可能被序列化为 JSON 'null',需要同时处理 + op.create_check_constraint( + "check_allowed_ips_not_empty", + "management_tokens", + "allowed_ips IS NULL OR allowed_ips::text = 'null' OR json_array_length(allowed_ips) > 0", + ) + + +def downgrade() -> None: + """回滚迁移:删除 management_tokens 表""" + # 幂等性检查 + if not table_exists("management_tokens"): + return + + # 删除约束 + if constraint_exists("management_tokens", "check_allowed_ips_not_empty"): + op.drop_constraint("check_allowed_ips_not_empty", "management_tokens", type_="check") + if constraint_exists("management_tokens", "uq_management_tokens_user_name"): + op.drop_constraint("uq_management_tokens_user_name", "management_tokens", type_="unique") + + # 删除索引 + if index_exists("management_tokens", "ix_management_tokens_token_hash"): + op.drop_index(op.f('ix_management_tokens_token_hash'), table_name='management_tokens') + if index_exists("management_tokens", "idx_management_tokens_user_id"): + op.drop_index('idx_management_tokens_user_id', table_name='management_tokens') + if index_exists("management_tokens", "idx_management_tokens_is_active"): + op.drop_index('idx_management_tokens_is_active', table_name='management_tokens') + + # 删除表 + op.drop_table('management_tokens') diff --git a/frontend/src/api/management-tokens.ts b/frontend/src/api/management-tokens.ts new file mode 100644 index 0000000..ec6defd --- /dev/null +++ b/frontend/src/api/management-tokens.ts @@ -0,0 +1,203 @@ +/** + * Management Token API + */ + +import apiClient from './client' + +// ============== 类型定义 ============== + +export interface ManagementToken { + id: string + user_id: string + name: string + description?: string + token_display: string + allowed_ips?: string[] | null + expires_at?: string | null + last_used_at?: string | null + last_used_ip?: string | null + usage_count: number + is_active: boolean + created_at: string + updated_at: string + user?: { + id: string + email: string + username: string + role: string + } +} + +export interface CreateManagementTokenRequest { + name: string + description?: string + allowed_ips?: string[] + expires_at?: string | null +} + +export interface CreateManagementTokenResponse { + message: string + token: string + data: ManagementToken +} + +export interface UpdateManagementTokenRequest { + name?: string + description?: string | null + allowed_ips?: string[] | null + expires_at?: string | null + is_active?: boolean +} + +export interface ManagementTokenListResponse { + items: ManagementToken[] + total: number + skip: number + limit: number + quota?: { + used: number + max: number + } +} + +// ============== 用户自助管理 API ============== + +export const managementTokenApi = { + /** + * 列出当前用户的 Management Tokens + */ + async listTokens(params?: { + is_active?: boolean + skip?: number + limit?: number + }): Promise { + const response = await apiClient.get( + '/api/me/management-tokens', + { params } + ) + return response.data + }, + + /** + * 创建 Management Token + */ + async createToken( + data: CreateManagementTokenRequest + ): Promise { + const response = await apiClient.post( + '/api/me/management-tokens', + data + ) + return response.data + }, + + /** + * 获取 Token 详情 + */ + async getToken(tokenId: string): Promise { + const response = await apiClient.get( + `/api/me/management-tokens/${tokenId}` + ) + return response.data + }, + + /** + * 更新 Token + */ + async updateToken( + tokenId: string, + data: UpdateManagementTokenRequest + ): Promise<{ message: string; data: ManagementToken }> { + const response = await apiClient.put<{ message: string; data: ManagementToken }>( + `/api/me/management-tokens/${tokenId}`, + data + ) + return response.data + }, + + /** + * 删除 Token + */ + async deleteToken(tokenId: string): Promise<{ message: string }> { + const response = await apiClient.delete<{ message: string }>( + `/api/me/management-tokens/${tokenId}` + ) + return response.data + }, + + /** + * 切换 Token 状态 + */ + async toggleToken( + tokenId: string + ): Promise<{ message: string; data: ManagementToken }> { + const response = await apiClient.patch<{ message: string; data: ManagementToken }>( + `/api/me/management-tokens/${tokenId}/status` + ) + return response.data + }, + + /** + * 重新生成 Token + */ + async regenerateToken( + tokenId: string + ): Promise<{ token: string; data: ManagementToken }> { + const response = await apiClient.post<{ token: string; data: ManagementToken }>( + `/api/me/management-tokens/${tokenId}/regenerate` + ) + return response.data + } +} + +// ============== 管理员 API ============== + +export const adminManagementTokenApi = { + /** + * 列出所有 Management Tokens(管理员) + */ + async listAllTokens(params?: { + user_id?: string + is_active?: boolean + skip?: number + limit?: number + }): Promise { + const response = await apiClient.get( + '/api/admin/management-tokens', + { params } + ) + return response.data + }, + + /** + * 获取 Token 详情(管理员) + */ + async getToken(tokenId: string): Promise { + const response = await apiClient.get( + `/api/admin/management-tokens/${tokenId}` + ) + return response.data + }, + + /** + * 删除任意 Token(管理员) + */ + async deleteToken(tokenId: string): Promise<{ message: string }> { + const response = await apiClient.delete<{ message: string }>( + `/api/admin/management-tokens/${tokenId}` + ) + return response.data + }, + + /** + * 切换任意 Token 状态(管理员) + */ + async toggleToken( + tokenId: string + ): Promise<{ message: string; data: ManagementToken }> { + const response = await apiClient.patch<{ message: string; data: ManagementToken }>( + `/api/admin/management-tokens/${tokenId}/status` + ) + return response.data + } +} diff --git a/frontend/src/components/ui/dialog/Dialog.vue b/frontend/src/components/ui/dialog/Dialog.vue index a77759a..b35b771 100644 --- a/frontend/src/components/ui/dialog/Dialog.vue +++ b/frontend/src/components/ui/dialog/Dialog.vue @@ -18,7 +18,7 @@ v-if="isOpen" class="fixed inset-0 bg-black/40 backdrop-blur-sm transition-opacity pointer-events-auto" :style="{ zIndex: backdropZIndex }" - @click="handleClose" + @click="handleBackdropClick" /> @@ -106,6 +106,7 @@ const props = defineProps<{ iconClass?: string // Custom icon color class zIndex?: number // Custom z-index for nested dialogs (default: 60) noPadding?: boolean // Disable default content padding + persistent?: boolean // Prevent closing on backdrop click }>() // Emits 定义 @@ -138,6 +139,13 @@ function handleClose() { } } +// 处理背景点击 +function handleBackdropClick() { + if (!props.persistent) { + handleClose() + } +} + const maxWidthClass = computed(() => { const sizeValue = props.maxWidth || props.size || 'md' const sizes = { @@ -162,7 +170,7 @@ const contentZIndex = computed(() => (props.zIndex || 60) + 10) // 添加 ESC 键监听 useEscapeKey(() => { - if (isOpen.value) { + if (isOpen.value && !props.persistent) { handleClose() return true // 阻止其他监听器(如父级抽屉的 ESC 监听器) } diff --git a/frontend/src/features/auth/components/LoginDialog.vue b/frontend/src/features/auth/components/LoginDialog.vue index 4d05dfe..2cc6baf 100644 --- a/frontend/src/features/auth/components/LoginDialog.vue +++ b/frontend/src/features/auth/components/LoginDialog.vue @@ -73,14 +73,16 @@ >

@@ -92,7 +121,10 @@

-