fix: repair admin monitor auth and add invite CRUD
This commit is contained in:
parent
7e1d9bfede
commit
9a2bae04b5
@ -153,6 +153,38 @@ class UserManager:
|
|||||||
def list_invite_codes(self):
|
def list_invite_codes(self):
|
||||||
return list(self._invites.values())
|
return list(self._invites.values())
|
||||||
|
|
||||||
|
def upsert_invite_code(self, code: str, remaining: Optional[int]) -> Dict:
|
||||||
|
"""新增或更新邀请码。remaining=None 表示不限次数。"""
|
||||||
|
invite_code = (code or "").strip()
|
||||||
|
if not invite_code:
|
||||||
|
raise ValueError("邀请码不能为空。")
|
||||||
|
if len(invite_code) > 64:
|
||||||
|
raise ValueError("邀请码长度不能超过 64 个字符。")
|
||||||
|
if remaining is not None:
|
||||||
|
try:
|
||||||
|
remaining = int(remaining)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValueError("remaining 必须是整数或 null。")
|
||||||
|
if remaining < 0:
|
||||||
|
raise ValueError("remaining 不能小于 0。")
|
||||||
|
entry = {
|
||||||
|
"code": invite_code,
|
||||||
|
"remaining": remaining,
|
||||||
|
}
|
||||||
|
self._invites[invite_code] = entry
|
||||||
|
self._save_invite_codes()
|
||||||
|
return dict(entry)
|
||||||
|
|
||||||
|
def delete_invite_code(self, code: str) -> bool:
|
||||||
|
invite_code = (code or "").strip()
|
||||||
|
if not invite_code:
|
||||||
|
raise ValueError("邀请码不能为空。")
|
||||||
|
existed = invite_code in self._invites
|
||||||
|
if existed:
|
||||||
|
self._invites.pop(invite_code, None)
|
||||||
|
self._save_invite_codes()
|
||||||
|
return existed
|
||||||
|
|
||||||
def list_users(self) -> Dict[str, UserRecord]:
|
def list_users(self) -> Dict[str, UserRecord]:
|
||||||
"""返回当前注册用户的浅拷贝字典,键为用户名。"""
|
"""返回当前注册用户的浅拷贝字典,键为用户名。"""
|
||||||
return dict(self._users)
|
return dict(self._users)
|
||||||
|
|||||||
@ -19,6 +19,7 @@ from .conversation import (
|
|||||||
collect_upload_events,
|
collect_upload_events,
|
||||||
summarize_upload_events,
|
summarize_upload_events,
|
||||||
)
|
)
|
||||||
|
from .conversation_stats import summarize_invite_codes
|
||||||
from modules import admin_policy_manager, balance_client
|
from modules import admin_policy_manager, balance_client
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from config import ADMIN_SECONDARY_PASSWORD_HASH, ADMIN_SECONDARY_PASSWORD, ADMIN_SECONDARY_TTL_SECONDS
|
from config import ADMIN_SECONDARY_PASSWORD_HASH, ADMIN_SECONDARY_PASSWORD, ADMIN_SECONDARY_TTL_SECONDS
|
||||||
@ -58,6 +59,14 @@ def admin_monitor_page():
|
|||||||
"""管理员监控页面入口"""
|
"""管理员监控页面入口"""
|
||||||
return send_from_directory(str(Path(current_app.static_folder)/"admin_dashboard"), 'index.html')
|
return send_from_directory(str(Path(current_app.static_folder)/"admin_dashboard"), 'index.html')
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/monitor')
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
def admin_monitor_page_alias():
|
||||||
|
"""兼容旧入口:/monitor -> /admin/monitor"""
|
||||||
|
return redirect('/admin/monitor')
|
||||||
|
|
||||||
@admin_bp.route('/admin/policy')
|
@admin_bp.route('/admin/policy')
|
||||||
@login_required
|
@login_required
|
||||||
@admin_required
|
@admin_required
|
||||||
@ -233,6 +242,50 @@ def admin_dashboard_snapshot_api():
|
|||||||
return jsonify({"success": False, "error": str(exc)}), 500
|
return jsonify({"success": False, "error": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@admin_bp.route('/api/admin/invites', methods=['GET', 'POST', 'PUT', 'DELETE'])
|
||||||
|
@api_login_required
|
||||||
|
@admin_api_required
|
||||||
|
def admin_invites_api():
|
||||||
|
"""邀请码管理:查询 / 新增 / 更新 / 删除。"""
|
||||||
|
guard = _secondary_required()
|
||||||
|
if guard:
|
||||||
|
return guard
|
||||||
|
|
||||||
|
try:
|
||||||
|
if request.method == 'GET':
|
||||||
|
codes = user_manager.list_invite_codes()
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"data": {
|
||||||
|
"summary": summarize_invite_codes(codes),
|
||||||
|
"codes": codes,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
code = (payload.get("code") or request.args.get("code") or "").strip()
|
||||||
|
|
||||||
|
if request.method in {'POST', 'PUT'}:
|
||||||
|
has_remaining_key = "remaining" in payload
|
||||||
|
remaining = payload.get("remaining") if has_remaining_key else None
|
||||||
|
# 未传 remaining 时默认 1,便于快速新增邀请码
|
||||||
|
if not has_remaining_key:
|
||||||
|
remaining = 1
|
||||||
|
saved = user_manager.upsert_invite_code(code, remaining)
|
||||||
|
return jsonify({"success": True, "data": saved})
|
||||||
|
|
||||||
|
# DELETE
|
||||||
|
deleted = user_manager.delete_invite_code(code)
|
||||||
|
if not deleted:
|
||||||
|
return jsonify({"success": False, "error": "邀请码不存在"}), 404
|
||||||
|
return jsonify({"success": True, "data": {"deleted": code}})
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 400
|
||||||
|
except Exception as exc:
|
||||||
|
logging.exception("invites API error")
|
||||||
|
return jsonify({"success": False, "error": str(exc)}), 500
|
||||||
|
|
||||||
|
|
||||||
def build_admin_dashboard_snapshot():
|
def build_admin_dashboard_snapshot():
|
||||||
"""
|
"""
|
||||||
复用对话模块的完整统计逻辑,返回带用量/Token/存储/上传等数据的仪表盘快照。
|
复用对话模块的完整统计逻辑,返回带用量/Token/存储/上传等数据的仪表盘快照。
|
||||||
@ -243,6 +296,8 @@ def build_admin_dashboard_snapshot():
|
|||||||
# 兜底:若高级统计失败,仍返回最小信息,避免前端 500
|
# 兜底:若高级统计失败,仍返回最小信息,避免前端 500
|
||||||
logging.getLogger(__name__).warning("rich dashboard snapshot failed: %s", exc)
|
logging.getLogger(__name__).warning("rich dashboard snapshot failed: %s", exc)
|
||||||
handle_map = state.container_manager.list_containers()
|
handle_map = state.container_manager.list_containers()
|
||||||
|
invite_codes = state.user_manager.list_invite_codes()
|
||||||
|
invite_summary = summarize_invite_codes(invite_codes)
|
||||||
return {
|
return {
|
||||||
"generated_at": time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
"generated_at": time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
||||||
"overview": {
|
"overview": {
|
||||||
@ -257,6 +312,10 @@ def build_admin_dashboard_snapshot():
|
|||||||
"containers": [
|
"containers": [
|
||||||
{"username": u, "status": h} for u, h in handle_map.items()
|
{"username": u, "status": h} for u, h in handle_map.items()
|
||||||
],
|
],
|
||||||
|
"invites": {
|
||||||
|
"summary": invite_summary,
|
||||||
|
"codes": invite_codes,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -73,8 +73,9 @@ def resolve_admin_policy(record=None) -> Dict[str, Any]:
|
|||||||
def admin_required(view_func):
|
def admin_required(view_func):
|
||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
def wrapped(*args, **kwargs):
|
def wrapped(*args, **kwargs):
|
||||||
record = get_current_user_record()
|
# host 模式下可能不存在 users.json 记录(如 username=host),
|
||||||
if not record or not is_admin_user(record):
|
# 但 session.role 已是 admin,此时仍应允许访问管理页。
|
||||||
|
if not is_admin_user():
|
||||||
return redirect('/new')
|
return redirect('/new')
|
||||||
return view_func(*args, **kwargs)
|
return view_func(*args, **kwargs)
|
||||||
return wrapped
|
return wrapped
|
||||||
@ -83,8 +84,7 @@ def admin_required(view_func):
|
|||||||
def admin_api_required(view_func):
|
def admin_api_required(view_func):
|
||||||
@wraps(view_func)
|
@wraps(view_func)
|
||||||
def wrapped(*args, **kwargs):
|
def wrapped(*args, **kwargs):
|
||||||
record = get_current_user_record()
|
if not is_admin_user():
|
||||||
if not record or not is_admin_user(record):
|
|
||||||
return jsonify({"success": False, "error": "需要管理员权限"}), 403
|
return jsonify({"success": False, "error": "需要管理员权限"}), 403
|
||||||
return view_func(*args, **kwargs)
|
return view_func(*args, **kwargs)
|
||||||
return wrapped
|
return wrapped
|
||||||
|
|||||||
@ -2,15 +2,18 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from collections import Counter
|
import time
|
||||||
from datetime import datetime
|
from collections import Counter, deque
|
||||||
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from modules.user_manager import UserWorkspace
|
from modules.user_manager import UserWorkspace
|
||||||
|
from modules.usage_tracker import QUOTA_DEFAULTS
|
||||||
from utils.conversation_manager import ConversationManager
|
from utils.conversation_manager import ConversationManager
|
||||||
|
from .context import get_or_create_usage_tracker
|
||||||
|
|
||||||
from config import PROJECT_MAX_STORAGE_MB, PROJECT_MAX_STORAGE_BYTES, UPLOAD_SCAN_LOG_SUBDIR
|
from config import PROJECT_MAX_STORAGE_MB, PROJECT_MAX_STORAGE_BYTES, UPLOAD_SCAN_LOG_SUBDIR, LOGS_DIR
|
||||||
from .state import (
|
from .state import (
|
||||||
RECENT_UPLOAD_EVENT_LIMIT,
|
RECENT_UPLOAD_EVENT_LIMIT,
|
||||||
RECENT_UPLOAD_FEED_LIMIT,
|
RECENT_UPLOAD_FEED_LIMIT,
|
||||||
@ -82,6 +85,19 @@ def compute_workspace_storage(workspace: UserWorkspace) -> Dict[str, Any]:
|
|||||||
|
|
||||||
def collect_usage_snapshot(username: str, workspace: UserWorkspace, role: Optional[str]) -> Dict[str, Any]:
|
def collect_usage_snapshot(username: str, workspace: UserWorkspace, role: Optional[str]) -> Dict[str, Any]:
|
||||||
tracker = get_or_create_usage_tracker(username, workspace)
|
tracker = get_or_create_usage_tracker(username, workspace)
|
||||||
|
if not tracker:
|
||||||
|
# 兜底:理论上网页用户应始终有 tracker;这里避免因异常导致整个 dashboard 失败
|
||||||
|
snapshot: Dict[str, Any] = {}
|
||||||
|
for metric in ("fast", "thinking", "search"):
|
||||||
|
default_limit = QUOTA_DEFAULTS.get("default", {}).get(metric, {}).get("limit", 0)
|
||||||
|
snapshot[metric] = {
|
||||||
|
"count": 0,
|
||||||
|
"window_start": None,
|
||||||
|
"reset_at": None,
|
||||||
|
"limit": default_limit,
|
||||||
|
}
|
||||||
|
snapshot["role"] = role or "user"
|
||||||
|
return snapshot
|
||||||
stats = tracker.get_stats()
|
stats = tracker.get_stats()
|
||||||
quotas = stats.get("quotas") or {}
|
quotas = stats.get("quotas") or {}
|
||||||
windows = stats.get("windows") or {}
|
windows = stats.get("windows") or {}
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>管理员监控面板</title>
|
<title>管理员监控面板</title>
|
||||||
<link rel="stylesheet" href="/static/dist/assets/admin.css" />
|
<link rel="stylesheet" href="/static/dist/assets/admin.css" />
|
||||||
|
<script src="/static/security.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="admin-app"></div>
|
<div id="admin-app"></div>
|
||||||
|
|||||||
@ -310,11 +310,44 @@
|
|||||||
<span>已用:{{ inviteSummary.consumed || 0 }}</span>
|
<span>已用:{{ inviteSummary.consumed || 0 }}</span>
|
||||||
<span>无限制:{{ inviteSummary.unlimited || 0 }}</span>
|
<span>无限制:{{ inviteSummary.unlimited || 0 }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="invite-manage">
|
||||||
|
<input
|
||||||
|
v-model.trim="newInviteCode"
|
||||||
|
class="invite-input"
|
||||||
|
type="text"
|
||||||
|
placeholder="新邀请码"
|
||||||
|
:disabled="inviteSubmitting"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
v-model="newInviteRemaining"
|
||||||
|
class="invite-input short"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
placeholder="剩余次数"
|
||||||
|
:disabled="inviteSubmitting || newInviteUnlimited"
|
||||||
|
/>
|
||||||
|
<label class="invite-check">
|
||||||
|
<input type="checkbox" v-model="newInviteUnlimited" :disabled="inviteSubmitting" />
|
||||||
|
不限次数
|
||||||
|
</label>
|
||||||
|
<button type="button" :disabled="inviteSubmitting" @click="createInvite">
|
||||||
|
{{ inviteSubmitting ? '处理中...' : '新增邀请码' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="inviteError" class="secondary-error">{{ inviteError }}</p>
|
||||||
<div class="invite-grid">
|
<div class="invite-grid">
|
||||||
<div v-if="!inviteCodes.length" class="invite-card">暂无邀请码数据</div>
|
<div v-if="!inviteCodes.length" class="invite-card">暂无邀请码数据</div>
|
||||||
<div v-for="code in inviteCodes" :key="code.code" class="invite-card">
|
<div v-for="code in inviteCodes" :key="code.code" class="invite-card">
|
||||||
<h4>{{ code.code }}</h4>
|
<h4>{{ code.code }}</h4>
|
||||||
<span>{{ inviteStatus(code.remaining) }}</span>
|
<span>{{ inviteStatus(code.remaining) }}</span>
|
||||||
|
<div class="invite-actions">
|
||||||
|
<button type="button" class="mini-btn" :disabled="inviteSubmitting" @click="editInvite(code)">
|
||||||
|
调整次数
|
||||||
|
</button>
|
||||||
|
<button type="button" class="mini-btn danger" :disabled="inviteSubmitting" @click="removeInvite(code.code)">
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@ -353,6 +386,11 @@ const balanceLoading = ref(false);
|
|||||||
const balanceError = ref<string | null>(null);
|
const balanceError = ref<string | null>(null);
|
||||||
const balanceData = ref<Record<string, any> | null>(null);
|
const balanceData = ref<Record<string, any> | null>(null);
|
||||||
const balanceUpdatedAt = ref<number | null>(null);
|
const balanceUpdatedAt = ref<number | null>(null);
|
||||||
|
const inviteSubmitting = ref(false);
|
||||||
|
const inviteError = ref<string | null>(null);
|
||||||
|
const newInviteCode = ref('');
|
||||||
|
const newInviteRemaining = ref('1');
|
||||||
|
const newInviteUnlimited = ref(false);
|
||||||
|
|
||||||
const sectionTabs: Array<{ id: SectionId; label: string }> = [
|
const sectionTabs: Array<{ id: SectionId; label: string }> = [
|
||||||
{ id: 'overview', label: '系统总览' },
|
{ id: 'overview', label: '系统总览' },
|
||||||
@ -422,6 +460,125 @@ const scheduleAutoRefresh = () => {
|
|||||||
const handleManualRefresh = () => fetchDashboard(true);
|
const handleManualRefresh = () => fetchDashboard(true);
|
||||||
const handleRetry = () => fetchDashboard(false);
|
const handleRetry = () => fetchDashboard(false);
|
||||||
|
|
||||||
|
const fetchCsrfToken = async (): Promise<string> => {
|
||||||
|
const resp = await fetch('/api/csrf-token', { credentials: 'same-origin' });
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(`获取 CSRF Token 失败:${resp.status}`);
|
||||||
|
}
|
||||||
|
const payload = await resp.json();
|
||||||
|
const token = payload?.token;
|
||||||
|
if (!token || typeof token !== 'string') {
|
||||||
|
throw new Error('获取 CSRF Token 失败:响应无 token');
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseInviteRemaining = (raw: unknown): number | null => {
|
||||||
|
const text = String(raw ?? '').trim().toLowerCase();
|
||||||
|
if (!text || text === 'unlimited' || text === 'null' || text === 'none' || text === '不限' || text === '无限制') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = Number(text);
|
||||||
|
if (!Number.isFinite(parsed) || parsed < 0 || !Number.isInteger(parsed)) {
|
||||||
|
throw new Error('剩余次数必须是大于等于 0 的整数,或留空表示不限次数');
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createInvite = async () => {
|
||||||
|
const code = newInviteCode.value.trim();
|
||||||
|
if (!code) {
|
||||||
|
inviteError.value = '请输入邀请码';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inviteSubmitting.value = true;
|
||||||
|
inviteError.value = null;
|
||||||
|
try {
|
||||||
|
const remaining = newInviteUnlimited.value ? null : parseInviteRemaining(newInviteRemaining.value);
|
||||||
|
const csrfToken = await fetchCsrfToken();
|
||||||
|
const resp = await fetch('/api/admin/invites', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': csrfToken,
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({ code, remaining }),
|
||||||
|
});
|
||||||
|
const payload = await resp.json();
|
||||||
|
if (!resp.ok || !payload.success) {
|
||||||
|
throw new Error(payload.error || `请求失败:${resp.status}`);
|
||||||
|
}
|
||||||
|
newInviteCode.value = '';
|
||||||
|
newInviteRemaining.value = '1';
|
||||||
|
newInviteUnlimited.value = false;
|
||||||
|
await fetchDashboard(true);
|
||||||
|
} catch (error) {
|
||||||
|
inviteError.value = error instanceof Error ? error.message : String(error);
|
||||||
|
} finally {
|
||||||
|
inviteSubmitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const editInvite = async (item: any) => {
|
||||||
|
const current = item?.remaining;
|
||||||
|
const seed = current === null || typeof current === 'undefined' ? 'unlimited' : String(current);
|
||||||
|
const value = window.prompt('请输入新的剩余次数(留空或输入 unlimited 表示不限次数)', seed);
|
||||||
|
if (value === null) return;
|
||||||
|
inviteSubmitting.value = true;
|
||||||
|
inviteError.value = null;
|
||||||
|
try {
|
||||||
|
const remaining = parseInviteRemaining(value);
|
||||||
|
const csrfToken = await fetchCsrfToken();
|
||||||
|
const resp = await fetch('/api/admin/invites', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': csrfToken,
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({ code: item?.code, remaining }),
|
||||||
|
});
|
||||||
|
const payload = await resp.json();
|
||||||
|
if (!resp.ok || !payload.success) {
|
||||||
|
throw new Error(payload.error || `请求失败:${resp.status}`);
|
||||||
|
}
|
||||||
|
await fetchDashboard(true);
|
||||||
|
} catch (error) {
|
||||||
|
inviteError.value = error instanceof Error ? error.message : String(error);
|
||||||
|
} finally {
|
||||||
|
inviteSubmitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeInvite = async (code: string) => {
|
||||||
|
if (!code) return;
|
||||||
|
if (!window.confirm(`确认删除邀请码 ${code}?`)) return;
|
||||||
|
inviteSubmitting.value = true;
|
||||||
|
inviteError.value = null;
|
||||||
|
try {
|
||||||
|
const csrfToken = await fetchCsrfToken();
|
||||||
|
const resp = await fetch('/api/admin/invites', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRF-Token': csrfToken,
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
});
|
||||||
|
const payload = await resp.json();
|
||||||
|
if (!resp.ok || !payload.success) {
|
||||||
|
throw new Error(payload.error || `请求失败:${resp.status}`);
|
||||||
|
}
|
||||||
|
await fetchDashboard(true);
|
||||||
|
} catch (error) {
|
||||||
|
inviteError.value = error instanceof Error ? error.message : String(error);
|
||||||
|
} finally {
|
||||||
|
inviteSubmitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchBalance = async () => {
|
const fetchBalance = async () => {
|
||||||
balanceLoading.value = true;
|
balanceLoading.value = true;
|
||||||
balanceError.value = null;
|
balanceError.value = null;
|
||||||
@ -1152,6 +1309,37 @@ th {
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invite-manage {
|
||||||
|
margin: 10px 0 14px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-input {
|
||||||
|
min-width: 180px;
|
||||||
|
height: 38px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(118, 103, 84, 0.35);
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
padding: 0 10px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-input.short {
|
||||||
|
min-width: 120px;
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.invite-check {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--claude-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.invite-card {
|
.invite-card {
|
||||||
border: 1px solid rgba(118, 103, 84, 0.3);
|
border: 1px solid rgba(118, 103, 84, 0.3);
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@ -1169,6 +1357,24 @@ th {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.invite-actions {
|
||||||
|
margin-top: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-btn {
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-btn.danger {
|
||||||
|
background: rgba(189, 93, 58, 0.2);
|
||||||
|
color: #7c3418;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
.balance-header {
|
.balance-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user