Initial commit: rdgen with GitHub/Gitea dual build backend
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
from django.contrib import admin
|
||||
|
||||
from .models import ApiToken, GithubRun
|
||||
|
||||
|
||||
@admin.register(GithubRun)
|
||||
class GithubRunAdmin(admin.ModelAdmin):
|
||||
list_display = ('id', 'filename', 'platform', 'status_label', 'created_by', 'github_run_id', 'created_at')
|
||||
list_filter = ('status', 'platform', 'created_at')
|
||||
search_fields = ('uuid', 'filename', 'created_by__username')
|
||||
readonly_fields = ('uuid', 'github_run_id', 'created_at', 'updated_at')
|
||||
list_per_page = 30
|
||||
|
||||
|
||||
@admin.register(ApiToken)
|
||||
class ApiTokenAdmin(admin.ModelAdmin):
|
||||
list_display = ('name', 'user', 'is_active', 'created_at', 'last_used_at')
|
||||
list_filter = ('is_active',)
|
||||
search_fields = ('name', 'user__username', 'key')
|
||||
readonly_fields = ('key', 'created_at', 'last_used_at')
|
||||
list_per_page = 30
|
||||
|
||||
|
||||
admin.site.site_header = 'RustDesk 客户端定制平台 · 后台管理'
|
||||
admin.site.site_title = '平台管理后台'
|
||||
admin.site.index_title = '管理首页'
|
||||
@@ -0,0 +1,216 @@
|
||||
import json
|
||||
import re
|
||||
|
||||
from django.http import JsonResponse
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from . import app_settings
|
||||
from .models import ApiToken
|
||||
from .versions import is_valid_version
|
||||
from .views import generate_custom_client, _get_run_status
|
||||
|
||||
|
||||
# 字段取值约束(与 GenerateForm 保持一致)
|
||||
PLATFORM_CHOICES = ['windows', 'windows-x86', 'linux', 'android', 'macos']
|
||||
# 版本跟随 rustdesk/rustdesk 官方 tags 动态更新,这里只做格式校验
|
||||
DIRECTION_CHOICES = ['incoming', 'outgoing', 'both']
|
||||
INSTALLATION_CHOICES = ['installationY', 'installationN']
|
||||
SETTINGS_CHOICES = ['settingsY', 'settingsN']
|
||||
THEME_CHOICES = ['light', 'dark', 'system']
|
||||
THEME_DORO_CHOICES = ['default', 'override']
|
||||
PASS_APPROVE_MODE_CHOICES = ['password', 'click', 'password-click']
|
||||
PERMISSIONS_DORO_CHOICES = ['default', 'override']
|
||||
PERMISSIONS_TYPE_CHOICES = ['custom', 'full', 'view']
|
||||
|
||||
# 布尔字段
|
||||
BOOL_FIELDS = [
|
||||
'delayFix', 'xOffline', 'hidecm', 'removeNewVersionNotif',
|
||||
'denyLan', 'enableDirectIP', 'autoClose',
|
||||
'enableKeyboard', 'enableClipboard', 'enableFileTransfer', 'enableAudio',
|
||||
'enableTCP', 'enableRemoteRestart', 'enableRecording', 'enableBlockingInput',
|
||||
'enableRemoteModi', 'removeWallpaper', 'enablePrinter', 'enableCamera', 'enableTerminal',
|
||||
]
|
||||
|
||||
# 可选字符串字段
|
||||
OPTIONAL_STR_FIELDS = [
|
||||
'sh_secret_field', 'serverIP', 'serverPort', 'key', 'apiServer', 'urlLink', 'downloadLink',
|
||||
'appname', 'compname', 'androidappid', 'permanentPassword',
|
||||
'defaultManual', 'overrideManual',
|
||||
'iconbase64', 'logobase64', 'privacybase64',
|
||||
]
|
||||
|
||||
|
||||
def _authenticate(request):
|
||||
"""通过 Authorization: Token <key> 请求头认证,返回 User 或 None。"""
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if not auth_header.startswith('Token '):
|
||||
return None
|
||||
key = auth_header[len('Token '):].strip()
|
||||
token = (
|
||||
ApiToken.objects
|
||||
.filter(key=key, is_active=True)
|
||||
.select_related('user')
|
||||
.first()
|
||||
)
|
||||
if token and token.user.is_active:
|
||||
token.last_used_at = timezone.now()
|
||||
token.save(update_fields=['last_used_at'])
|
||||
return token.user
|
||||
return None
|
||||
|
||||
|
||||
def _unauthorized():
|
||||
return JsonResponse(
|
||||
{"success": False, "error": "未认证或令牌无效,请在请求头中携带有效的 Token(Authorization: Token <key>)。"},
|
||||
status=401,
|
||||
headers={'WWW-Authenticate': 'Token'},
|
||||
)
|
||||
|
||||
|
||||
def validate_generate_params(data):
|
||||
"""
|
||||
按与 GenerateForm 相同的约束校验 JSON API 参数。
|
||||
|
||||
Returns:
|
||||
(cleaned_data, errors)
|
||||
"""
|
||||
errors = {}
|
||||
cleaned = {}
|
||||
|
||||
# 必填字符串字段
|
||||
exename = data.get('exename', '')
|
||||
if not exename:
|
||||
errors['exename'] = '该字段为必填项。'
|
||||
else:
|
||||
cleaned['exename'] = exename
|
||||
|
||||
# 枚举字段
|
||||
choice_validations = {
|
||||
'platform': (PLATFORM_CHOICES, 'windows'),
|
||||
'direction': (DIRECTION_CHOICES, 'both'),
|
||||
'installation': (INSTALLATION_CHOICES, 'installationY'),
|
||||
'settings': (SETTINGS_CHOICES, 'settingsY'),
|
||||
'theme': (THEME_CHOICES, 'system'),
|
||||
'themeDorO': (THEME_DORO_CHOICES, 'default'),
|
||||
'passApproveMode': (PASS_APPROVE_MODE_CHOICES, 'password-click'),
|
||||
'permissionsDorO': (PERMISSIONS_DORO_CHOICES, 'default'),
|
||||
'permissionsType': (PERMISSIONS_TYPE_CHOICES, 'custom'),
|
||||
}
|
||||
for field, (choices, default) in choice_validations.items():
|
||||
value = data.get(field, default)
|
||||
if value not in choices:
|
||||
errors[field] = f'取值无效,必须为以下之一:{choices}'
|
||||
else:
|
||||
cleaned[field] = value
|
||||
|
||||
# 版本:master 或形如 1.4.9 / 1.5.0-rc.1 的官方 tag(列表动态获取,不做硬枚举)
|
||||
version = data.get('version', '1.4.9')
|
||||
if not is_valid_version(version):
|
||||
errors['version'] = "版本号无效,应为 'master' 或形如 1.4.9 的官方发布 tag。"
|
||||
else:
|
||||
cleaned['version'] = version
|
||||
|
||||
# 布尔字段
|
||||
for field in BOOL_FIELDS:
|
||||
value = data.get(field, False)
|
||||
if not isinstance(value, bool):
|
||||
errors[field] = '必须为布尔值(true/false)。'
|
||||
else:
|
||||
cleaned[field] = value
|
||||
|
||||
# 可选字符串字段
|
||||
for field in OPTIONAL_STR_FIELDS:
|
||||
cleaned[field] = data.get(field, '')
|
||||
|
||||
# 自由文本名称会进入 shell sed 脚本(与表单校验规则一致)
|
||||
for field in ('appname', 'compname'):
|
||||
value = cleaned.get(field, '')
|
||||
if isinstance(value, str) and re.search(r'[&\\|\'"$`\r\n]', value):
|
||||
errors[field] = '包含构建脚本不支持的字符(& \\ | \' " $ ` 或换行符)。'
|
||||
|
||||
# API 模式不使用文件上传,改用 base64 字段
|
||||
cleaned['iconfile'] = None
|
||||
cleaned['logofile'] = None
|
||||
cleaned['privacyfile'] = None
|
||||
|
||||
return cleaned, errors
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def api_generate(request):
|
||||
"""
|
||||
POST /api/generate
|
||||
|
||||
接收客户端配置 JSON,触发 GitHub Actions 自定义构建。
|
||||
需在请求头携带:Authorization: Token <key>
|
||||
"""
|
||||
if request.method != 'POST':
|
||||
return JsonResponse({"success": False, "error": "仅支持 POST 请求。"}, status=405)
|
||||
|
||||
user = _authenticate(request)
|
||||
if user is None:
|
||||
return _unauthorized()
|
||||
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
return JsonResponse({"success": False, "error": f"JSON 格式无效:{str(e)}"}, status=400)
|
||||
|
||||
cleaned, errors = validate_generate_params(data)
|
||||
if errors:
|
||||
return JsonResponse({
|
||||
"success": False,
|
||||
"error": "参数校验未通过",
|
||||
"details": errors
|
||||
}, status=400)
|
||||
|
||||
full_url = f"{app_settings.get_value('PROTOCOL')}://{request.get_host()}"
|
||||
|
||||
result = generate_custom_client(cleaned, full_url, user=user)
|
||||
|
||||
if result['success']:
|
||||
result['status_url'] = f"/api/status?uuid={result['uuid']}&platform={result['platform']}&filename={result['filename']}"
|
||||
return JsonResponse(result)
|
||||
else:
|
||||
return JsonResponse({"success": False, "error": result['error']}, status=result.get('status_code', 500))
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def api_status(request):
|
||||
"""
|
||||
GET /api/status?uuid=<uuid>
|
||||
|
||||
查询指定构建任务的状态。需携带 API Token。
|
||||
"""
|
||||
if request.method != 'GET':
|
||||
return JsonResponse({"success": False, "error": "仅支持 GET 请求。"}, status=405)
|
||||
|
||||
if _authenticate(request) is None:
|
||||
return _unauthorized()
|
||||
|
||||
uuid_val = request.GET.get('uuid')
|
||||
if not uuid_val:
|
||||
return JsonResponse({"success": False, "error": "缺少必填参数:uuid"}, status=400)
|
||||
|
||||
filename = request.GET.get('filename', '')
|
||||
platform = request.GET.get('platform', '')
|
||||
|
||||
result = _get_run_status(uuid_val)
|
||||
|
||||
if not result['found']:
|
||||
return JsonResponse({"success": False, "error": "未找到对应的构建任务"}, status=404)
|
||||
|
||||
response_data = {
|
||||
"success": True,
|
||||
"status": result['status'],
|
||||
"status_label": result['gh_run'].status_label(),
|
||||
"uuid": uuid_val,
|
||||
"log_url": result['github_log_url'],
|
||||
}
|
||||
if filename:
|
||||
response_data['filename'] = filename
|
||||
if platform:
|
||||
response_data['platform'] = platform
|
||||
|
||||
return JsonResponse(response_data)
|
||||
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
GitHub / 构建相关站点配置的统一读取层。
|
||||
|
||||
取值优先级:
|
||||
1. 后台「GitHub 构建配置」页保存的值(SiteSetting 表)
|
||||
2. 环境变量(docker-compose / 系统环境,历史部署方式,继续支持)
|
||||
3. 内置默认值
|
||||
|
||||
进程内带 15 秒短缓存,避免每次调用都查库;后台保存后立即失效当前进程缓存,
|
||||
其他 gunicorn worker 最迟 15 秒后生效。
|
||||
"""
|
||||
import os
|
||||
import time
|
||||
|
||||
# 每项:key=配置键 / env=对应环境变量名 / default=内置默认值 / label/help/组 供后台页渲染
|
||||
SETTING_DEFS = [
|
||||
{
|
||||
'key': 'GHUSER',
|
||||
'env': 'GHUSER',
|
||||
'default': '',
|
||||
'label': 'GitHub 用户名',
|
||||
'group': 'basic',
|
||||
'sensitive': False,
|
||||
'help': 'Fork 本项目仓库所在的 GitHub 账号用户名(或组织名)。',
|
||||
},
|
||||
{
|
||||
'key': 'REPONAME',
|
||||
'env': 'REPONAME',
|
||||
'default': 'rdgen',
|
||||
'label': '仓库名称',
|
||||
'group': 'basic',
|
||||
'sensitive': False,
|
||||
'help': 'Fork 后的仓库名,默认为 rdgen;若改名请如实填写。',
|
||||
},
|
||||
{
|
||||
'key': 'GHBRANCH',
|
||||
'env': 'GHBRANCH',
|
||||
'default': 'master',
|
||||
'label': '工作流分支',
|
||||
'group': 'basic',
|
||||
'sensitive': False,
|
||||
'help': '触发 GitHub Actions 时使用的分支名,通常保持 master。',
|
||||
},
|
||||
{
|
||||
'key': 'GHBEARER',
|
||||
'env': 'GHBEARER',
|
||||
'default': '',
|
||||
'label': 'GitHub 访问令牌(Fine-grained PAT)',
|
||||
'group': 'basic',
|
||||
'sensitive': True,
|
||||
'help': '需对该仓库授予 Actions 与 Workflows 的读写权限。页面仅显示保存状态,不会回显明文。',
|
||||
},
|
||||
{
|
||||
'key': 'GENURL',
|
||||
'env': 'GENURL',
|
||||
'default': '',
|
||||
'label': '本平台外部访问地址',
|
||||
'group': 'callback',
|
||||
'sensitive': False,
|
||||
'help': '运行 Actions 的构建机可从网络访问到的本平台地址,例如 https://rdgen.example.com,用于回传构建产物。',
|
||||
},
|
||||
{
|
||||
'key': 'PROTOCOL',
|
||||
'env': 'PROTOCOL',
|
||||
'default': 'https',
|
||||
'label': '平台访问协议',
|
||||
'group': 'callback',
|
||||
'sensitive': False,
|
||||
'choices': [('https', 'https'), ('http', 'http')],
|
||||
'help': '经 Nginx/Caddy 等 HTTPS 反向代理时保持 https;本地纯 HTTP 部署选 http。',
|
||||
},
|
||||
{
|
||||
'key': 'ZIP_PASSWORD',
|
||||
'env': 'ZIP_PASSWORD',
|
||||
'default': 'insecure',
|
||||
'label': '配置压缩包密码',
|
||||
'group': 'callback',
|
||||
'sensitive': True,
|
||||
'help': '本平台用它加密传给 Actions 的配置包,必须与构建仓库 Secret 中的 ZIP_PASSWORD 完全一致(GitHub 与 Gitea 仓库均需配置)。默认值不安全,建议更换。',
|
||||
},
|
||||
{
|
||||
'key': 'SH_SECRET',
|
||||
'env': 'SH_SECRET',
|
||||
'default': 'secret',
|
||||
'label': '自托管 Runner 密钥',
|
||||
'group': 'advanced',
|
||||
'sensitive': True,
|
||||
'help': '仅在使用自托管 GitHub Runner(rdgen-cli 的 sh_secret_field)时需要,默认值不安全。',
|
||||
},
|
||||
{
|
||||
'key': 'WEBHOOK_SECRET',
|
||||
'env': 'WEBHOOK_SECRET',
|
||||
'default': '',
|
||||
'label': '回调校验密钥',
|
||||
'group': 'advanced',
|
||||
'sensitive': True,
|
||||
'help': '可选。配置后构建平台(GitHub / Gitea)Actions 回调必须携带 X-Webhook-Secret 请求头;留空则不校验(兼容旧工作流)。',
|
||||
},
|
||||
{
|
||||
'key': 'GH_PROXY',
|
||||
'env': 'GH_PROXY',
|
||||
'default': '',
|
||||
'label': 'GitHub 访问代理',
|
||||
'group': 'network',
|
||||
'sensitive': True,
|
||||
'help': (
|
||||
'本服务器直连 GitHub 失败时填写,例如 http://127.0.0.1:7890;'
|
||||
'支持 socks5://(需 PySocks)。可包含账密,如 http://user:pass@host:port。'
|
||||
'留空则直连(仍遵循容器的 HTTPS_PROXY/HTTP_PROXY 环境变量)。'
|
||||
),
|
||||
},
|
||||
# ===== 构建平台选择(GitHub / Gitea 二选一) =====
|
||||
{
|
||||
'key': 'BUILD_PLATFORM',
|
||||
'env': 'BUILD_PLATFORM',
|
||||
'default': 'github',
|
||||
'label': '构建平台',
|
||||
'group': 'platform',
|
||||
'sensitive': False,
|
||||
'choices': [('github', 'GitHub Actions'), ('gitea', 'Gitea Actions(自建)')],
|
||||
'help': (
|
||||
'选择实际执行构建任务的平台。切换后仅影响新提交的任务,'
|
||||
'历史任务仍在原平台查询状态与日志。Gitea 需自建实例并自备构建机(Linux/Windows/macOS)。'
|
||||
),
|
||||
},
|
||||
# ===== Gitea 连接(BUILD_PLATFORM=gitea 时生效) =====
|
||||
{
|
||||
'key': 'GITEA_SERVER_URL',
|
||||
'env': 'GITEA_SERVER_URL',
|
||||
'default': '',
|
||||
'label': 'Gitea 服务器地址',
|
||||
'group': 'gitea',
|
||||
'sensitive': False,
|
||||
'help': '自建 Gitea 的访问地址,例如 https://gitea.example.com(不带末尾斜杠);建议版本不低于 1.27。',
|
||||
},
|
||||
{
|
||||
'key': 'GITEA_OWNER',
|
||||
'env': 'GITEA_OWNER',
|
||||
'default': '',
|
||||
'label': 'Gitea 仓库属主',
|
||||
'group': 'gitea',
|
||||
'sensitive': False,
|
||||
'help': '存放构建仓库的 Gitea 用户名或组织名。',
|
||||
},
|
||||
{
|
||||
'key': 'GITEA_REPO',
|
||||
'env': 'GITEA_REPO',
|
||||
'default': 'rdgen',
|
||||
'label': 'Gitea 仓库名称',
|
||||
'group': 'gitea',
|
||||
'sensitive': False,
|
||||
'help': 'Gitea 上的构建仓库名,默认 rdgen;仓库需在设置中启用 Repository Actions。',
|
||||
},
|
||||
{
|
||||
'key': 'GITEA_BRANCH',
|
||||
'env': 'GITEA_BRANCH',
|
||||
'default': 'master',
|
||||
'label': 'Gitea 工作流分支',
|
||||
'group': 'gitea',
|
||||
'sensitive': False,
|
||||
'help': '触发 Gitea Actions 时使用的分支名,通常保持 master。',
|
||||
},
|
||||
{
|
||||
'key': 'GITEA_TOKEN',
|
||||
'env': 'GITEA_TOKEN',
|
||||
'default': '',
|
||||
'label': 'Gitea 访问令牌',
|
||||
'group': 'gitea',
|
||||
'sensitive': True,
|
||||
'help': '在 Gitea 个人设置中生成的 API 令牌,需对构建仓库授予 Actions 读写权限。页面仅显示保存状态,不会回显明文。',
|
||||
},
|
||||
{
|
||||
'key': 'GITEA_PROXY',
|
||||
'env': 'GITEA_PROXY',
|
||||
'default': '',
|
||||
'label': 'Gitea 访问代理',
|
||||
'group': 'gitea',
|
||||
'sensitive': True,
|
||||
'help': (
|
||||
'服务器访问 Gitea 需要代理时填写;Gitea 通常部署在同内网,多数情况留空直连即可。'
|
||||
),
|
||||
},
|
||||
# ===== 任务维护(后台「维护设置」页使用,不渲染在 GitHub 配置页) =====
|
||||
{
|
||||
'key': 'BUILD_RETENTION_DAYS',
|
||||
'env': 'BUILD_RETENTION_DAYS',
|
||||
'default': '30',
|
||||
'label': '构建任务保留天数',
|
||||
'group': 'maintenance',
|
||||
'sensitive': False,
|
||||
'help': '超过该天数的已结束任务(成功/失败/取消)及其安装包产物会被自动清理;0 表示永不自动清理。',
|
||||
},
|
||||
{
|
||||
'key': 'DB_BACKUP_ENABLED',
|
||||
'env': 'DB_BACKUP_ENABLED',
|
||||
'default': '1',
|
||||
'label': '启用数据库定时备份',
|
||||
'group': 'maintenance',
|
||||
'sensitive': False,
|
||||
'help': '开启后维护进程每天自动备份一次数据库(含账号、配置方案、构建记录)。',
|
||||
},
|
||||
{
|
||||
'key': 'DB_BACKUP_KEEP',
|
||||
'env': 'DB_BACKUP_KEEP',
|
||||
'default': '7',
|
||||
'label': '数据库备份保留份数',
|
||||
'group': 'maintenance',
|
||||
'sensitive': False,
|
||||
'help': '超出份数的最旧备份会被自动删除,建议 7~30。',
|
||||
},
|
||||
]
|
||||
|
||||
DEFS_BY_KEY = {d['key']: d for d in SETTING_DEFS}
|
||||
|
||||
SOURCE_DB = 'db' # 后台配置
|
||||
SOURCE_ENV = 'env' # 环境变量
|
||||
SOURCE_DEFAULT = 'default' # 内置默认值
|
||||
|
||||
SOURCE_LABELS = {
|
||||
SOURCE_DB: '后台配置',
|
||||
SOURCE_ENV: '环境变量',
|
||||
SOURCE_DEFAULT: '默认值',
|
||||
}
|
||||
|
||||
_CACHE_TTL = 15.0
|
||||
_cache = {'ts': 0.0, 'rows': {}}
|
||||
|
||||
|
||||
def _load_rows():
|
||||
"""一次性读取全部后台配置,带短缓存。"""
|
||||
now = time.time()
|
||||
if now - _cache['ts'] > _CACHE_TTL:
|
||||
try:
|
||||
from .models import SiteSetting
|
||||
_cache['rows'] = dict(SiteSetting.objects.values_list('key', 'value'))
|
||||
except Exception:
|
||||
# 迁移尚未执行等异常情况下退回环境变量,避免阻断请求
|
||||
_cache['rows'] = {}
|
||||
_cache['ts'] = now
|
||||
return _cache['rows']
|
||||
|
||||
|
||||
def invalidate_cache():
|
||||
_cache['ts'] = 0.0
|
||||
|
||||
|
||||
def get_source(key):
|
||||
"""返回某项当前生效值的来源:db / env / default。"""
|
||||
d = DEFS_BY_KEY[key]
|
||||
rows = _load_rows()
|
||||
if key in rows:
|
||||
return SOURCE_DB
|
||||
if os.environ.get(d['env']) is not None:
|
||||
return SOURCE_ENV
|
||||
return SOURCE_DEFAULT
|
||||
|
||||
|
||||
def get_value(key):
|
||||
"""按 后台配置 → 环境变量 → 默认值 的优先级返回生效值(始终为字符串)。"""
|
||||
d = DEFS_BY_KEY[key]
|
||||
rows = _load_rows()
|
||||
if key in rows:
|
||||
return rows[key]
|
||||
return os.environ.get(d['env'], d['default'])
|
||||
|
||||
|
||||
def is_overridden(key):
|
||||
return get_source(key) == SOURCE_DB
|
||||
|
||||
|
||||
def set_value(key, value):
|
||||
"""写入后台配置覆盖。"""
|
||||
from .models import SiteSetting
|
||||
SiteSetting.objects.update_or_create(key=key, defaults={'value': value})
|
||||
invalidate_cache()
|
||||
|
||||
|
||||
def delete_value(key):
|
||||
"""删除后台覆盖,回退到环境变量/默认值。"""
|
||||
from .models import SiteSetting
|
||||
SiteSetting.objects.filter(key=key).delete()
|
||||
invalidate_cache()
|
||||
|
||||
|
||||
def all_settings():
|
||||
"""供后台页面渲染:每项定义 + 当前生效值 + 来源。"""
|
||||
result = []
|
||||
for d in SETTING_DEFS:
|
||||
result.append({
|
||||
**d,
|
||||
'value': get_value(d['key']),
|
||||
'source': get_source(d['key']),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def is_github_configured():
|
||||
"""当前所选构建平台是否具备发起构建的最低配置(保留函数名兼容既有调用)。"""
|
||||
from .build_backends import get_backend
|
||||
return get_backend().configured()
|
||||
|
||||
|
||||
def config_warnings():
|
||||
"""返回当前所选构建平台的配置隐患列表(中文),供页面提示。"""
|
||||
from .build_backends import get_backend
|
||||
return get_backend().warnings()
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class RdgeneratorConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'rdgenerator'
|
||||
verbose_name = 'RustDesk 客户端生成'
|
||||
@@ -0,0 +1,39 @@
|
||||
"""
|
||||
构建后端抽象层:把 GitHub Actions / Gitea Actions 的触发、状态查询、
|
||||
日志链接、连接测试收敛为统一接口,业务代码不再感知平台差异。
|
||||
|
||||
通过 app_settings 的 BUILD_PLATFORM(后台配置 → 环境变量 → 默认值)选择后端;
|
||||
每个 GithubRun 记录自身 backend,切换平台后历史任务仍走原平台查询。
|
||||
"""
|
||||
from .. import app_settings
|
||||
from .github import GitHubBackend
|
||||
from .gitea import GiteaBackend
|
||||
|
||||
# 已支持的构建平台(Gitee Go 经评估不兼容,未纳入)
|
||||
BACKENDS = {
|
||||
'github': GitHubBackend,
|
||||
'gitea': GiteaBackend,
|
||||
}
|
||||
|
||||
PLATFORM_CHOICES = [
|
||||
('github', 'GitHub Actions'),
|
||||
('gitea', 'Gitea Actions(自建)'),
|
||||
]
|
||||
|
||||
|
||||
def current_platform():
|
||||
"""返回当前生效的构建平台标识(非法值回退 github)。"""
|
||||
name = app_settings.get_value('BUILD_PLATFORM').strip().lower()
|
||||
return name if name in BACKENDS else 'github'
|
||||
|
||||
|
||||
def get_backend(name=None):
|
||||
"""按名称获取后端实例;name 为空时取当前配置平台。
|
||||
|
||||
后端实例无状态(每次调用实时读取设置),直接新建即可,
|
||||
这样后台修改配置后无需重启即可生效。
|
||||
"""
|
||||
if not name:
|
||||
name = current_platform()
|
||||
cls = BACKENDS.get(name, GitHubBackend)
|
||||
return cls()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""构建后端统一接口与共享逻辑。"""
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class BackendError(Exception):
|
||||
"""触发构建失败时抛出,message 为可直接展示给用户的中文提示。"""
|
||||
|
||||
def __init__(self, message, *, kind='error'):
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
# rejected=平台明确拒绝(4xx/5xx);network=连不上平台
|
||||
self.kind = kind
|
||||
|
||||
|
||||
@dataclass
|
||||
class DispatchResult:
|
||||
run_id: object = None # 平台返回的运行编号(int 或 None)
|
||||
html_url: str = '' # 平台上的运行详情页地址
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunInfo:
|
||||
finished: bool # 是否已进入终态
|
||||
status: str = '' # 终态结论:success/failure/cancelled/skipped;未结束为进行中状态
|
||||
html_url: str = ''
|
||||
|
||||
|
||||
# rdgen 目标平台 -> 工作流文件名(两平台同名)
|
||||
WORKFLOW_FILES = {
|
||||
'windows': 'generator-windows.yml',
|
||||
'windows-x86': 'generator-windows-x86.yml',
|
||||
'linux': 'generator-linux.yml',
|
||||
'android': 'generator-android.yml',
|
||||
'macos': 'generator-macos.yml',
|
||||
}
|
||||
|
||||
|
||||
class BuildBackend:
|
||||
"""各构建平台实现此接口。所有配置均通过 app_settings 实时读取。"""
|
||||
|
||||
name = ''
|
||||
label = ''
|
||||
|
||||
# ---- 配置健康度(后台页 / 全局告警条使用) ----
|
||||
def configured(self) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
def warnings(self) -> list:
|
||||
"""配置隐患的中文提示列表;通用项(GENURL/ZIP_PASSWORD)各实现自行包含。"""
|
||||
raise NotImplementedError
|
||||
|
||||
# ---- 构建任务 ----
|
||||
def workflow_file_for(self, platform, selfhosted=False) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
def dispatch(self, workflow_file, inputs, *, timeout=20) -> DispatchResult:
|
||||
"""触发工作流。inputs 为完整输入字典(GitHub/Gitea 均为 {ref, inputs})。"""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_run(self, run_id, *, timeout=12):
|
||||
"""查询单次运行;网络异常或非 200 返回 None(调用方按「仍在进行」处理)。"""
|
||||
raise NotImplementedError
|
||||
|
||||
def log_url(self, run_id) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
# ---- 后台连接测试 ----
|
||||
def test_connection(self) -> dict:
|
||||
"""返回 {'ok': bool, 'message': 中文说明},不抛异常。"""
|
||||
raise NotImplementedError
|
||||
|
||||
# ---- 共享的配置检查 ----
|
||||
def _shared_warnings(self, *, include_sh_secret=False):
|
||||
"""两平台共用的回调/加密检查。"""
|
||||
from .. import app_settings
|
||||
warnings = []
|
||||
if not app_settings.get_value('GENURL').strip():
|
||||
warnings.append('平台外部访问地址(GENURL)未配置,构建机将无法回传构建产物。')
|
||||
zip_password = app_settings.get_value('ZIP_PASSWORD')
|
||||
if zip_password in ('', 'insecure'):
|
||||
warnings.append(
|
||||
'配置压缩包密码为空或仍为默认值 insecure,建议设置随机强密码,'
|
||||
f'并同步更新{self.label}仓库 Secret。'
|
||||
)
|
||||
if include_sh_secret and app_settings.get_value('SH_SECRET') in ('', 'secret'):
|
||||
warnings.append('自托管 Runner 密钥为空或仍为默认值 secret;如使用自托管 Runner,建议设置独立强密钥,避免被冒用触发。')
|
||||
return warnings
|
||||
@@ -0,0 +1,230 @@
|
||||
"""Gitea Actions 构建后端(自建 Gitea 1.23+,建议 1.27+)。
|
||||
|
||||
API 与 GitHub Actions 几乎同形(已对照 Gitea 官方 Swagger 核实):
|
||||
- 触发:POST /api/v1/repos/{owner}/{repo}/actions/workflows/{file}/dispatches
|
||||
?return_run_details=true,body {ref, inputs},200 返回
|
||||
{workflow_run_id, html_url, run_url},不传该参数为 204;
|
||||
- 状态:GET /api/v1/repos/{owner}/{repo}/actions/runs/{id},
|
||||
status/conclusion 字段仿 GitHub;
|
||||
- 鉴权:Authorization: token <PAT>。
|
||||
"""
|
||||
from .. import app_settings, ghnet
|
||||
from .base import BackendError, DispatchResult, RunInfo, WORKFLOW_FILES, BuildBackend
|
||||
|
||||
# Gitea 未结束状态(blocked=等待审批,waiting=等待 runner)
|
||||
_GITEA_ACTIVE = {'queued', 'in_progress', 'waiting', 'blocked', 'requested', 'pending'}
|
||||
# Gitea 结论 -> rdgen 内部状态
|
||||
_CONCLUSION_MAP = {
|
||||
'success': 'success',
|
||||
'failure': 'failure',
|
||||
'cancelled': 'cancelled',
|
||||
'skipped': 'skipped',
|
||||
'timed_out': 'timed_out',
|
||||
}
|
||||
|
||||
|
||||
class GiteaBackend(BuildBackend):
|
||||
name = 'gitea'
|
||||
label = 'Gitea'
|
||||
|
||||
# ---- 取值辅助 ----
|
||||
def _server(self):
|
||||
return app_settings.get_value('GITEA_SERVER_URL').strip().rstrip('/')
|
||||
|
||||
def _owner(self):
|
||||
return app_settings.get_value('GITEA_OWNER').strip()
|
||||
|
||||
def _repo(self):
|
||||
return app_settings.get_value('GITEA_REPO').strip() or 'rdgen'
|
||||
|
||||
def _branch(self):
|
||||
return app_settings.get_value('GITEA_BRANCH').strip() or 'master'
|
||||
|
||||
def _ref(self):
|
||||
branch = self._branch()
|
||||
return branch if branch.startswith('refs/') else f'refs/heads/{branch}'
|
||||
|
||||
def _proxy(self):
|
||||
return app_settings.get_value('GITEA_PROXY').strip()
|
||||
|
||||
def _api(self, path):
|
||||
return f'{self._server()}/api/v1{path}'
|
||||
|
||||
# ---- 配置 ----
|
||||
def configured(self):
|
||||
return bool(self._server() and self._owner() and app_settings.get_value('GITEA_TOKEN').strip())
|
||||
|
||||
def warnings(self):
|
||||
warnings = []
|
||||
if not self._server():
|
||||
warnings.append('Gitea 服务器地址未配置,无法触发构建。')
|
||||
if not self._owner():
|
||||
warnings.append('Gitea 仓库属主未配置,无法触发构建。')
|
||||
if not app_settings.get_value('GITEA_TOKEN').strip():
|
||||
warnings.append('Gitea 访问令牌未配置,无法触发构建。')
|
||||
warnings.extend(self._shared_warnings())
|
||||
return warnings
|
||||
|
||||
# ---- 工作流选择 ----
|
||||
def workflow_file_for(self, platform, selfhosted=False):
|
||||
# Gitea 的 runner 全部为自建注册,无需 sh- 变体,统一用标准工作流
|
||||
return WORKFLOW_FILES.get(platform, 'generator-windows.yml')
|
||||
|
||||
# ---- 触发 ----
|
||||
def dispatch(self, workflow_file, inputs, *, timeout=20):
|
||||
if not self.configured():
|
||||
raise BackendError('Gitea 构建服务未完成配置(服务器地址 / 属主 / 令牌缺失)。', kind='config')
|
||||
url = self._api(
|
||||
f'/repos/{self._owner()}/{self._repo()}'
|
||||
f'/actions/workflows/{workflow_file}/dispatches?return_run_details=true'
|
||||
)
|
||||
payload = {'ref': self._ref(), 'inputs': inputs}
|
||||
try:
|
||||
resp = ghnet.post(
|
||||
url, json=payload,
|
||||
token=app_settings.get_value('GITEA_TOKEN'),
|
||||
timeout=timeout, flavor='gitea', proxy=self._proxy(),
|
||||
)
|
||||
except Exception as e:
|
||||
raise BackendError(f'无法连接 Gitea 构建服务:{e}', kind='network')
|
||||
|
||||
if resp.status_code in (200, 204):
|
||||
data = {}
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
pass
|
||||
run_id = data.get('workflow_run_id')
|
||||
html_url = data.get('html_url') or ''
|
||||
# 旧版 Gitea 或未回传详情时,按工作流最新一条运行兜底取 run id
|
||||
if not run_id and resp.status_code == 204:
|
||||
fallback = self._latest_run_id(workflow_file)
|
||||
if fallback:
|
||||
run_id, html_url = fallback
|
||||
return DispatchResult(run_id=run_id, html_url=html_url or self.log_url(run_id))
|
||||
|
||||
detail = ''
|
||||
try:
|
||||
detail = (resp.text or '')[:200]
|
||||
except Exception:
|
||||
pass
|
||||
if resp.status_code == 404:
|
||||
msg = f'Gitea 仓库或工作流 {workflow_file} 不存在(请确认仓库已启用 Repository Actions)。'
|
||||
elif resp.status_code in (401, 403):
|
||||
msg = 'Gitea 拒绝访问:请检查令牌是否有效且具备该仓库 Actions 读写权限。'
|
||||
elif resp.status_code == 422:
|
||||
msg = f'Gitea 校验失败(分支或工作流参数有误):{detail}'
|
||||
else:
|
||||
msg = f'Gitea 构建服务拒绝了启动请求(HTTP {resp.status_code}):{detail}'
|
||||
raise BackendError(msg, kind='rejected')
|
||||
|
||||
def _latest_run_id(self, workflow_file, *, timeout=10):
|
||||
"""204 兜底:查该工作流最新一条运行(仅在刚触发后调用)。"""
|
||||
url = self._api(
|
||||
f'/repos/{self._owner()}/{self._repo()}'
|
||||
f'/actions/workflows/{workflow_file}/runs?limit=1'
|
||||
)
|
||||
try:
|
||||
resp = ghnet.get(
|
||||
url, token=app_settings.get_value('GITEA_TOKEN'),
|
||||
timeout=timeout, flavor='gitea', proxy=self._proxy(),
|
||||
)
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json()
|
||||
runs = data.get('workflow_runs') if isinstance(data, dict) else data
|
||||
if runs:
|
||||
run = runs[0]
|
||||
return run.get('id'), run.get('html_url') or ''
|
||||
except Exception as e:
|
||||
print(f'查询 Gitea 最新运行失败:{e}')
|
||||
return None
|
||||
|
||||
# ---- 状态查询 ----
|
||||
def get_run(self, run_id, *, timeout=12):
|
||||
url = self._api(f'/repos/{self._owner()}/{self._repo()}/actions/runs/{run_id}')
|
||||
try:
|
||||
resp = ghnet.get(
|
||||
url, token=app_settings.get_value('GITEA_TOKEN'),
|
||||
timeout=timeout, flavor='gitea', proxy=self._proxy(),
|
||||
)
|
||||
except Exception as e:
|
||||
print(f'查询 Gitea 状态出错:{e}')
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json()
|
||||
html_url = data.get('html_url') or self.log_url(run_id)
|
||||
status = (data.get('status') or '').lower()
|
||||
conclusion = (data.get('conclusion') or '').lower()
|
||||
if status == 'completed':
|
||||
return RunInfo(
|
||||
finished=True,
|
||||
status=_CONCLUSION_MAP.get(conclusion, 'failure'),
|
||||
html_url=html_url,
|
||||
)
|
||||
# blocked(等待审批)等也算进行中
|
||||
return RunInfo(finished=False, status=status or 'in_progress', html_url=html_url)
|
||||
|
||||
# ---- 日志链接 ----
|
||||
def log_url(self, run_id):
|
||||
if not run_id:
|
||||
return ''
|
||||
return f'{self._server()}/{self._owner()}/{self._repo()}/actions/runs/{run_id}'
|
||||
|
||||
# ---- 连接测试 ----
|
||||
def test_connection(self):
|
||||
server = self._server()
|
||||
owner = self._owner()
|
||||
token = app_settings.get_value('GITEA_TOKEN').strip()
|
||||
repo = self._repo()
|
||||
if not server or not owner or not token:
|
||||
return {'ok': False, 'message': 'Gitea 服务器地址、仓库属主或访问令牌为空,请先保存配置。'}
|
||||
if not (server.startswith('http://') or server.startswith('https://')):
|
||||
return {'ok': False, 'message': 'Gitea 服务器地址需以 http:// 或 https:// 开头。'}
|
||||
try:
|
||||
resp = ghnet.get(
|
||||
self._api(f'/repos/{owner}/{repo}'),
|
||||
token=token, timeout=10, flavor='gitea', proxy=self._proxy(),
|
||||
)
|
||||
if resp.status_code == 401:
|
||||
return {'ok': False, 'message': '令牌无效或已过期(Gitea 返回 401)。'}
|
||||
if resp.status_code == 404:
|
||||
return {'ok': False,
|
||||
'message': f'找不到 Gitea 仓库 {owner}/{repo},或令牌无权访问该仓库。'}
|
||||
if resp.status_code != 200:
|
||||
return {'ok': False, 'message': f'Gitea 返回异常状态码:{resp.status_code}。'}
|
||||
|
||||
act = ghnet.get(
|
||||
self._api(f'/repos/{owner}/{repo}/actions/workflows'),
|
||||
token=token, timeout=10, flavor='gitea', proxy=self._proxy(),
|
||||
)
|
||||
if act.status_code != 200:
|
||||
return {'ok': False,
|
||||
'message': '可以访问仓库,但无法读取 Actions 工作流;请在仓库设置中启用 Repository Actions。'}
|
||||
|
||||
# 版本检查:1.23 起支持 workflow_dispatch;建议 1.27+(return_run_details 与缓存兼容性更好)
|
||||
version_msg = ''
|
||||
ver = ghnet.get(
|
||||
self._api('/version'),
|
||||
token=token, timeout=10, flavor='gitea', proxy=self._proxy(),
|
||||
)
|
||||
if ver.status_code == 200:
|
||||
raw = (ver.json().get('version') or '').strip()
|
||||
parts = raw.replace('+dev', '').split('.')
|
||||
try:
|
||||
major, minor = int(parts[0]), int(parts[1])
|
||||
if (major, minor) < (1, 23):
|
||||
return {'ok': False,
|
||||
'message': f'Gitea 版本为 {raw},低于 1.23,不支持 workflow_dispatch 远程触发,请先升级。'}
|
||||
if (major, minor) < (1, 27):
|
||||
version_msg = f'当前 Gitea 版本为 {raw},可触发构建,但建议升级到 1.27+(运行详情回传与缓存兼容性更好)。'
|
||||
except (ValueError, IndexError):
|
||||
pass
|
||||
|
||||
msg = f'连接正常:令牌可访问仓库 {owner}/{repo},Actions 已启用。'
|
||||
if version_msg:
|
||||
msg += version_msg
|
||||
return {'ok': True, 'message': msg}
|
||||
except Exception as e:
|
||||
return {'ok': False, 'message': f'无法连接 Gitea API:{e}(请检查服务器地址与网络/代理配置)'}
|
||||
@@ -0,0 +1,110 @@
|
||||
"""GitHub Actions 构建后端(迁自 views.py / web_views.py 的原有逻辑,行为保持一致)。"""
|
||||
from .. import app_settings, ghnet
|
||||
from .base import BackendError, DispatchResult, RunInfo, WORKFLOW_FILES, BuildBackend
|
||||
|
||||
API = 'https://api.github.com'
|
||||
|
||||
# 未进入 completed 的状态一律视为进行中
|
||||
_GITHUB_ACTIVE = {'queued', 'in_progress', 'waiting', 'requested', 'pending'}
|
||||
|
||||
|
||||
class GitHubBackend(BuildBackend):
|
||||
name = 'github'
|
||||
label = 'GitHub'
|
||||
|
||||
# ---- 配置 ----
|
||||
def configured(self):
|
||||
return bool(app_settings.get_value('GHUSER').strip()) \
|
||||
and bool(app_settings.get_value('GHBEARER').strip())
|
||||
|
||||
def warnings(self):
|
||||
warnings = []
|
||||
if not app_settings.get_value('GHUSER').strip():
|
||||
warnings.append('GitHub 用户名未配置,无法触发构建。')
|
||||
if not app_settings.get_value('GHBEARER').strip():
|
||||
warnings.append('GitHub 访问令牌未配置,无法触发构建。')
|
||||
warnings.extend(self._shared_warnings(include_sh_secret=True))
|
||||
return warnings
|
||||
|
||||
# ---- 工作流选择(windows selfhosted 走 sh-generator-windows.yml) ----
|
||||
def workflow_file_for(self, platform, selfhosted=False):
|
||||
if selfhosted and platform in ('windows',):
|
||||
return 'sh-generator-windows.yml'
|
||||
return WORKFLOW_FILES.get(platform, 'generator-windows.yml')
|
||||
|
||||
# ---- 触发 ----
|
||||
def dispatch(self, workflow_file, inputs, *, timeout=20):
|
||||
user = app_settings.get_value('GHUSER')
|
||||
repo = app_settings.get_value('REPONAME')
|
||||
url = f'{API}/repos/{user}/{repo}/actions/workflows/{workflow_file}/dispatches'
|
||||
payload = {
|
||||
'ref': app_settings.get_value('GHBRANCH'),
|
||||
'inputs': inputs,
|
||||
'return_run_details': True,
|
||||
}
|
||||
token = app_settings.get_value('GHBEARER')
|
||||
try:
|
||||
resp = ghnet.post(url, json=payload, token=token, timeout=timeout)
|
||||
except Exception as e:
|
||||
raise BackendError(f'无法连接 GitHub 构建服务:{e}', kind='network')
|
||||
if resp.status_code not in (200, 204):
|
||||
raise BackendError('GitHub 构建服务拒绝了启动请求,请检查服务配置或稍后重试。', kind='rejected')
|
||||
data = {}
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
pass
|
||||
return DispatchResult(run_id=data.get('workflow_run_id'), html_url=data.get('html_url') or '')
|
||||
|
||||
# ---- 状态查询 ----
|
||||
def get_run(self, run_id, *, timeout=12):
|
||||
user = app_settings.get_value('GHUSER')
|
||||
repo = app_settings.get_value('REPONAME')
|
||||
url = f'{API}/repos/{user}/{repo}/actions/runs/{run_id}'
|
||||
try:
|
||||
resp = ghnet.get(url, token=app_settings.get_value('GHBEARER'), timeout=timeout)
|
||||
except Exception as e:
|
||||
print(f'查询 GitHub 状态出错:{e}')
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
data = resp.json()
|
||||
if data.get('status') == 'completed':
|
||||
return RunInfo(finished=True, status=data.get('conclusion') or 'failure',
|
||||
html_url=data.get('html_url') or self.log_url(run_id))
|
||||
return RunInfo(finished=False, status=data.get('status') or 'in_progress',
|
||||
html_url=data.get('html_url') or self.log_url(run_id))
|
||||
|
||||
# ---- 日志链接 ----
|
||||
def log_url(self, run_id):
|
||||
if not run_id:
|
||||
return ''
|
||||
return (
|
||||
f"https://github.com/{app_settings.get_value('GHUSER')}"
|
||||
f"/{app_settings.get_value('REPONAME')}/actions/runs/{run_id}"
|
||||
)
|
||||
|
||||
# ---- 连接测试 ----
|
||||
def test_connection(self):
|
||||
user = app_settings.get_value('GHUSER').strip()
|
||||
repo = app_settings.get_value('REPONAME').strip()
|
||||
token = app_settings.get_value('GHBEARER').strip()
|
||||
if not user or not token:
|
||||
return {'ok': False, 'message': 'GitHub 用户名或访问令牌为空,请先保存配置。'}
|
||||
try:
|
||||
resp = ghnet.get(f'{API}/repos/{user}/{repo}', token=token, timeout=10)
|
||||
if resp.status_code == 401:
|
||||
return {'ok': False, 'message': '令牌无效或已过期(GitHub 返回 401)。'}
|
||||
if resp.status_code == 404:
|
||||
return {'ok': False,
|
||||
'message': f'找不到仓库 {user}/{repo},或令牌无权访问该仓库(请确认仓库名与令牌的 Repository access)。'}
|
||||
if resp.status_code != 200:
|
||||
return {'ok': False, 'message': f'GitHub 返回异常状态码:{resp.status_code}。'}
|
||||
act = ghnet.get(f'{API}/repos/{user}/{repo}/actions/workflows', token=token, timeout=10)
|
||||
if act.status_code == 200:
|
||||
return {'ok': True, 'message': f'连接正常:令牌可访问仓库 {user}/{repo},且具备 Actions 权限。'}
|
||||
return {'ok': False,
|
||||
'message': '可以访问仓库,但令牌缺少 Actions 权限;请编辑令牌,授予 Actions 与 Workflows 的读写权限。'}
|
||||
except Exception as e:
|
||||
return {'ok': False,
|
||||
'message': f'无法连接 GitHub API:{e}(如服务器在国内网络,请在下方配置代理后重试)'}
|
||||
@@ -0,0 +1,20 @@
|
||||
"""全局模板上下文:当前构建平台的配置状态(用于未配置告警条)。"""
|
||||
from .build_backends import get_backend
|
||||
|
||||
|
||||
def github_config(request):
|
||||
if not getattr(request.user, 'is_authenticated', False):
|
||||
return {}
|
||||
backend = get_backend()
|
||||
ready = backend.configured()
|
||||
warnings = backend.warnings()
|
||||
return {
|
||||
# 新变量名
|
||||
'build_platform': backend.name,
|
||||
'build_platform_label': backend.label,
|
||||
'build_ready': ready,
|
||||
'build_warnings': warnings,
|
||||
# 兼容旧模板变量名
|
||||
'github_ready': ready,
|
||||
'github_warnings': warnings,
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
import re
|
||||
|
||||
from django import forms
|
||||
from django.contrib.auth.models import User
|
||||
from PIL import Image
|
||||
|
||||
from . import versions as rdeskVersions
|
||||
|
||||
# 应用名/公司名会被插入到单/双引号包裹的 bash sed 脚本中:& \ | 会导致替换
|
||||
# 静默失败,' " $ ` 会破坏 shell 引号,CR/LF 会破坏 sed 寻址。
|
||||
UNSAFE_NAME_CHARS = re.compile(r'[&\\|\'"$`\r\n]')
|
||||
|
||||
UNSAFE_NAME_MESSAGE = "包含构建脚本不支持的字符(& \\ | ' \" $ ` 或换行符)。"
|
||||
|
||||
# 统一的控件样式:文本/数字/下拉/文件输入框 .form-control,复选/单选 .form-check-input
|
||||
# 注意:Django 5.x 中 NumberInput 直接继承 Input(而非 TextInput),必须单独列出
|
||||
_CONTROL_WIDGETS = (
|
||||
forms.TextInput, forms.NumberInput, forms.PasswordInput, forms.EmailInput,
|
||||
forms.Select, forms.Textarea, forms.FileInput,
|
||||
)
|
||||
|
||||
|
||||
def apply_control_classes(form):
|
||||
for field in form.fields.values():
|
||||
widget = field.widget
|
||||
if isinstance(widget, _CONTROL_WIDGETS):
|
||||
widget.attrs.setdefault('class', 'form-control')
|
||||
elif isinstance(widget, forms.CheckboxInput):
|
||||
widget.attrs.setdefault('class', 'form-check-input')
|
||||
|
||||
|
||||
class GenerateForm(forms.Form):
|
||||
sh_secret_field = forms.CharField(required=False, widget=forms.HiddenInput)
|
||||
|
||||
# 平台与版本
|
||||
platform = forms.ChoiceField(
|
||||
label="目标平台",
|
||||
choices=[
|
||||
('windows', 'Windows 64 位'),
|
||||
('windows-x86', 'Windows 32 位'),
|
||||
('linux', 'Linux'),
|
||||
('android', 'Android'),
|
||||
('macos', 'macOS'),
|
||||
],
|
||||
initial='windows',
|
||||
)
|
||||
version = forms.ChoiceField(
|
||||
label="RustDesk 版本",
|
||||
choices=[('master', '开发版(nightly 每夜构建)')] + [(v, v) for v in rdeskVersions.FALLBACK_VERSIONS],
|
||||
initial='1.4.9',
|
||||
help_text=(
|
||||
"'master' 为开发版(每夜构建),包含最新特性,但稳定性可能不如正式版本。"
|
||||
"正式版列表自动取自 GitHub 官方仓库 rustdesk/rustdesk,跟随官方发版更新。"
|
||||
),
|
||||
)
|
||||
delayFix = forms.BooleanField(label="修复使用第三方 API 时的连接延迟问题", initial=True, required=False)
|
||||
|
||||
# 常规设置
|
||||
exename = forms.CharField(
|
||||
label="配置名称",
|
||||
required=True,
|
||||
help_text="仅限英文、数字与下划线,不含空格;将同时作为生成文件的名称。",
|
||||
)
|
||||
appname = forms.CharField(
|
||||
label="自定义应用名称",
|
||||
required=False,
|
||||
help_text="留空则使用默认名称 rustdesk。",
|
||||
)
|
||||
direction = forms.ChoiceField(
|
||||
label="连接方向",
|
||||
widget=forms.RadioSelect,
|
||||
choices=[
|
||||
('incoming', '仅受控(只能被他人远程连接)'),
|
||||
('outgoing', '仅主控(只能主动连接他人)'),
|
||||
('both', '主控 / 受控双向(默认)'),
|
||||
],
|
||||
initial='both',
|
||||
)
|
||||
installation = forms.ChoiceField(
|
||||
label="是否允许安装",
|
||||
choices=[
|
||||
('installationY', '允许安装(默认)'),
|
||||
('installationN', '禁止安装(绿色单文件运行)'),
|
||||
],
|
||||
initial='installationY',
|
||||
)
|
||||
settings = forms.ChoiceField(
|
||||
label="是否允许修改设置",
|
||||
choices=[
|
||||
('settingsY', '允许修改设置(默认)'),
|
||||
('settingsN', '禁止修改设置'),
|
||||
],
|
||||
initial='settingsY',
|
||||
)
|
||||
androidappid = forms.CharField(
|
||||
label="自定义 Android 应用 ID",
|
||||
required=False,
|
||||
help_text="替换默认 com.carriez.flutter_hbb,留空使用默认值。",
|
||||
)
|
||||
|
||||
# 自定义服务器
|
||||
serverIP = forms.CharField(
|
||||
label="ID / 中继服务器地址",
|
||||
required=False,
|
||||
help_text="填写自建服务器的主机名或 IP。",
|
||||
)
|
||||
serverPort = forms.CharField(label="服务器端口", required=False)
|
||||
apiServer = forms.CharField(
|
||||
label="API 服务器地址",
|
||||
required=False,
|
||||
help_text="留空时默认使用「服务器地址:21114」。",
|
||||
)
|
||||
key = forms.CharField(label="公钥(Key)", required=False)
|
||||
urlLink = forms.CharField(
|
||||
label="自定义官网链接",
|
||||
required=False,
|
||||
help_text="替换默认的 https://rustdesk.com。",
|
||||
)
|
||||
downloadLink = forms.CharField(
|
||||
label="更新下载链接",
|
||||
required=False,
|
||||
help_text="替换默认的 https://rustdesk.com/download。",
|
||||
)
|
||||
compname = forms.CharField(
|
||||
label="版权公司名称",
|
||||
required=False,
|
||||
help_text="替换默认的 Purslane Tech Pte. Ltd.。",
|
||||
)
|
||||
|
||||
# 外观
|
||||
iconfile = forms.FileField(
|
||||
label="应用图标",
|
||||
required=False,
|
||||
widget=forms.FileInput(attrs={'accept': 'image/png'}),
|
||||
help_text="PNG 格式,建议 256×256 正方形。",
|
||||
)
|
||||
logofile = forms.FileField(
|
||||
label="应用 Logo",
|
||||
required=False,
|
||||
widget=forms.FileInput(attrs={'accept': 'image/png'}),
|
||||
help_text="PNG 格式。",
|
||||
)
|
||||
privacyfile = forms.FileField(
|
||||
label="隐私屏图片",
|
||||
required=False,
|
||||
widget=forms.FileInput(attrs={'accept': 'image/png'}),
|
||||
help_text="PNG 格式。",
|
||||
)
|
||||
iconbase64 = forms.CharField(required=False, widget=forms.HiddenInput)
|
||||
logobase64 = forms.CharField(required=False, widget=forms.HiddenInput)
|
||||
privacybase64 = forms.CharField(required=False, widget=forms.HiddenInput)
|
||||
theme = forms.ChoiceField(
|
||||
label="主题",
|
||||
choices=[('light', '浅色'), ('dark', '深色'), ('system', '跟随系统')],
|
||||
initial='system',
|
||||
)
|
||||
themeDorO = forms.ChoiceField(
|
||||
label="主题生效方式",
|
||||
choices=[('default', '默认(允许客户端更改)'), ('override', '强制锁定(客户端无法更改)')],
|
||||
initial='default',
|
||||
)
|
||||
|
||||
# 安全
|
||||
passApproveMode = forms.ChoiceField(
|
||||
label="连接确认方式",
|
||||
choices=[
|
||||
('password', '仅通过密码接受会话'),
|
||||
('click', '仅通过点击接受会话'),
|
||||
('password-click', '密码或点击均可(默认)'),
|
||||
],
|
||||
initial='password-click',
|
||||
)
|
||||
permanentPassword = forms.CharField(
|
||||
label="设置固定访问密码",
|
||||
widget=forms.PasswordInput(),
|
||||
required=False,
|
||||
help_text="该密码为客户端默认密码,用户仍可在客户端自行修改。",
|
||||
)
|
||||
denyLan = forms.BooleanField(label="禁止局域网发现", initial=False, required=False)
|
||||
enableDirectIP = forms.BooleanField(label="允许直接 IP 直连", initial=False, required=False)
|
||||
autoClose = forms.BooleanField(label="用户无操作时自动断开入站会话", initial=False, required=False)
|
||||
hidecm = forms.BooleanField(label="允许在远程画面中隐藏连接窗口", initial=False, required=False)
|
||||
|
||||
# 权限
|
||||
permissionsDorO = forms.ChoiceField(
|
||||
label="权限生效方式",
|
||||
choices=[('default', '默认(用户可更改)'), ('override', '强制锁定(用户无法更改)')],
|
||||
initial='default',
|
||||
)
|
||||
permissionsType = forms.ChoiceField(
|
||||
label="权限预设",
|
||||
choices=[('custom', '自定义'), ('full', '完全控制'), ('view', '仅查看(屏幕共享)')],
|
||||
initial='custom',
|
||||
)
|
||||
enableKeyboard = forms.BooleanField(label="键盘 / 鼠标控制", initial=True, required=False)
|
||||
enableClipboard = forms.BooleanField(label="剪贴板同步", initial=True, required=False)
|
||||
enableFileTransfer = forms.BooleanField(label="文件传输", initial=True, required=False)
|
||||
enableAudio = forms.BooleanField(label="音频传输", initial=True, required=False)
|
||||
enableTCP = forms.BooleanField(label="TCP 隧道", initial=True, required=False)
|
||||
enableRemoteRestart = forms.BooleanField(label="远程重启", initial=True, required=False)
|
||||
enableRecording = forms.BooleanField(label="会话录制", initial=True, required=False)
|
||||
enableBlockingInput = forms.BooleanField(label="屏蔽被控端键鼠输入", initial=True, required=False)
|
||||
enableRemoteModi = forms.BooleanField(label="允许修改远程配置", initial=False, required=False)
|
||||
enablePrinter = forms.BooleanField(label="远程打印机", initial=True, required=False)
|
||||
enableCamera = forms.BooleanField(label="远程摄像头", initial=True, required=False)
|
||||
enableTerminal = forms.BooleanField(label="远程终端", initial=True, required=False)
|
||||
|
||||
# 其他
|
||||
removeWallpaper = forms.BooleanField(label="入站会话期间移除桌面壁纸", initial=True, required=False)
|
||||
defaultManual = forms.CharField(
|
||||
label="默认高级设置",
|
||||
widget=forms.Textarea(attrs={'rows': 4}),
|
||||
required=False,
|
||||
help_text="每行一项,格式:键=值。",
|
||||
)
|
||||
overrideManual = forms.CharField(
|
||||
label="强制高级设置",
|
||||
widget=forms.Textarea(attrs={'rows': 4}),
|
||||
required=False,
|
||||
help_text="每行一项,格式:键=值;设置后客户端用户无法修改。",
|
||||
)
|
||||
|
||||
# 定制功能
|
||||
xOffline = forms.BooleanField(label="通讯录中离线设备显示 X 标记", initial=False, required=False)
|
||||
removeNewVersionNotif = forms.BooleanField(label="移除新版本更新通知", initial=False, required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
apply_control_classes(self)
|
||||
|
||||
def clean_version(self):
|
||||
# 视图会在绑定前把 choices 刷新为官方最新列表;这里再做宽松校验,
|
||||
# 兼容「打开页面后列表刷新 / 官方最新 tag 尚未进列表」等窗口期。
|
||||
value = self.cleaned_data.get('version', '')
|
||||
if not rdeskVersions.is_valid_version(value):
|
||||
raise forms.ValidationError("版本号无效,请从列表中选择或填写形如 1.4.9 的正式版本号。")
|
||||
return value
|
||||
|
||||
def clean_iconfile(self):
|
||||
image = self.cleaned_data['iconfile']
|
||||
if image:
|
||||
try:
|
||||
img = Image.open(image)
|
||||
if img.format != 'PNG':
|
||||
raise forms.ValidationError("仅支持 PNG 格式的图标。")
|
||||
width, height = img.size
|
||||
if width != height:
|
||||
raise forms.ValidationError("应用图标必须为正方形图片。")
|
||||
return image
|
||||
except forms.ValidationError:
|
||||
raise
|
||||
except OSError:
|
||||
raise forms.ValidationError("图标文件无效或已损坏。")
|
||||
except Exception as e:
|
||||
raise forms.ValidationError(f"处理图标时出错:{e}")
|
||||
|
||||
def _reject_unsafe_name_chars(self, field):
|
||||
value = self.cleaned_data.get(field, '')
|
||||
if value and UNSAFE_NAME_CHARS.search(value):
|
||||
raise forms.ValidationError(UNSAFE_NAME_MESSAGE)
|
||||
return value
|
||||
|
||||
def clean_appname(self):
|
||||
return self._reject_unsafe_name_chars('appname')
|
||||
|
||||
def clean_compname(self):
|
||||
return self._reject_unsafe_name_chars('compname')
|
||||
|
||||
|
||||
class ApiTokenForm(forms.Form):
|
||||
name = forms.CharField(label="令牌名称", max_length=100, required=True,
|
||||
help_text="建议按用途命名,例如:办公网构建机、CI 流水线。")
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
apply_control_classes(self)
|
||||
|
||||
|
||||
class GitHubSettingsForm(forms.Form):
|
||||
"""后台「构建平台配置」表单(GitHub / Gitea)。
|
||||
|
||||
非密钥项直接显示生效值;密钥项不回显明文,留空表示不修改,
|
||||
勾选「清除」则删除后台覆盖、回退到环境变量/默认值。
|
||||
"""
|
||||
|
||||
# 构建平台
|
||||
build_platform = forms.ChoiceField(
|
||||
label="构建平台",
|
||||
choices=[('github', 'GitHub Actions'), ('gitea', 'Gitea Actions(自建)')],
|
||||
widget=forms.RadioSelect,
|
||||
required=False,
|
||||
)
|
||||
|
||||
# Gitea 连接
|
||||
gitea_server_url = forms.CharField(
|
||||
label="Gitea 服务器地址",
|
||||
max_length=255,
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': 'https://gitea.example.com'}),
|
||||
)
|
||||
gitea_owner = forms.CharField(label="Gitea 仓库属主", max_length=100, required=False)
|
||||
gitea_repo = forms.CharField(label="Gitea 仓库名称", max_length=100, required=False)
|
||||
gitea_branch = forms.CharField(label="Gitea 工作流分支", max_length=100, required=False)
|
||||
gitea_token = forms.CharField(
|
||||
label="Gitea 访问令牌",
|
||||
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password', 'placeholder': '已保存时留空表示不修改'}),
|
||||
required=False,
|
||||
)
|
||||
clear_gitea_token = forms.BooleanField(label="清除后台保存的令牌", required=False)
|
||||
gitea_proxy = forms.CharField(
|
||||
label="Gitea 访问代理",
|
||||
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password', 'placeholder': '内网直连通常留空,如 http://host:port'}),
|
||||
required=False,
|
||||
)
|
||||
clear_gitea_proxy = forms.BooleanField(label="清除后台保存的代理", required=False)
|
||||
|
||||
# GitHub 基础连接
|
||||
ghuser = forms.CharField(label="GitHub 用户名", max_length=100, required=False)
|
||||
reponame = forms.CharField(label="仓库名称", max_length=100, required=False)
|
||||
ghbranch = forms.CharField(label="工作流分支", max_length=100, required=False)
|
||||
ghbearer = forms.CharField(
|
||||
label="GitHub 访问令牌",
|
||||
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password', 'placeholder': '已保存时留空表示不修改'}),
|
||||
required=False,
|
||||
)
|
||||
clear_ghbearer = forms.BooleanField(label="清除后台保存的令牌", required=False)
|
||||
|
||||
# 回调与加密
|
||||
genurl = forms.CharField(
|
||||
label="本平台外部访问地址",
|
||||
max_length=255,
|
||||
required=False,
|
||||
widget=forms.TextInput(attrs={'placeholder': 'https://rdgen.example.com'}),
|
||||
)
|
||||
protocol = forms.ChoiceField(
|
||||
label="平台访问协议",
|
||||
choices=[('https', 'https'), ('http', 'http')],
|
||||
required=False,
|
||||
)
|
||||
zip_password = forms.CharField(
|
||||
label="配置压缩包密码",
|
||||
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password', 'placeholder': '已保存时留空表示不修改'}),
|
||||
required=False,
|
||||
)
|
||||
clear_zip_password = forms.BooleanField(label="清除后台保存的密码", required=False)
|
||||
|
||||
# 高级
|
||||
sh_secret = forms.CharField(
|
||||
label="自托管 Runner 密钥",
|
||||
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password', 'placeholder': '已保存时留空表示不修改'}),
|
||||
required=False,
|
||||
)
|
||||
clear_sh_secret = forms.BooleanField(label="清除后台保存的密钥", required=False)
|
||||
webhook_secret = forms.CharField(
|
||||
label="回调校验密钥",
|
||||
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password', 'placeholder': '已保存时留空表示不修改'}),
|
||||
required=False,
|
||||
)
|
||||
clear_webhook_secret = forms.BooleanField(label="清除后台保存的密钥", required=False)
|
||||
|
||||
# 网络代理
|
||||
gh_proxy = forms.CharField(
|
||||
label="GitHub 访问代理",
|
||||
widget=forms.PasswordInput(attrs={'autocomplete': 'new-password', 'placeholder': '留空直连,如 http://127.0.0.1:7890'}),
|
||||
required=False,
|
||||
)
|
||||
clear_gh_proxy = forms.BooleanField(label="清除后台保存的代理", required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
apply_control_classes(self)
|
||||
|
||||
|
||||
class MaintenanceSettingsForm(forms.Form):
|
||||
"""后台「维护设置」:构建任务定时清理与数据库定时备份。"""
|
||||
|
||||
build_retention_days = forms.IntegerField(
|
||||
label="构建任务保留天数",
|
||||
min_value=0,
|
||||
max_value=3650,
|
||||
initial=30,
|
||||
help_text="超过该天数的已结束任务(成功 / 失败 / 取消)及安装包产物会被自动清理;0 表示永不自动清理。进行中的任务不会被清理。",
|
||||
)
|
||||
db_backup_enabled = forms.BooleanField(
|
||||
label="启用数据库定时备份(每天自动备份一次)",
|
||||
required=False,
|
||||
initial=True,
|
||||
)
|
||||
db_backup_keep = forms.IntegerField(
|
||||
label="数据库备份保留份数",
|
||||
min_value=1,
|
||||
max_value=100,
|
||||
initial=7,
|
||||
help_text="超出份数的最旧备份会被自动删除。备份文件保存在持久化数据卷中,可在下方列表下载。",
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
apply_control_classes(self)
|
||||
|
||||
|
||||
class AdminUserCreateForm(forms.Form):
|
||||
username = forms.CharField(label="用户名", max_length=150, required=True)
|
||||
email = forms.EmailField(label="邮箱", required=False)
|
||||
password = forms.CharField(label="登录密码", widget=forms.PasswordInput, required=True, min_length=8,
|
||||
help_text="至少 8 位。")
|
||||
password_confirm = forms.CharField(label="确认密码", widget=forms.PasswordInput, required=True)
|
||||
is_staff = forms.BooleanField(label="设为管理员(可访问后台管理、查看所有任务)", required=False)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
apply_control_classes(self)
|
||||
|
||||
def clean_username(self):
|
||||
username = self.cleaned_data['username'].strip()
|
||||
if User.objects.filter(username=username).exists():
|
||||
raise forms.ValidationError("该用户名已存在。")
|
||||
return username
|
||||
|
||||
def clean(self):
|
||||
cleaned = super().clean()
|
||||
if cleaned.get('password') and cleaned.get('password') != cleaned.get('password_confirm'):
|
||||
self.add_error('password_confirm', "两次输入的密码不一致。")
|
||||
return cleaned
|
||||
|
||||
|
||||
class AdminUserEditForm(forms.Form):
|
||||
email = forms.EmailField(label="邮箱", required=False)
|
||||
is_active = forms.BooleanField(label="账号启用", required=False)
|
||||
is_staff = forms.BooleanField(label="管理员权限(可访问后台管理)", required=False)
|
||||
new_password = forms.CharField(
|
||||
label="重置密码(留空表示不修改)",
|
||||
widget=forms.PasswordInput,
|
||||
required=False,
|
||||
min_length=8,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
apply_control_classes(self)
|
||||
@@ -0,0 +1,74 @@
|
||||
"""
|
||||
代码托管平台 API 的统一网络层(GitHub / Gitea)。
|
||||
|
||||
- 所有出站请求集中在此,按 flavor 注入对应平台的推荐请求头与鉴权方式;
|
||||
- 代理支持显式传入(GitHub 用 GH_PROXY、Gitea 用 GITEA_PROXY);
|
||||
- 未显式配置代理时 requests 仍会自动遵循容器的 HTTPS_PROXY/HTTP_PROXY 环境变量。
|
||||
"""
|
||||
import requests
|
||||
|
||||
from . import app_settings
|
||||
|
||||
GITHUB_API = 'https://api.github.com'
|
||||
API_VERSION = '2026-03-10'
|
||||
DEFAULT_TIMEOUT = 15
|
||||
|
||||
|
||||
def proxies(proxy=None):
|
||||
"""返回 requests 使用的 proxies 字典。
|
||||
|
||||
proxy=None 时沿用 GitHub 的 GH_PROXY(历史默认);
|
||||
显式传 '' 表示直连(环境变量仍生效),传具体地址则使用该代理。
|
||||
"""
|
||||
if proxy is None:
|
||||
proxy = app_settings.get_value('GH_PROXY').strip()
|
||||
if proxy:
|
||||
return {'http': proxy, 'https': proxy}
|
||||
return {}
|
||||
|
||||
|
||||
def build_headers(token=None, json_content=False, flavor='github'):
|
||||
"""按平台风格构建请求头。
|
||||
|
||||
- github:application/vnd.github+json + 版本头 + Bearer 鉴权;
|
||||
- gitea:application/json + Gitea 官方文档主推的 token 鉴权方案。
|
||||
"""
|
||||
flavor = (flavor or 'github').lower()
|
||||
if flavor == 'gitea':
|
||||
headers = {'Accept': 'application/json'}
|
||||
if token:
|
||||
headers['Authorization'] = f'token {token}'
|
||||
else:
|
||||
headers = {
|
||||
'Accept': 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': API_VERSION,
|
||||
}
|
||||
if token:
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
if json_content:
|
||||
headers['Content-Type'] = 'application/json'
|
||||
return headers
|
||||
|
||||
|
||||
def request(method, url, *, token=None, timeout=DEFAULT_TIMEOUT, headers=None,
|
||||
flavor='github', proxy=None, **kwargs):
|
||||
"""发起平台 API 请求,自动注入代理、超时与标准请求头。"""
|
||||
kwargs.setdefault('proxies', proxies(proxy))
|
||||
kwargs.setdefault('timeout', timeout)
|
||||
merged_headers = build_headers(
|
||||
token=token,
|
||||
json_content=kwargs.get('json') is not None,
|
||||
flavor=flavor,
|
||||
)
|
||||
if headers:
|
||||
merged_headers.update(headers)
|
||||
kwargs['headers'] = merged_headers
|
||||
return requests.request(method, url, **kwargs)
|
||||
|
||||
|
||||
def get(url, **kwargs):
|
||||
return request('GET', url, **kwargs)
|
||||
|
||||
|
||||
def post(url, **kwargs):
|
||||
return request('POST', url, **kwargs)
|
||||
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
定时维护:清理过期构建任务(含本地产物)、清理残留临时文件、在线备份 SQLite。
|
||||
|
||||
被 management 命令(容器内常驻守护进程)与后台「维护设置」页面共用:
|
||||
run_maintenance(force_backup=False) -> dict # 执行一轮
|
||||
maintenance_status() -> dict # 页面展示的状态摘要
|
||||
所有设置经 app_settings 统一读取(后台配置 → 环境变量 → 默认值)。
|
||||
"""
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from . import app_settings
|
||||
from .models import GithubRun
|
||||
|
||||
# 与 GithubRun.is_finished 保持一致的终态集合
|
||||
FINISHED_STATUSES = ('success', 'failure', 'cancelled', 'timed_out', 'skipped')
|
||||
|
||||
# 临时配置包/JSON 的最长保留时间(天),失败任务遗留时兜底清理
|
||||
TEMP_FILE_DAYS = 7
|
||||
|
||||
BACKUP_NAME_PREFIX = 'db-'
|
||||
BACKUP_NAME_SUFFIX = '.sqlite3'
|
||||
# 自动备份最小间隔(秒):守护进程每小时醒一次,但一天只备份一次
|
||||
BACKUP_INTERVAL = 24 * 3600
|
||||
|
||||
|
||||
def _setting_int(key, default):
|
||||
try:
|
||||
return int(str(app_settings.get_value(key)).strip())
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _setting_bool(key, default=True):
|
||||
return str(app_settings.get_value(key)).strip().lower() in ('1', 'true', 'yes', 'on', '是')
|
||||
|
||||
|
||||
def retention_days():
|
||||
"""构建任务保留天数;0 表示永不自动清理。"""
|
||||
return max(0, _setting_int('BUILD_RETENTION_DAYS', 30))
|
||||
|
||||
|
||||
def backup_enabled():
|
||||
return _setting_bool('DB_BACKUP_ENABLED', True)
|
||||
|
||||
|
||||
def backup_keep():
|
||||
return max(1, min(100, _setting_int('DB_BACKUP_KEEP', 7)))
|
||||
|
||||
|
||||
def db_path():
|
||||
return Path(settings.DATABASES['default']['NAME'])
|
||||
|
||||
|
||||
def backup_dir():
|
||||
d = db_path().parent / 'backups'
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
|
||||
def list_backups():
|
||||
"""返回 [(Path, 大小字节, mtime), ...],按时间倒序。"""
|
||||
items = []
|
||||
for p in backup_dir().glob(f'{BACKUP_NAME_PREFIX}*{BACKUP_NAME_SUFFIX}'):
|
||||
if p.is_file():
|
||||
st = p.stat()
|
||||
items.append((p, st.st_size, st.st_mtime))
|
||||
items.sort(key=lambda x: x[2], reverse=True)
|
||||
return items
|
||||
|
||||
|
||||
def cleanup_expired_builds(days=None):
|
||||
"""删除超过保留天数的终态任务,并同步清理 exe/png 产物。返回删除条数。"""
|
||||
if days is None:
|
||||
days = retention_days()
|
||||
if days <= 0:
|
||||
return 0
|
||||
|
||||
cutoff = timezone.now() - timedelta(days=days)
|
||||
qs = GithubRun.objects.filter(created_at__lt=cutoff, status__in=FINISHED_STATUSES)
|
||||
count = 0
|
||||
for run in qs.iterator():
|
||||
if run.uuid:
|
||||
shutil.rmtree(Path('exe') / run.uuid, ignore_errors=True)
|
||||
shutil.rmtree(Path('png') / run.uuid, ignore_errors=True)
|
||||
run.delete()
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
def cleanup_temp_files(days=TEMP_FILE_DAYS):
|
||||
"""清理失败任务遗留的加密配置包与临时 JSON。返回删除文件数。"""
|
||||
cutoff = time.time() - days * 86400
|
||||
count = 0
|
||||
targets = []
|
||||
zip_dir = Path('temp_zips')
|
||||
if zip_dir.is_dir():
|
||||
targets += list(zip_dir.glob('secrets_*.zip'))
|
||||
# 早期版本 data_*.json 写在工作目录根下
|
||||
targets += list(Path('.').glob('data_*.json'))
|
||||
for p in targets:
|
||||
try:
|
||||
if p.is_file() and p.stat().st_mtime < cutoff:
|
||||
p.unlink()
|
||||
count += 1
|
||||
except OSError:
|
||||
pass
|
||||
return count
|
||||
|
||||
|
||||
def backup_database():
|
||||
"""用 sqlite3 在线备份 API 生成一致性副本,然后按保留份数轮转。
|
||||
|
||||
返回备份文件 Path;非 SQLite 数据库或源文件不存在时返回 None。
|
||||
"""
|
||||
src = db_path()
|
||||
if not src.is_file() or settings.DATABASES['default']['ENGINE'] != 'django.db.backends.sqlite3':
|
||||
return None
|
||||
|
||||
dest = backup_dir() / f"{BACKUP_NAME_PREFIX}{time.strftime('%Y%m%d-%H%M%S')}{BACKUP_NAME_SUFFIX}"
|
||||
src_conn = sqlite3.connect(str(src))
|
||||
try:
|
||||
dst_conn = sqlite3.connect(str(dest))
|
||||
try:
|
||||
src_conn.backup(dst_conn)
|
||||
finally:
|
||||
dst_conn.close()
|
||||
finally:
|
||||
src_conn.close()
|
||||
|
||||
rotate_backups()
|
||||
return dest
|
||||
|
||||
|
||||
def rotate_backups():
|
||||
"""按设置的保留份数删除最旧备份(每轮维护都执行,含容器启动前的预迁移备份)。"""
|
||||
deleted = 0
|
||||
for old_path, _size, _mtime in list_backups()[backup_keep():]:
|
||||
try:
|
||||
old_path.unlink()
|
||||
deleted += 1
|
||||
except OSError:
|
||||
pass
|
||||
return deleted
|
||||
|
||||
|
||||
def _latest_backup_age():
|
||||
backups = list_backups()
|
||||
if not backups:
|
||||
return None
|
||||
return time.time() - backups[0][2]
|
||||
|
||||
|
||||
def run_maintenance(force_backup=False):
|
||||
"""执行一轮维护。返回各项动作的计数/结果(供命令行与页面反馈)。"""
|
||||
result = {
|
||||
'deleted_builds': cleanup_expired_builds(),
|
||||
'deleted_temp_files': cleanup_temp_files(),
|
||||
'rotated_backups': rotate_backups(),
|
||||
'backup_created': None,
|
||||
'backup_skipped': False,
|
||||
}
|
||||
if backup_enabled() and (force_backup or (_latest_backup_age() or BACKUP_INTERVAL) >= BACKUP_INTERVAL):
|
||||
dest = backup_database()
|
||||
result['backup_created'] = str(dest) if dest else None
|
||||
if dest is None:
|
||||
result['backup_skipped'] = True
|
||||
else:
|
||||
result['backup_skipped'] = True
|
||||
return result
|
||||
|
||||
|
||||
def _dir_size(path):
|
||||
total = 0
|
||||
base = Path(path)
|
||||
if not base.is_dir():
|
||||
return 0
|
||||
for root, _dirs, files in os.walk(base):
|
||||
for name in files:
|
||||
try:
|
||||
total += (Path(root) / name).stat().st_size
|
||||
except OSError:
|
||||
pass
|
||||
return total
|
||||
|
||||
|
||||
def maintenance_status():
|
||||
"""后台维护页展示用摘要。"""
|
||||
days = retention_days()
|
||||
total = GithubRun.objects.count()
|
||||
in_progress = GithubRun.objects.exclude(status__in=FINISHED_STATUSES).count()
|
||||
expired = 0
|
||||
if days > 0:
|
||||
cutoff = timezone.now() - timedelta(days=days)
|
||||
expired = GithubRun.objects.filter(
|
||||
created_at__lt=cutoff, status__in=FINISHED_STATUSES
|
||||
).count()
|
||||
|
||||
backups = [
|
||||
{
|
||||
'name': p.name,
|
||||
'size': size,
|
||||
'mtime': datetime.fromtimestamp(mtime, tz=timezone.get_current_timezone()),
|
||||
}
|
||||
for p, size, mtime in list_backups()
|
||||
]
|
||||
return {
|
||||
'retention_days': days,
|
||||
'backup_enabled': backup_enabled(),
|
||||
'backup_keep': backup_keep(),
|
||||
'db_path': str(db_path()),
|
||||
'db_size': db_path().stat().st_size if db_path().is_file() else 0,
|
||||
'total_builds': total,
|
||||
'in_progress_builds': in_progress,
|
||||
'expired_builds': expired,
|
||||
'artifacts_size': _dir_size('exe') + _dir_size('png'),
|
||||
'temp_size': _dir_size('temp_zips'),
|
||||
'backups': backups,
|
||||
'backup_interval_hours': BACKUP_INTERVAL // 3600,
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"""定时维护命令。
|
||||
|
||||
用法:
|
||||
python manage.py maintenance # 同 --once
|
||||
python manage.py maintenance --once # 执行一轮(清理过期任务/临时文件、按需备份)
|
||||
python manage.py maintenance --loop # 常驻:启动即一轮,之后每小时醒来检查
|
||||
"""
|
||||
import time
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from rdgenerator import maintenance
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = '清理过期构建任务与临时文件,并按设置定期备份 SQLite 数据库。'
|
||||
|
||||
LOOP_INTERVAL = 3600 # 每小时醒来一次(备份内部按 24h 间隔节流)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--loop', action='store_true',
|
||||
help='常驻模式:容器启动后在后台持续运行。',
|
||||
)
|
||||
parser.add_argument(
|
||||
'--once', action='store_true',
|
||||
help='只执行一轮后退出(默认行为,也供后台「立即执行」使用)。',
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
if options['loop']:
|
||||
self.stdout.write('[maintenance] 守护进程已启动,每小时检查一次。')
|
||||
while True:
|
||||
self._run_once()
|
||||
time.sleep(self.LOOP_INTERVAL)
|
||||
else:
|
||||
self._run_once()
|
||||
|
||||
def _run_once(self):
|
||||
result = maintenance.run_maintenance()
|
||||
self.stdout.write(
|
||||
'[maintenance] 清理过期任务 {deleted_builds} 条、临时文件 {deleted_temp_files} 个、'
|
||||
'轮转旧备份 {rotated_backups} 份;备份:{backup}'.format(
|
||||
deleted_builds=result['deleted_builds'],
|
||||
deleted_temp_files=result['deleted_temp_files'],
|
||||
rotated_backups=result['rotated_backups'],
|
||||
backup=('已创建 ' + result['backup_created']) if result['backup_created']
|
||||
else ('本轮跳过' if result['backup_skipped'] else '未创建'),
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.0.3 on 2024-09-26 14:48
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='GithubRun',
|
||||
fields=[
|
||||
('id', models.IntegerField(primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('uuid', models.CharField(max_length=100, verbose_name='uuid')),
|
||||
('status', models.CharField(max_length=100, verbose_name='status')),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.0.3 on 2026-03-11 04:58
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rdgenerator', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='githubrun',
|
||||
name='github_run_id',
|
||||
field=models.BigIntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,106 @@
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
import rdgenerator.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("rdgenerator", "0002_githubrun_github_run_id"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="githubrun",
|
||||
name="id",
|
||||
field=models.BigAutoField(primary_key=True, serialize=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="githubrun",
|
||||
name="uuid",
|
||||
field=models.CharField(db_index=True, max_length=100, verbose_name="任务 UUID"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="githubrun",
|
||||
name="status",
|
||||
field=models.CharField(default="queued", max_length=100, verbose_name="构建状态"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="githubrun",
|
||||
name="platform",
|
||||
field=models.CharField(blank=True, default="", max_length=20, verbose_name="目标平台"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="githubrun",
|
||||
name="filename",
|
||||
field=models.CharField(blank=True, default="", max_length=200, verbose_name="文件名"),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="githubrun",
|
||||
name="created_by",
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="builds",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="提交用户",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="githubrun",
|
||||
name="created_at",
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name="提交时间", null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="githubrun",
|
||||
name="updated_at",
|
||||
field=models.DateTimeField(auto_now=True, verbose_name="更新时间", null=True),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="githubrun",
|
||||
name="created_at",
|
||||
field=models.DateTimeField(auto_now_add=True, verbose_name="提交时间"),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="githubrun",
|
||||
name="updated_at",
|
||||
field=models.DateTimeField(auto_now=True, verbose_name="更新时间"),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ApiToken",
|
||||
fields=[
|
||||
(
|
||||
"key",
|
||||
models.CharField(
|
||||
default=rdgenerator.models.generate_token_key,
|
||||
editable=False,
|
||||
max_length=64,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="令牌",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(help_text="便于识别用途,例如:CI 构建机", max_length=100, verbose_name="令牌名称")),
|
||||
("is_active", models.BooleanField(default=True, verbose_name="启用")),
|
||||
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="创建时间")),
|
||||
("last_used_at", models.DateTimeField(blank=True, null=True, verbose_name="最后使用时间")),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=models.CASCADE,
|
||||
related_name="api_tokens",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="所属用户",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "API 令牌",
|
||||
"verbose_name_plural": "API 令牌",
|
||||
"ordering": ["-created_at"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rdgenerator', '0003_build_management'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SiteSetting',
|
||||
fields=[
|
||||
('key', models.CharField(max_length=50, primary_key=True, serialize=False, verbose_name='配置项')),
|
||||
('value', models.TextField(blank=True, default='', verbose_name='配置值')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '站点配置',
|
||||
'verbose_name_plural': '站点配置',
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,39 @@
|
||||
# Generated for SavedConfig
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
('rdgenerator', '0004_sitesetting'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SavedConfig',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, verbose_name='方案名称')),
|
||||
('data', models.JSONField(default=dict, verbose_name='配置内容')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='更新时间')),
|
||||
('owner', models.ForeignKey(
|
||||
on_delete=models.CASCADE,
|
||||
related_name='saved_configs',
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name='所属用户',
|
||||
)),
|
||||
],
|
||||
options={
|
||||
'verbose_name': '构建配置方案',
|
||||
'verbose_name_plural': '构建配置方案',
|
||||
'ordering': ['-updated_at'],
|
||||
},
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='savedconfig',
|
||||
unique_together={('owner', 'name')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rdgenerator', '0005_savedconfig'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='githubrun',
|
||||
name='config_data',
|
||||
field=models.JSONField(blank=True, default=dict, verbose_name='构建配置快照'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-29 02:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rdgenerator', '0006_githubrun_config_data'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='githubrun',
|
||||
options={'ordering': ['-created_at'], 'verbose_name': '构建任务', 'verbose_name_plural': '构建任务'},
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='githubrun',
|
||||
name='github_run_id',
|
||||
field=models.BigIntegerField(blank=True, null=True, verbose_name='GitHub 运行编号'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='githubrun',
|
||||
name='id',
|
||||
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated for 多构建平台支持(GitHub / Gitea)
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('rdgenerator', '0007_alter_githubrun_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='githubrun',
|
||||
name='backend',
|
||||
field=models.CharField(default='github', max_length=20, verbose_name='构建平台'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,171 @@
|
||||
import secrets
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
|
||||
# GitHub Actions 运行状态 -> 中文展示文案
|
||||
STATUS_LABELS = {
|
||||
"Starting generator...please wait": "正在启动构建任务",
|
||||
"queued": "排队中",
|
||||
"in_progress": "构建中",
|
||||
"success": "构建成功",
|
||||
"failure": "构建失败",
|
||||
"cancelled": "已取消",
|
||||
"timed_out": "已超时",
|
||||
"skipped": "已跳过",
|
||||
"action_required": "等待人工处理",
|
||||
}
|
||||
|
||||
# 状态对应的徽标样式(模板中使用)
|
||||
STATUS_BADGE = {
|
||||
"Starting generator...please wait": "badge-info",
|
||||
"queued": "badge-info",
|
||||
"in_progress": "badge-info",
|
||||
"success": "badge-success",
|
||||
"failure": "badge-danger",
|
||||
"cancelled": "badge-warning",
|
||||
"timed_out": "badge-warning",
|
||||
"skipped": "badge-secondary",
|
||||
"action_required": "badge-warning",
|
||||
}
|
||||
|
||||
PLATFORM_LABELS = {
|
||||
"windows": "Windows 64 位",
|
||||
"windows-x86": "Windows 32 位",
|
||||
"linux": "Linux",
|
||||
"android": "Android",
|
||||
"macos": "macOS",
|
||||
}
|
||||
|
||||
|
||||
def generate_token_key():
|
||||
return secrets.token_hex(20)
|
||||
|
||||
|
||||
class GithubRun(models.Model):
|
||||
uuid = models.CharField("任务 UUID", max_length=100, db_index=True)
|
||||
status = models.CharField("构建状态", max_length=100, default="queued")
|
||||
github_run_id = models.BigIntegerField("GitHub 运行编号", null=True, blank=True)
|
||||
# 构建平台:github / gitea;历史任务默认 github。切换平台后旧任务仍按此值查询状态
|
||||
backend = models.CharField("构建平台", max_length=20, default="github")
|
||||
platform = models.CharField("目标平台", max_length=20, blank=True, default="")
|
||||
filename = models.CharField("文件名", max_length=200, blank=True, default="")
|
||||
# 提交时的表单快照(纯字符串/数值,图片为 data URL),供失败后「一键重试」重新派发
|
||||
config_data = models.JSONField("构建配置快照", blank=True, default=dict)
|
||||
created_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
verbose_name="提交用户",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="builds",
|
||||
)
|
||||
created_at = models.DateTimeField("提交时间", auto_now_add=True)
|
||||
updated_at = models.DateTimeField("更新时间", auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "构建任务"
|
||||
verbose_name_plural = "构建任务"
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.filename or self.uuid}({self.status_label()})"
|
||||
|
||||
def status_label(self):
|
||||
return STATUS_LABELS.get(self.status, self.status)
|
||||
|
||||
def status_badge(self):
|
||||
return STATUS_BADGE.get(self.status, "badge-secondary")
|
||||
|
||||
def platform_label(self):
|
||||
return PLATFORM_LABELS.get(self.platform, self.platform or "未知平台")
|
||||
|
||||
def is_finished(self):
|
||||
return self.status in ("success", "failure", "cancelled", "timed_out", "skipped")
|
||||
|
||||
@property
|
||||
def github_log_url(self):
|
||||
if self.github_run_id:
|
||||
# 局部导入避免循环依赖;按任务记录的平台生成对应站点的日志链接
|
||||
from .build_backends import get_backend
|
||||
return get_backend(self.backend or 'github').log_url(self.github_run_id)
|
||||
return ""
|
||||
|
||||
def short_uuid(self):
|
||||
return self.uuid[:8] if len(self.uuid) >= 8 else self.uuid
|
||||
|
||||
|
||||
class SiteSetting(models.Model):
|
||||
"""后台可改的站点配置(键值存储)。
|
||||
|
||||
取值优先级由 app_settings 统一处理:后台配置(本表)→ 环境变量 → 默认值,
|
||||
因此历史的环境变量部署方式继续有效。
|
||||
"""
|
||||
|
||||
key = models.CharField("配置项", max_length=50, primary_key=True)
|
||||
value = models.TextField("配置值", blank=True, default='')
|
||||
updated_at = models.DateTimeField("更新时间", auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "站点配置"
|
||||
verbose_name_plural = "站点配置"
|
||||
|
||||
def __str__(self):
|
||||
return self.key
|
||||
|
||||
|
||||
class SavedConfig(models.Model):
|
||||
"""用户保存的客户端构建配置方案(与账号绑定,可在定制页直接载入、可导出/导入)。"""
|
||||
|
||||
owner = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
verbose_name="所属用户",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="saved_configs",
|
||||
)
|
||||
name = models.CharField("方案名称", max_length=100)
|
||||
data = models.JSONField("配置内容", default=dict)
|
||||
created_at = models.DateTimeField("创建时间", auto_now_add=True)
|
||||
updated_at = models.DateTimeField("更新时间", auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "构建配置方案"
|
||||
verbose_name_plural = "构建配置方案"
|
||||
unique_together = [('owner', 'name')]
|
||||
ordering = ["-updated_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name}({self.owner})"
|
||||
|
||||
def platform_label(self):
|
||||
return PLATFORM_LABELS.get(self.data.get('platform', ''), '未指定平台')
|
||||
|
||||
@property
|
||||
def version_label(self):
|
||||
return self.data.get('version', '') or '未指定版本'
|
||||
|
||||
|
||||
class ApiToken(models.Model):
|
||||
"""供 JSON API(如 rdgen-cli)使用的个人访问令牌"""
|
||||
|
||||
key = models.CharField("令牌", max_length=64, primary_key=True, default=generate_token_key, editable=False)
|
||||
name = models.CharField("令牌名称", max_length=100, help_text="便于识别用途,例如:CI 构建机")
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
verbose_name="所属用户",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="api_tokens",
|
||||
)
|
||||
is_active = models.BooleanField("启用", default=True)
|
||||
created_at = models.DateTimeField("创建时间", auto_now_add=True)
|
||||
last_used_at = models.DateTimeField("最后使用时间", null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "API 令牌"
|
||||
verbose_name_plural = "API 令牌"
|
||||
ordering = ["-created_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name}({self.user})"
|
||||
@@ -0,0 +1,851 @@
|
||||
/* ==========================================================================
|
||||
RustDesk 客户端定制平台 · 全局设计系统
|
||||
设计原则:扁平化、留白充足、控件统一;所有页面共用同一套组件类。
|
||||
========================================================================== */
|
||||
:root {
|
||||
--primary: #2563eb;
|
||||
--primary-dark: #1d4ed8;
|
||||
--primary-light: #eff4ff;
|
||||
--primary-border: #9db8f5;
|
||||
--success: #16a34a;
|
||||
--success-bg: #dcfce7;
|
||||
--warning: #b45309;
|
||||
--warning-bg: #fef3c7;
|
||||
--danger: #dc2626;
|
||||
--danger-bg: #fee2e2;
|
||||
--info: #0e7490;
|
||||
--info-bg: #ecfeff;
|
||||
--text: #1f2937;
|
||||
--text-soft: #374151;
|
||||
--text-muted: #6b7280;
|
||||
--border: #e5e7eb;
|
||||
--control-border: #d1d5db;
|
||||
--bg: #f5f7fa;
|
||||
--card: #ffffff;
|
||||
--radius: 10px;
|
||||
--radius-sm: 8px;
|
||||
--shadow: 0 1px 2px rgba(16, 24, 40, .05);
|
||||
--shadow-lg: 0 16px 40px rgba(16, 24, 40, .14);
|
||||
/* 统一节奏 */
|
||||
--control-h: 38px;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a { color: var(--primary); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* ===== 内联 SVG 图标(统一线性/扁平单色) ===== */
|
||||
.svg-sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
|
||||
.icon {
|
||||
width: 1em; height: 1em;
|
||||
flex: none;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-width: 1.8;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
vertical-align: -0.12em;
|
||||
}
|
||||
.brand-icon {
|
||||
width: 1em; height: 1em;
|
||||
flex: none;
|
||||
fill: currentColor;
|
||||
stroke: none;
|
||||
}
|
||||
.page-head .icon { font-size: 15px; }
|
||||
|
||||
/* ===== 顶部导航 ===== */
|
||||
.topbar {
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
.topbar-inner {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 24px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
}
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 700;
|
||||
font-size: 16px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.brand:hover { text-decoration: none; }
|
||||
.brand-logo {
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 8px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
letter-spacing: .5px;
|
||||
}
|
||||
.topbar nav { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||||
.nav-link {
|
||||
padding: 7px 13px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.nav-link:hover { background: var(--primary-light); color: var(--primary); text-decoration: none; }
|
||||
.nav-link.active { background: var(--primary-light); color: var(--primary); }
|
||||
.nav-user { color: var(--text-muted); font-size: 13px; padding: 0 8px; white-space: nowrap; }
|
||||
.btn-logout {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 6px 13px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-logout:hover { border-color: var(--danger); color: var(--danger); }
|
||||
|
||||
/* ===== 页面容器与标题 ===== */
|
||||
.page {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 24px 56px;
|
||||
}
|
||||
.page-narrow { max-width: 860px; }
|
||||
.page-head { margin-bottom: 22px; }
|
||||
.page-title { font-size: 22px; font-weight: 700; margin: 0 0 6px; }
|
||||
.page-subtitle { color: var(--text-muted); margin: 0; font-size: 13.5px; line-height: 1.7; }
|
||||
.breadcrumb { font-size: 13px; margin-bottom: 14px; color: var(--text-muted); }
|
||||
.breadcrumb a { color: var(--text-muted); font-weight: 500; }
|
||||
.breadcrumb a:hover { color: var(--primary); }
|
||||
.breadcrumb .sep { margin: 0 8px; color: #c0c6d0; }
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: #9aa3b2;
|
||||
font-size: 12.5px;
|
||||
padding: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--card);
|
||||
}
|
||||
|
||||
/* ===== 卡片 ===== */
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.card:last-child { margin-bottom: 0; }
|
||||
.card-header {
|
||||
padding: 16px 24px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
}
|
||||
.card-header .icon { color: #64748b; font-size: 17px; }
|
||||
.card-body { padding: 22px 24px; }
|
||||
.card-body.flush { padding: 0; }
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
.grid-2 .card { margin-bottom: 0; }
|
||||
.form-narrow { max-width: 540px; }
|
||||
@media (max-width: 920px) { .grid-2 { grid-template-columns: 1fr; } }
|
||||
|
||||
/* ===== 表单 ===== */
|
||||
.field { margin-bottom: 18px; }
|
||||
.field:last-child { margin-bottom: 0; }
|
||||
/* 与左侧「label + 输入控件」并排的独立勾选项:下移到与控件(非 label)同一水平线 */
|
||||
.field-check-control { margin-bottom: 0; }
|
||||
.field-check-control .check-item { margin-top: 33px; }
|
||||
@media (max-width: 920px) { .field-check-control .check-item { margin-top: 4px; } }
|
||||
.field > label, .field-label {
|
||||
display: block;
|
||||
font-weight: 600;
|
||||
font-size: 13.5px;
|
||||
margin-bottom: 7px;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
.form-control {
|
||||
width: 100%;
|
||||
min-height: var(--control-h);
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
background: #fff;
|
||||
transition: border-color .15s, box-shadow .15s;
|
||||
font-family: inherit;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, .12);
|
||||
}
|
||||
.form-control::placeholder { color: #9ca3af; }
|
||||
select.form-control {
|
||||
cursor: pointer;
|
||||
padding-right: 34px;
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%236b7280' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 11px center;
|
||||
background-size: 16px;
|
||||
}
|
||||
textarea.form-control {
|
||||
resize: vertical;
|
||||
min-height: 96px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
/* 文件选择框:浅灰底 + 统一内嵌按钮 */
|
||||
input[type="file"].form-control {
|
||||
min-height: 0;
|
||||
padding: 7px 10px;
|
||||
background: #fafbfd;
|
||||
cursor: pointer;
|
||||
}
|
||||
input[type="file"].form-control::file-selector-button {
|
||||
margin-right: 12px;
|
||||
padding: 5px 13px;
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: var(--text-soft);
|
||||
font: 600 12.5px inherit;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, color .15s;
|
||||
}
|
||||
input[type="file"].form-control:hover::file-selector-button {
|
||||
border-color: var(--primary-border);
|
||||
color: var(--primary);
|
||||
}
|
||||
input[type="file"].form-control::-webkit-file-upload-button {
|
||||
margin-right: 12px;
|
||||
padding: 5px 13px;
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: var(--text-soft);
|
||||
font: 600 12.5px inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.field-hint { color: var(--text-muted); font-size: 12.5px; margin-top: 7px; line-height: 1.6; }
|
||||
.help-text {
|
||||
display: block;
|
||||
background: var(--info-bg);
|
||||
color: var(--info);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin-top: 8px;
|
||||
}
|
||||
.inline-warning {
|
||||
display: none;
|
||||
color: var(--warning);
|
||||
background: var(--warning-bg);
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.inline-error { color: var(--danger); font-size: 12.5px; margin-left: 8px; }
|
||||
.errorlist { list-style: none; padding: 0; margin: 7px 0 0; color: var(--danger); font-size: 12.5px; }
|
||||
.errorlist li { margin-top: 3px; }
|
||||
|
||||
/* 输入框 + 按钮同一行(如令牌创建) */
|
||||
.input-row { display: flex; gap: 10px; align-items: center; }
|
||||
.input-row .form-control { flex: 1 1 auto; min-width: 0; width: auto; }
|
||||
|
||||
/* 复选 / 单选 */
|
||||
.form-check-input { width: 16px; height: 16px; accent-color: var(--primary); cursor: pointer; margin: 0; flex: none; }
|
||||
.check-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
padding: 4px 0;
|
||||
color: var(--text-soft);
|
||||
}
|
||||
.check-item:hover { color: var(--text); }
|
||||
.check-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(195px, 1fr));
|
||||
gap: 4px 24px;
|
||||
margin: 10px 0 4px;
|
||||
}
|
||||
.radio-group ul { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; }
|
||||
.radio-group li { display: flex; align-items: center; gap: 9px; padding: 2px 0; }
|
||||
.radio-group li label { font-weight: 400; margin: 0; font-size: 13.5px; cursor: pointer; }
|
||||
.subsection-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-soft);
|
||||
margin: 22px 0 10px;
|
||||
}
|
||||
.subsection-title:first-child { margin-top: 0; }
|
||||
.subsection-title .icon { color: #64748b; font-size: 15px; }
|
||||
.subsection-title::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* ===== 按钮 ===== */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 7px;
|
||||
min-height: var(--control-h);
|
||||
padding: 0 18px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid transparent;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background .15s, border-color .15s, color .15s, box-shadow .15s;
|
||||
text-decoration: none !important;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.btn .icon { font-size: 15px; }
|
||||
.btn-primary { background: var(--primary); color: #fff; }
|
||||
.btn-primary:hover { background: var(--primary-dark); }
|
||||
.btn-secondary { background: #fff; color: var(--text-soft); border-color: var(--control-border); }
|
||||
.btn-secondary:hover { border-color: var(--primary-border); color: var(--primary); }
|
||||
.btn-danger { background: #fff; color: var(--danger); border-color: #f3c2c2; }
|
||||
.btn-danger:hover { background: var(--danger); color: #fff; border-color: var(--danger); }
|
||||
.btn-sm { min-height: 30px; padding: 0 12px; font-size: 12.5px; border-radius: 6px; }
|
||||
.btn-sm .icon { font-size: 13px; }
|
||||
.btn-block { width: 100%; }
|
||||
.btn:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.btn-row { display: flex; gap: 10px; flex-wrap: wrap; }
|
||||
.inline-form { display: inline-flex; vertical-align: middle; }
|
||||
.table .btn + .btn, .table .btn + .inline-form { margin-left: 8px; }
|
||||
|
||||
/* ===== 平台选择卡 ===== */
|
||||
.platform-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 14px;
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
.platform-card {
|
||||
border: 1.5px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: #fff;
|
||||
padding: 18px 8px 16px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: border-color .15s, background .15s, transform .1s;
|
||||
user-select: none;
|
||||
}
|
||||
.platform-card:hover { border-color: var(--primary-border); }
|
||||
.platform-card.active { border-color: var(--primary); background: var(--primary-light); }
|
||||
.platform-card:active { transform: scale(.98); }
|
||||
.platform-card .brand-icon { font-size: 30px; color: #6b728b; transition: color .15s; }
|
||||
.platform-card.active .brand-icon { color: var(--primary); }
|
||||
.platform-name { font-weight: 600; font-size: 13.5px; margin-top: 9px; color: var(--text); }
|
||||
.platform-tag { color: var(--text-muted); font-size: 12px; margin-top: 2px; }
|
||||
@media (max-width: 920px) { .platform-grid { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 560px) { .platform-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
/* ===== 后台构建平台选择卡(GitHub / Gitea 二选一) ===== */
|
||||
.build-platform-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 14px;
|
||||
margin: 4px 0 8px;
|
||||
}
|
||||
.build-platform-grid .platform-card { padding: 20px 12px 18px; }
|
||||
.build-platform-grid .platform-card .icon { font-size: 28px; }
|
||||
.platform-desc { color: var(--text-muted); font-size: 12.5px; margin-top: 5px; line-height: 1.55; }
|
||||
.panel-hidden { display: none !important; }
|
||||
.settings-submit-card .settings-actions { margin-top: 0; padding: 22px 24px; border-top: none; }
|
||||
@media (max-width: 560px) { .build-platform-grid { grid-template-columns: 1fr; } }
|
||||
.visually-hidden {
|
||||
position: absolute !important;
|
||||
width: 1px; height: 1px;
|
||||
padding: 0; margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ===== 图片预览 ===== */
|
||||
.image-preview { margin-top: 9px; }
|
||||
.image-preview img {
|
||||
max-width: 100%;
|
||||
max-height: 64px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 5px;
|
||||
background: #fff;
|
||||
}
|
||||
.image-preview.photo img { max-height: 120px; }
|
||||
|
||||
/* ===== 徽标 ===== */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge-success { background: var(--success-bg); color: #15803d; }
|
||||
.badge-info { background: var(--info-bg); color: var(--info); }
|
||||
.badge-warning { background: var(--warning-bg); color: var(--warning); }
|
||||
.badge-danger { background: var(--danger-bg); color: #b91c1c; }
|
||||
.badge-secondary { background: #f1f3f7; color: var(--text-muted); }
|
||||
.badge-dot::before {
|
||||
content: "";
|
||||
display: inline-block;
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.badge-info.badge-dot::before { animation: pulse 1.2s infinite; }
|
||||
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .25; } }
|
||||
|
||||
/* ===== 提示消息 ===== */
|
||||
.alerts { margin-bottom: 20px; display: grid; gap: 10px; }
|
||||
.alert {
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.alert-success { background: var(--success-bg); border-color: #bbf7d0; color: #15803d; }
|
||||
.alert-error, .alert-danger { background: var(--danger-bg); border-color: #fecaca; color: #b91c1c; }
|
||||
.alert-warning { background: var(--warning-bg); border-color: #fde68a; color: var(--warning); }
|
||||
.alert-info { background: var(--info-bg); border-color: #a5f3fc; color: var(--info); }
|
||||
|
||||
/* ===== 表格 ===== */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table.table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
||||
.table th, .table td {
|
||||
padding: 13px 16px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.table th {
|
||||
background: #f9fafb;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 12.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.table tr:last-child td { border-bottom: none; }
|
||||
.table tbody tr:hover td { background: #fafbfe; }
|
||||
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; }
|
||||
.text-muted { color: var(--text-muted); }
|
||||
|
||||
/* ===== 统计卡片 ===== */
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.stat-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 18px 20px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.stat-value { font-size: 26px; font-weight: 700; line-height: 1.15; }
|
||||
.stat-label { color: var(--text-muted); font-size: 12.5px; margin-top: 5px; }
|
||||
.stat-card.is-primary .stat-value { color: var(--primary); }
|
||||
.stat-card.is-success .stat-value { color: var(--success); }
|
||||
.stat-card.is-info .stat-value { color: #0891b2; }
|
||||
.stat-card.is-danger .stat-value { color: var(--danger); }
|
||||
@media (max-width: 1000px) { .stat-grid { grid-template-columns: repeat(3, 1fr); } }
|
||||
@media (max-width: 560px) { .stat-grid { grid-template-columns: repeat(2, 1fr); } }
|
||||
|
||||
/* ===== 空状态 ===== */
|
||||
.empty-state { text-align: center; padding: 60px 24px; color: var(--text-muted); }
|
||||
.empty-state .empty-icon { font-size: 42px; color: #c3cad6; margin-bottom: 14px; }
|
||||
.empty-state h3 { margin: 0 0 7px; color: var(--text); font-size: 16px; font-weight: 600; }
|
||||
.empty-state p { margin: 0 0 20px; font-size: 13.5px; line-height: 1.7; }
|
||||
.empty-state .btn { margin: 0 auto; }
|
||||
|
||||
/* ===== 登录页 ===== */
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.login-card {
|
||||
background: #fff;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
padding: 36px 34px 30px;
|
||||
}
|
||||
.login-brand { display: flex; align-items: center; gap: 12px; margin-bottom: 4px; }
|
||||
.login-brand .brand-logo { width: 42px; height: 42px; font-size: 15px; border-radius: 11px; }
|
||||
.login-brand h1 { font-size: 18px; margin: 0; }
|
||||
.login-brand p { margin: 0; color: var(--text-muted); font-size: 12.5px; }
|
||||
.login-card form { margin-top: 24px; }
|
||||
.login-card .field { margin-bottom: 16px; }
|
||||
.login-card .btn { width: 100%; margin-top: 4px; }
|
||||
|
||||
/* ===== 等待 / 结果页 ===== */
|
||||
.result-wrap { text-align: center; padding: 36px 20px; }
|
||||
.result-icon { width: 64px; height: 64px; margin: 0 auto 16px; }
|
||||
.result-icon .icon { width: 100%; height: 100%; stroke-width: 1.6; }
|
||||
.result-icon.success { color: var(--success); }
|
||||
.result-icon.warning { color: #d97706; }
|
||||
.result-title { font-size: 22px; font-weight: 700; margin: 0 0 8px; }
|
||||
.result-desc { color: var(--text-muted); margin: 0 0 24px; line-height: 1.8; }
|
||||
.spinner {
|
||||
width: 42px; height: 42px;
|
||||
border: 4px solid #dbe5f5;
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 18px;
|
||||
animation: spin .9s linear infinite;
|
||||
}
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
height: 8px;
|
||||
background: #e7ecf5;
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
margin: 0 auto 20px;
|
||||
}
|
||||
.progress-bar-fill {
|
||||
width: 30%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #3b82f6, #0ea5e9);
|
||||
border-radius: 999px;
|
||||
animation: progress-slide 1.6s ease-in-out infinite;
|
||||
}
|
||||
@keyframes progress-slide {
|
||||
0% { width: 8%; margin-left: 0; }
|
||||
50% { width: 45%; margin-left: 20%; }
|
||||
100% { width: 8%; margin-left: 92%; }
|
||||
}
|
||||
.download-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(270px, 1fr));
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.download-list .btn { justify-content: flex-start; padding: 0 14px; font-weight: 500; }
|
||||
.info-box {
|
||||
background: #f9fafb;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px 18px;
|
||||
max-width: 540px;
|
||||
margin: 0 auto 20px;
|
||||
font-size: 13.5px;
|
||||
color: var(--text-muted);
|
||||
text-align: left;
|
||||
line-height: 1.9;
|
||||
}
|
||||
.info-box strong { color: var(--text); }
|
||||
.info-box-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.info-box-title .icon { color: #64748b; }
|
||||
.info-box .icon { vertical-align: -0.15em; margin-right: 4px; }
|
||||
.key-box {
|
||||
background: #0f172a;
|
||||
color: #a7f3d0;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 13px 16px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
word-break: break-all;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* ===== 错误页 ===== */
|
||||
.error-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg);
|
||||
padding: 20px;
|
||||
}
|
||||
.error-box { text-align: center; max-width: 460px; }
|
||||
.error-code { font-size: 72px; font-weight: 800; color: var(--primary); line-height: 1; }
|
||||
.error-box h1 { font-size: 20px; margin: 14px 0 8px; }
|
||||
.error-box p { color: var(--text-muted); margin: 0 0 24px; line-height: 1.7; }
|
||||
|
||||
/* ===== 全站 GitHub 未配置告警条 ===== */
|
||||
.config-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
text-decoration: none;
|
||||
}
|
||||
a.config-banner:hover { text-decoration: none; filter: brightness(0.97); }
|
||||
.config-banner .icon { flex-shrink: 0; }
|
||||
|
||||
/* ===== GitHub 构建配置页 ===== */
|
||||
.brand-github { font-size: 1.15em; vertical-align: -0.18em; fill: currentColor; stroke: none; }
|
||||
.card-desc {
|
||||
margin: 0 0 18px;
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.config-ok {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
color: #15803d;
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 16px;
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.config-ok .icon { color: #16a34a; flex-shrink: 0; }
|
||||
.config-check-list { list-style: none; padding: 0; margin: 0; display: grid; gap: 9px; }
|
||||
.config-check-list li {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 9px;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.config-check-list .icon { flex-shrink: 0; margin-top: 2px; }
|
||||
.config-check-list .is-ok { color: #15803d; }
|
||||
.config-check-list .is-ok .icon { color: #16a34a; }
|
||||
.config-check-list .is-bad { color: #b91c1c; }
|
||||
.config-check-list .is-bad .icon { color: var(--danger); }
|
||||
.config-check-list .is-warn { color: var(--warning); }
|
||||
.config-check-list .is-warn .icon { color: #d97706; }
|
||||
|
||||
.setting-row { max-width: 640px; }
|
||||
.setting-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
font-weight: 600;
|
||||
font-size: 13.5px;
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
.source-badge {
|
||||
font-weight: 500;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.source-db { background: #eff6ff; color: #1d4ed8; border-color: #bfdbfe; }
|
||||
.source-env { background: #f1f5f9; color: #475569; border-color: #e2e8f0; }
|
||||
.source-default { background: #fefce8; color: #a16207; border-color: #fde68a; }
|
||||
.secret-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 14px;
|
||||
margin-top: 7px;
|
||||
}
|
||||
.clear-check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
margin: 0;
|
||||
}
|
||||
.clear-check input { margin: 0; }
|
||||
.secret-state {
|
||||
font-size: 12px;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.secret-state.is-set { background: #eff6ff; color: #1d4ed8; }
|
||||
.secret-state.is-env { background: #f1f5f9; color: #475569; }
|
||||
.secret-state.is-empty { background: #fef2f2; color: #b91c1c; }
|
||||
|
||||
/* 表单内最后一张分组卡命中 .card:last-child 被去掉下边距,
|
||||
需由表单本身补回,避免与表单后的引导卡片紧贴 */
|
||||
.settings-form { margin-bottom: 24px; }
|
||||
|
||||
.settings-actions { margin-top: 22px; padding-top: 20px; border-top: 1px solid var(--border); }
|
||||
.settings-actions .btn-row { margin-bottom: 0; }
|
||||
|
||||
/* 文件选择:隐藏原生控件,用统一按钮样式(与客户端定制页一致) */
|
||||
.file-pick-row { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
|
||||
.file-pick-name { font-size: 13px; color: var(--text-muted); }
|
||||
.test-result {
|
||||
font-size: 13px;
|
||||
padding: 9px 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--border);
|
||||
background: #f9fafb;
|
||||
color: var(--text-muted);
|
||||
margin-top: 12px;
|
||||
}
|
||||
.test-result.is-loading { color: var(--text-muted); }
|
||||
.test-result.is-ok { background: #f0fdf4; border-color: #bbf7d0; color: #15803d; }
|
||||
.test-result.is-bad { background: #fef2f2; border-color: #fecaca; color: #b91c1c; }
|
||||
|
||||
/* 版本来源提示 */
|
||||
.version-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 7px;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.version-note .icon { font-size: 13px; flex-shrink: 0; }
|
||||
.version-note.is-live { color: #1d4ed8; }
|
||||
.version-note.is-fallback { color: #b45309; }
|
||||
|
||||
/* 定制页配置方案区 */
|
||||
.profile-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.profile-row:last-child { margin-bottom: 0; }
|
||||
.profile-row .form-control { width: auto; min-width: 220px; max-width: 420px; }
|
||||
.profile-row #profileName { flex: 0 1 300px; }
|
||||
.profile-msg { display: none; margin-top: 10px; font-size: 13px; line-height: 1.6; }
|
||||
.profile-msg.is-ok { display: block; color: #15803d; }
|
||||
.profile-msg.is-bad { display: block; color: #b91c1c; }
|
||||
|
||||
/* 配置引导教程 */
|
||||
.guide-steps { list-style: none; padding: 0; margin: 0; counter-reset: guide; }
|
||||
.guide-steps > li {
|
||||
position: relative;
|
||||
padding: 0 0 20px 42px;
|
||||
counter-increment: guide;
|
||||
}
|
||||
.guide-steps > li:last-child { padding-bottom: 0; }
|
||||
.guide-steps > li::before {
|
||||
content: counter(guide);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.guide-steps > li:not(:last-child)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 13px;
|
||||
top: 31px;
|
||||
bottom: 2px;
|
||||
width: 1.5px;
|
||||
background: #dbe4f0;
|
||||
}
|
||||
.guide-title { font-weight: 600; font-size: 14px; margin-bottom: 5px; }
|
||||
.guide-steps p { margin: 0; font-size: 13px; color: var(--text-muted); line-height: 1.75; }
|
||||
.guide-steps a { color: var(--primary); font-weight: 500; }
|
||||
.icon-inline { width: 13px; height: 13px; vertical-align: -0.15em; }
|
||||
.guide-sublist { margin: 8px 0 0; padding-left: 18px; font-size: 13px; color: var(--text-muted); line-height: 1.85; }
|
||||
.guide-sublist strong { color: var(--text); }
|
||||
.guide-steps code {
|
||||
background: #f1f5f9;
|
||||
border-radius: 4px;
|
||||
padding: 1px 6px;
|
||||
font-size: 12px;
|
||||
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.setting-row { max-width: none; }
|
||||
.test-result { width: 100%; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{% load static %}<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>无权访问 · RustDesk 客户端定制平台</title>
|
||||
<link rel="stylesheet" href="{% static 'rdgenerator/css/app.css' %}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-page">
|
||||
<div class="error-box">
|
||||
<div class="error-code">403</div>
|
||||
<h1>抱歉,你无权访问该页面</h1>
|
||||
<p>该资源仅对任务提交者或管理员开放。如需权限,请联系平台管理员。</p>
|
||||
<a class="btn btn-primary" href="/">返回首页</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
{% load static %}<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>页面不存在 · RustDesk 客户端定制平台</title>
|
||||
<link rel="stylesheet" href="{% static 'rdgenerator/css/app.css' %}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-page">
|
||||
<div class="error-box">
|
||||
<div class="error-code">404</div>
|
||||
<h1>页面或资源不存在</h1>
|
||||
<p>链接可能已失效,或对应的构建任务 / 文件尚未生成。请确认地址无误,或返回平台首页继续操作。</p>
|
||||
<a class="btn btn-primary" href="/">返回首页</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,19 @@
|
||||
{% load static %}<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>服务异常 · RustDesk 客户端定制平台</title>
|
||||
<link rel="stylesheet" href="{% static 'rdgenerator/css/app.css' %}">
|
||||
</head>
|
||||
<body>
|
||||
<div class="error-page">
|
||||
<div class="error-box">
|
||||
<div class="error-code">500</div>
|
||||
<h1>服务暂时出现异常</h1>
|
||||
<p>服务器处理请求时发生错误,请稍后重试。如问题持续存在,请联系平台管理员。</p>
|
||||
<a class="btn btn-primary" href="/">返回首页</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,76 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}后台管理 · RustDesk 客户端定制平台{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">后台管理 · 构建任务总览</h1>
|
||||
<p class="page-subtitle">查看全部用户提交的构建任务(最近 100 条),并进行用户与任务管理。</p>
|
||||
</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat-card is-primary"><div class="stat-value">{{ stats.total }}</div><div class="stat-label">全部任务</div></div>
|
||||
<div class="stat-card is-info"><div class="stat-value">{{ stats.in_progress }}</div><div class="stat-label">进行中</div></div>
|
||||
<div class="stat-card is-success"><div class="stat-value">{{ stats.success }}</div><div class="stat-label">构建成功</div></div>
|
||||
<div class="stat-card is-danger"><div class="stat-value">{{ stats.failure }}</div><div class="stat-label">失败 / 取消</div></div>
|
||||
<div class="stat-card"><div class="stat-value">{{ stats.users }}</div><div class="stat-label">注册用户</div></div>
|
||||
<div class="stat-card"><div class="stat-value">{{ stats.tokens }}</div><div class="stat-label">有效 API 令牌</div></div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row" style="margin-bottom:20px">
|
||||
<a class="btn btn-secondary" href="{% url 'github_settings' %}"><svg class="icon"><use href="#i-sliders"/></svg> 构建平台配置</a>
|
||||
<a class="btn btn-secondary" href="{% url 'maintenance_settings' %}"><svg class="icon"><use href="#i-clock"/></svg> 维护设置</a>
|
||||
<a class="btn btn-secondary" href="{% url 'user_list' %}"><svg class="icon"><use href="#i-users"/></svg> 用户管理</a>
|
||||
<a class="btn btn-secondary" href="/admin/"><svg class="icon"><use href="#i-wrench"/></svg> Django 高级管理后台</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><svg class="icon"><use href="#i-list"/></svg> 最近构建任务</div>
|
||||
{% if builds %}
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>提交时间</th>
|
||||
<th>提交用户</th>
|
||||
<th>配置名称</th>
|
||||
<th>平台</th>
|
||||
<th>状态</th>
|
||||
<th>任务编号</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for run in builds %}
|
||||
<tr>
|
||||
<td class="text-muted" style="white-space:nowrap">{{ run.created_at|date:"Y-m-d H:i" }}</td>
|
||||
<td>{% if run.created_by %}{{ run.created_by.username }}{% else %}<span class="text-muted">—</span>{% endif %}</td>
|
||||
<td><strong>{{ run.filename|default:"—" }}</strong></td>
|
||||
<td>{{ run.platform_label }}</td>
|
||||
<td><span class="badge {{ run.status_badge }} {% if not run.is_finished %}badge-dot{% endif %}">{{ run.status_label }}</span></td>
|
||||
<td class="mono text-muted">{{ run.short_uuid }}</td>
|
||||
<td style="white-space:nowrap">
|
||||
<a class="btn btn-secondary btn-sm"
|
||||
href="/check_for_file?filename={{ run.filename|urlencode }}&uuid={{ run.uuid|urlencode }}&platform={{ run.platform|urlencode }}">查看</a>
|
||||
<form method="post" class="inline-form" action="{% url 'delete_build' run.id %}"
|
||||
onsubmit="return confirm('确定删除任务「{{ run.filename|default:run.short_uuid|escapejs }}」吗?将同时删除服务器上已生成的安装包文件,且不可恢复。');">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-danger btn-sm">
|
||||
<svg class="icon"><use href="#i-trash"/></svg> 删除
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon"><svg class="icon" style="width:42px;height:42px"><use href="#i-inbox"/></svg></div>
|
||||
<h3>暂无构建任务</h3>
|
||||
<p>还没有任何用户提交过构建任务。</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,62 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}构建未完成 · RustDesk 客户端定制平台{% endblock %}
|
||||
{% block page_class %}page-narrow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-body result-wrap">
|
||||
<div class="result-icon warning"><svg class="icon"><use href="#i-alert"/></svg></div>
|
||||
<h2 class="result-title">构建未能完成</h2>
|
||||
<p class="result-desc">
|
||||
任务状态:<span class="badge {{ status_badge }}">{{ status_label }}</span><br>
|
||||
配置名称:<strong>{{ filename }}</strong>
|
||||
</p>
|
||||
|
||||
<div class="info-box" style="text-align:left">
|
||||
<strong>构建过程异常终止,部分安装包可能并未生成。</strong><br>
|
||||
你可以先尝试下载下方已经产出的文件(不存在的文件将提示 404),或查看 GitHub Actions 日志定位原因后重新提交构建。
|
||||
</div>
|
||||
|
||||
<div class="download-list">
|
||||
{% if platform == 'windows' %}
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}.exe&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}.exe</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}.msi&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}.msi</a>
|
||||
{% elif platform == 'windows-x86' %}
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}.exe&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}.exe</a>
|
||||
{% elif platform == 'linux' %}
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.deb&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-x86_64.deb</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.rpm&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-x86_64.rpm</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-suse-x86_64.rpm&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-suse-x86_64.rpm</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.pkg.tar.zst&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-x86_64.pkg.tar.zst</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-aarch64.deb&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-aarch64.deb</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-aarch64.rpm&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-aarch64.rpm</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-suse-aarch64.rpm&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-suse-aarch64.rpm</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-aarch64.pkg.tar.zst&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-aarch64.pkg.tar.zst</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.AppImage&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-x86_64.AppImage</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-aarch64.AppImage&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-aarch64.AppImage</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.flatpak&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-x86_64.flatpak</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-aarch64.flatpak&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-aarch64.flatpak</a>
|
||||
{% elif platform == 'android' %}
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-aarch64.apk&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-aarch64.apk</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.apk&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-x86_64.apk</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-armv7.apk&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-armv7.apk</a>
|
||||
{% elif platform == 'macos' %}
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.dmg&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-x86_64.dmg</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-aarch64.dmg&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 尝试下载 {{ filename }}-aarch64.dmg</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<p style="margin-top:22px">
|
||||
{% if log_url %}
|
||||
<a href="{{ log_url }}" target="_blank" rel="noopener"><svg class="icon"><use href="#i-external"/></svg> 查看 GitHub Actions 日志,了解具体失败原因</a>
|
||||
{% endif %}
|
||||
</p>
|
||||
|
||||
<div class="btn-row" style="justify-content:center">
|
||||
<a class="btn btn-primary" href="{% url 'generator' %}">重新提交构建</a>
|
||||
<a class="btn btn-secondary" href="{% url 'my_builds' %}">返回我的构建</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,67 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}构建完成 · RustDesk 客户端定制平台{% endblock %}
|
||||
{% block page_class %}page-narrow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-body result-wrap">
|
||||
<div class="result-icon success"><svg class="icon"><use href="#i-check-circle"/></svg></div>
|
||||
<h2 class="result-title">客户端构建完成</h2>
|
||||
<p class="result-desc">
|
||||
配置名称:<strong>{{ filename }}</strong> ·
|
||||
任务编号:<span class="mono">{{ uuid }}</span><br>
|
||||
请选择与目标系统对应的安装包下载。
|
||||
</p>
|
||||
|
||||
<div class="download-list">
|
||||
{% if platform == 'windows' %}
|
||||
<a class="btn btn-primary" href='/download?filename={{ filename|urlencode }}.exe&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 下载 {{ filename }}.exe(便携版)</a>
|
||||
<a class="btn btn-primary" href='/download?filename={{ filename|urlencode }}.msi&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 下载 {{ filename }}.msi(安装版)</a>
|
||||
{% elif platform == 'windows-x86' %}
|
||||
<a class="btn btn-primary" href='/download?filename={{ filename|urlencode }}.exe&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> 下载 {{ filename }}.exe(32 位)</a>
|
||||
{% elif platform == 'linux' %}
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.deb&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-x86_64.deb</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-unattended-wayland-x86_64.deb&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-unattended-wayland-x86_64.deb</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.rpm&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-x86_64.rpm</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-suse-x86_64.rpm&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-suse-x86_64.rpm</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.pkg.tar.zst&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-x86_64.pkg.tar.zst</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-aarch64.deb&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-aarch64.deb</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-aarch64.rpm&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-aarch64.rpm</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-suse-aarch64.rpm&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-suse-aarch64.rpm</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-aarch64.pkg.tar.zst&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-aarch64.pkg.tar.zst</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.AppImage&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-x86_64.AppImage</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-aarch64.AppImage&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-aarch64.AppImage</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.flatpak&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-x86_64.flatpak</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-aarch64.flatpak&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-aarch64.flatpak</a>
|
||||
{% elif platform == 'android' %}
|
||||
<a class="btn btn-primary" href='/download?filename={{ filename|urlencode }}-aarch64.apk&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-aarch64.apk(主流机型)</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-x86_64.apk&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-x86_64.apk</a>
|
||||
<a class="btn btn-secondary" href='/download?filename={{ filename|urlencode }}-armv7.apk&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-armv7.apk</a>
|
||||
{% elif platform == 'macos' %}
|
||||
<a class="btn btn-primary" href='/download?filename={{ filename|urlencode }}-x86_64.dmg&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-x86_64.dmg(Intel 芯片)</a>
|
||||
<a class="btn btn-primary" href='/download?filename={{ filename|urlencode }}-aarch64.dmg&uuid={{ uuid|urlencode }}'><svg class="icon"><use href="#i-download"/></svg> {{ filename }}-aarch64.dmg(Apple 芯片)</a>
|
||||
{% else %}
|
||||
<div class="alert alert-warning">未匹配到目标平台,没有可下载的文件。</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="info-box" style="margin-top:22px" id="platformNote">
|
||||
{% if platform == 'windows' or platform == 'windows-x86' %}
|
||||
<svg class="icon"><use href="#i-alert"/></svg> Windows 首次运行时,可能需要在 SmartScreen 中选择「仍要运行」,或在安全软件中放行。
|
||||
{% elif platform == 'macos' %}
|
||||
<svg class="icon"><use href="#i-alert"/></svg> macOS 首次运行时,可能需要在「系统设置 → 隐私与安全性」中允许打开该应用。
|
||||
{% elif platform == 'linux' %}
|
||||
<svg class="icon"><use href="#i-alert"/></svg> 请确认系统已安装运行所需的相关依赖库。
|
||||
{% elif platform == 'android' %}
|
||||
<svg class="icon"><use href="#i-alert"/></svg> 安装时可能需要在系统设置中允许「安装未知来源应用」。
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="btn-row" style="justify-content:center">
|
||||
<a class="btn btn-secondary" href="{% url 'my_builds' %}">查看我的构建</a>
|
||||
<a class="btn btn-primary" href="{% url 'generator' %}">继续定制新客户端</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,674 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}客户端定制 · RustDesk 客户端定制平台{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">定制 RustDesk 客户端</h1>
|
||||
<p class="page-subtitle">填写以下配置并提交,系统将通过 GitHub Actions 自动构建专属客户端,通常需要 30~45 分钟,可在「我的构建」中随时查看进度。</p>
|
||||
</div>
|
||||
|
||||
<form id="myForm" action="/generator" method="post" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
|
||||
{# ---------- 平台与版本 ---------- #}
|
||||
<div class="card">
|
||||
<div class="card-header"><svg class="icon"><use href="#i-monitor"/></svg> 目标平台与版本</div>
|
||||
<div class="card-body">
|
||||
<div class="platform-grid">
|
||||
<div class="platform-card active" data-platform="windows">
|
||||
<svg class="brand-icon"><use href="#b-windows"/></svg>
|
||||
<div class="platform-name">Windows</div>
|
||||
<div class="platform-tag">64 位</div>
|
||||
</div>
|
||||
<div class="platform-card" data-platform="windows-x86">
|
||||
<svg class="brand-icon"><use href="#b-windows"/></svg>
|
||||
<div class="platform-name">Windows</div>
|
||||
<div class="platform-tag">32 位</div>
|
||||
</div>
|
||||
<div class="platform-card" data-platform="linux">
|
||||
<svg class="brand-icon"><use href="#b-linux"/></svg>
|
||||
<div class="platform-name">Linux</div>
|
||||
<div class="platform-tag">deb / rpm 等</div>
|
||||
</div>
|
||||
<div class="platform-card" data-platform="android">
|
||||
<svg class="brand-icon"><use href="#b-android"/></svg>
|
||||
<div class="platform-name">Android</div>
|
||||
<div class="platform-tag">APK</div>
|
||||
</div>
|
||||
<div class="platform-card" data-platform="macos">
|
||||
<svg class="brand-icon"><use href="#b-apple"/></svg>
|
||||
<div class="platform-name">macOS</div>
|
||||
<div class="platform-tag">dmg</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ form.platform }}
|
||||
|
||||
<div class="grid-2" style="margin-top:20px">
|
||||
<div class="field" style="margin-bottom:0">
|
||||
<label for="{{ form.version.id_for_label }}">RustDesk 版本</label>
|
||||
{{ form.version }}
|
||||
{% if version_source == 'github' %}
|
||||
<div class="version-note is-live">
|
||||
<svg class="icon"><use href="#b-github"/></svg>
|
||||
版本列表来自 GitHub 官方仓库 rustdesk/rustdesk,跟随官方发版自动更新
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="version-note is-fallback">
|
||||
<svg class="icon"><use href="#i-alert"/></svg>
|
||||
官方版本列表暂时获取失败,当前显示内置版本列表;保存配置或配置代理后会自动重试
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if form.version.help_text %}<div class="help-text">{{ form.version.help_text }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="field field-check-control">
|
||||
<label class="check-item">
|
||||
{{ form.delayFix }} <span>修复使用第三方 API 时的连接延迟问题</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---------- 配置方案:账号绑定 / 载入 / 另存 / 导入导出 ---------- #}
|
||||
<div class="card">
|
||||
<div class="card-header"><svg class="icon"><use href="#i-save"/></svg> 配置方案(保存 · 载入 · 导入导出)</div>
|
||||
<div class="card-body">
|
||||
<p class="field-hint" style="margin:0 0 14px">
|
||||
把当前配置「另存为方案」后与账号绑定,下次直接选择方案即可填表创建构建任务;
|
||||
也可导出 JSON 本地备份、从 JSON 文件导入,或前往
|
||||
<a href="{% url 'saved_configs' %}">配置文件管理页</a>统一维护。
|
||||
</p>
|
||||
<div class="profile-row">
|
||||
<select id="profileSelect" class="form-control">
|
||||
<option value="">— 选择已保存的配置方案 —</option>
|
||||
{% for p in profiles %}
|
||||
<option value="{{ p.id }}">{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="button" class="btn btn-secondary" id="loadProfileBtn">
|
||||
<svg class="icon"><use href="#i-zap"/></svg> 载入方案
|
||||
</button>
|
||||
<a class="btn btn-secondary" href="{% url 'saved_configs' %}">
|
||||
<svg class="icon"><use href="#i-list"/></svg> 管理配置文件
|
||||
</a>
|
||||
</div>
|
||||
<div class="profile-row">
|
||||
<input type="text" id="profileName" class="form-control" maxlength="100"
|
||||
placeholder="方案名称,如:公司标准-Windows 版">
|
||||
<button type="button" class="btn btn-primary" id="saveProfileBtn">
|
||||
<svg class="icon"><use href="#i-save"/></svg> 另存当前配置为方案
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="saveFormData()">
|
||||
<svg class="icon"><use href="#i-file-down"/></svg> 导出为 JSON 文件
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" onclick="loadFormData()">
|
||||
<svg class="icon"><use href="#i-file-up"/></svg> 从 JSON 文件导入
|
||||
</button>
|
||||
<input type="file" id="fileInput" style="display:none;" accept=".json,application/json">
|
||||
</div>
|
||||
<div id="profileMsg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
{# ---------- 常规 ---------- #}
|
||||
<div class="card">
|
||||
<div class="card-header"><svg class="icon"><use href="#i-settings"/></svg> 常规设置</div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label for="{{ form.exename.id_for_label }}">{{ form.exename.label }}</label>
|
||||
{{ form.exename }}
|
||||
<span id="filenameError" class="inline-error"></span>
|
||||
{{ form.exename.errors }}
|
||||
{% if form.exename.help_text %}<div class="field-hint">{{ form.exename.help_text }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="{{ form.appname.id_for_label }}">{{ form.appname.label }}</label>
|
||||
{{ form.appname }}
|
||||
{{ form.appname.errors }}
|
||||
{% if form.appname.help_text %}<div class="field-hint">{{ form.appname.help_text }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="field radio-group">
|
||||
<label class="field-label">{{ form.direction.label }}</label>
|
||||
{{ form.direction }}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="{{ form.installation.id_for_label }}">{{ form.installation.label }}</label>
|
||||
{{ form.installation }}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="{{ form.settings.id_for_label }}">{{ form.settings.label }}</label>
|
||||
{{ form.settings }}
|
||||
</div>
|
||||
<div class="field" style="margin-bottom:0">
|
||||
<label for="{{ form.androidappid.id_for_label }}">{{ form.androidappid.label }}</label>
|
||||
{{ form.androidappid }}
|
||||
{% if form.androidappid.help_text %}<div class="field-hint">{{ form.androidappid.help_text }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---------- 自定义服务器 ---------- #}
|
||||
<div class="card">
|
||||
<div class="card-header"><svg class="icon"><use href="#i-server"/></svg> 自定义服务器</div>
|
||||
<div class="card-body">
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="{{ form.serverIP.id_for_label }}">{{ form.serverIP.label }}</label>
|
||||
{{ form.serverIP }}
|
||||
{% if form.serverIP.help_text %}<div class="field-hint">{{ form.serverIP.help_text }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="{{ form.serverPort.id_for_label }}">{{ form.serverPort.label }}</label>
|
||||
{{ form.serverPort }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="{{ form.apiServer.id_for_label }}">{{ form.apiServer.label }}</label>
|
||||
{{ form.apiServer }}
|
||||
{% if form.apiServer.help_text %}<div class="field-hint">{{ form.apiServer.help_text }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="{{ form.key.id_for_label }}">{{ form.key.label }}</label>
|
||||
{{ form.key }}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="{{ form.urlLink.id_for_label }}">{{ form.urlLink.label }}</label>
|
||||
{{ form.urlLink }}
|
||||
{% if form.urlLink.help_text %}<div class="field-hint">{{ form.urlLink.help_text }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="{{ form.downloadLink.id_for_label }}">{{ form.downloadLink.label }}</label>
|
||||
{{ form.downloadLink }}
|
||||
{% if form.downloadLink.help_text %}<div class="field-hint">{{ form.downloadLink.help_text }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="field" style="margin-bottom:0">
|
||||
<label for="{{ form.compname.id_for_label }}">{{ form.compname.label }}</label>
|
||||
{{ form.compname }}
|
||||
{{ form.compname.errors }}
|
||||
{% if form.compname.help_text %}<div class="field-hint">{{ form.compname.help_text }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
{# ---------- 安全 ---------- #}
|
||||
<div class="card">
|
||||
<div class="card-header"><svg class="icon"><use href="#i-shield"/></svg> 安全设置</div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label for="{{ form.passApproveMode.id_for_label }}">{{ form.passApproveMode.label }}</label>
|
||||
{{ form.passApproveMode }}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="{{ form.permanentPassword.id_for_label }}">{{ form.permanentPassword.label }}</label>
|
||||
{{ form.permanentPassword }}
|
||||
{% if form.permanentPassword.help_text %}<div class="field-hint">{{ form.permanentPassword.help_text }}</div>{% endif %}
|
||||
</div>
|
||||
<div id="passwordRequirement" class="inline-warning">使用「隐藏连接窗口」功能时,请先设置固定访问密码。</div>
|
||||
<label class="check-item" for="{{ form.denyLan.id_for_label }}">{{ form.denyLan }} <span>禁止局域网发现</span></label>
|
||||
<label class="check-item" for="{{ form.enableDirectIP.id_for_label }}">{{ form.enableDirectIP }} <span>允许直接 IP 直连</span></label>
|
||||
<label class="check-item" for="{{ form.autoClose.id_for_label }}">{{ form.autoClose }} <span>用户无操作时自动断开入站会话</span></label>
|
||||
<label class="check-item" for="{{ form.hidecm.id_for_label }}">{{ form.hidecm }} <span>允许在远程画面中隐藏连接窗口</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---------- 外观 ---------- #}
|
||||
<div class="card">
|
||||
<div class="card-header"><svg class="icon"><use href="#i-image"/></svg> 外观定制</div>
|
||||
<div class="card-body">
|
||||
{{ form.iconbase64.as_hidden }}
|
||||
{{ form.logobase64.as_hidden }}
|
||||
{{ form.privacybase64.as_hidden }}
|
||||
<div class="field">
|
||||
<label for="{{ form.iconfile.id_for_label }}">{{ form.iconfile.label }}</label>
|
||||
{{ form.iconfile }}
|
||||
{{ form.iconfile.errors }}
|
||||
{% if form.iconfile.help_text %}<div class="field-hint">{{ form.iconfile.help_text }}</div>{% endif %}
|
||||
<div id="icon-preview" class="image-preview"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="{{ form.logofile.id_for_label }}">{{ form.logofile.label }}</label>
|
||||
{{ form.logofile }}
|
||||
{% if form.logofile.help_text %}<div class="field-hint">{{ form.logofile.help_text }}</div>{% endif %}
|
||||
<div id="logo-preview" class="image-preview"></div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="{{ form.privacyfile.id_for_label }}">{{ form.privacyfile.label }}</label>
|
||||
{{ form.privacyfile }}
|
||||
{% if form.privacyfile.help_text %}<div class="field-hint">{{ form.privacyfile.help_text }}</div>{% endif %}
|
||||
<div id="privacy-preview" class="image-preview photo"></div>
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="field" style="margin-bottom:0">
|
||||
<label for="{{ form.theme.id_for_label }}">主题</label>
|
||||
{{ form.theme }}
|
||||
</div>
|
||||
<div class="field" style="margin-bottom:0">
|
||||
<label for="{{ form.themeDorO.id_for_label }}">主题生效方式</label>
|
||||
{{ form.themeDorO }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="field-hint">「默认」仅设定初始主题,客户端用户可自行更改;「强制锁定」后用户无法更改主题。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2">
|
||||
{# ---------- 权限 ---------- #}
|
||||
<div class="card">
|
||||
<div class="card-header"><svg class="icon"><use href="#i-lock"/></svg> 权限设置</div>
|
||||
<div class="card-body">
|
||||
<div class="field">
|
||||
<label for="{{ form.permissionsDorO.id_for_label }}">权限生效方式</label>
|
||||
{{ form.permissionsDorO }}
|
||||
<div class="field-hint">「默认」用户仍可自行修改;「强制锁定」后用户无法修改以下权限。</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="{{ form.permissionsType.id_for_label }}">{{ form.permissionsType.label }}</label>
|
||||
{{ form.permissionsType }}
|
||||
</div>
|
||||
<div class="check-grid">
|
||||
<label class="check-item" for="{{ form.enableKeyboard.id_for_label }}">{{ form.enableKeyboard }} <span>键盘 / 鼠标控制</span></label>
|
||||
<label class="check-item" for="{{ form.enableClipboard.id_for_label }}">{{ form.enableClipboard }} <span>剪贴板同步</span></label>
|
||||
<label class="check-item" for="{{ form.enableFileTransfer.id_for_label }}">{{ form.enableFileTransfer }} <span>文件传输</span></label>
|
||||
<label class="check-item" for="{{ form.enableAudio.id_for_label }}">{{ form.enableAudio }} <span>音频传输</span></label>
|
||||
<label class="check-item" for="{{ form.enableTCP.id_for_label }}">{{ form.enableTCP }} <span>TCP 隧道</span></label>
|
||||
<label class="check-item" for="{{ form.enableRemoteRestart.id_for_label }}">{{ form.enableRemoteRestart }} <span>远程重启</span></label>
|
||||
<label class="check-item" for="{{ form.enableRecording.id_for_label }}">{{ form.enableRecording }} <span>会话录制</span></label>
|
||||
<label class="check-item" for="{{ form.enableBlockingInput.id_for_label }}">{{ form.enableBlockingInput }} <span>屏蔽被控端键鼠</span></label>
|
||||
<label class="check-item" for="{{ form.enableRemoteModi.id_for_label }}">{{ form.enableRemoteModi }} <span>允许修改远程配置</span></label>
|
||||
<label class="check-item" for="{{ form.enablePrinter.id_for_label }}">{{ form.enablePrinter }} <span>远程打印机</span></label>
|
||||
<label class="check-item" for="{{ form.enableCamera.id_for_label }}">{{ form.enableCamera }} <span>远程摄像头</span></label>
|
||||
<label class="check-item" for="{{ form.enableTerminal.id_for_label }}">{{ form.enableTerminal }} <span>远程终端</span></label>
|
||||
</div>
|
||||
|
||||
<div class="subsection-title"><svg class="icon"><use href="#i-zap"/></svg> 定制功能</div>
|
||||
<label class="check-item" for="{{ form.xOffline.id_for_label }}">{{ form.xOffline }} <span>通讯录中离线设备显示 X 标记</span></label>
|
||||
<label class="check-item" for="{{ form.removeNewVersionNotif.id_for_label }}">{{ form.removeNewVersionNotif }} <span>移除新版本更新通知</span></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---------- 其他 / 高级 ---------- #}
|
||||
<div class="card">
|
||||
<div class="card-header"><svg class="icon"><use href="#i-sliders"/></svg> 其他与高级设置</div>
|
||||
<div class="card-body">
|
||||
<label class="check-item" for="{{ form.removeWallpaper.id_for_label }}">{{ form.removeWallpaper }} <span>入站会话期间移除桌面壁纸</span></label>
|
||||
|
||||
<p class="field-hint" style="margin:18px 0 10px">
|
||||
高级设置项参考:
|
||||
<a href="https://rustdesk.com/docs/en/self-host/client-configuration/advanced-settings/" target="_blank" rel="noopener">RustDesk 官方文档
|
||||
<svg class="icon" style="font-size:12px"><use href="#i-external"/></svg>
|
||||
</a>
|
||||
</p>
|
||||
<div class="field">
|
||||
<label for="{{ form.defaultManual.id_for_label }}">{{ form.defaultManual.label }}</label>
|
||||
{{ form.defaultManual }}
|
||||
{% if form.defaultManual.help_text %}<div class="field-hint">{{ form.defaultManual.help_text }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="field" style="margin-bottom:0">
|
||||
<label for="{{ form.overrideManual.id_for_label }}">{{ form.overrideManual.label }}</label>
|
||||
{{ form.overrideManual }}
|
||||
{% if form.overrideManual.help_text %}<div class="field-hint">{{ form.overrideManual.help_text }}</div>{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body" style="text-align:center">
|
||||
<button type="submit" id="submitBtn" class="btn btn-primary" style="min-width:240px">
|
||||
<svg class="icon"><use href="#i-send"/></svg> 开始生成自定义客户端
|
||||
</button>
|
||||
<p class="field-hint" style="margin-top:12px">提交后将自动跳转到构建进度页,构建期间可关闭页面,稍后在「我的构建」中回来下载。</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
/* ---------- 平台卡片选择 ---------- */
|
||||
const platformSelect = document.getElementById('id_platform');
|
||||
document.querySelectorAll('.platform-card').forEach(card => {
|
||||
card.addEventListener('click', function () {
|
||||
document.querySelectorAll('.platform-card').forEach(c => c.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
platformSelect.value = this.dataset.platform;
|
||||
});
|
||||
});
|
||||
// 回显当前选中平台
|
||||
const current = platformSelect.value;
|
||||
document.querySelectorAll('.platform-card').forEach(c => {
|
||||
if (c.dataset.platform === current) {
|
||||
document.querySelectorAll('.platform-card').forEach(x => x.classList.remove('active'));
|
||||
c.classList.add('active');
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- 图片预览 ---------- */
|
||||
function previewImage(input, previewContainerId) {
|
||||
const box = document.getElementById(previewContainerId);
|
||||
if (input.files && input.files[0]) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function (e) {
|
||||
box.innerHTML = '';
|
||||
const img = document.createElement('img');
|
||||
img.src = e.target.result;
|
||||
box.appendChild(img);
|
||||
};
|
||||
reader.readAsDataURL(input.files[0]);
|
||||
}
|
||||
}
|
||||
document.getElementById("{{ form.iconfile.id_for_label }}").addEventListener('change', e => previewImage(e.target, 'icon-preview'));
|
||||
document.getElementById("{{ form.logofile.id_for_label }}").addEventListener('change', e => previewImage(e.target, 'logo-preview'));
|
||||
document.getElementById("{{ form.privacyfile.id_for_label }}").addEventListener('change', e => previewImage(e.target, 'privacy-preview'));
|
||||
|
||||
/* ---------- 隐藏连接窗口联动 ---------- */
|
||||
const hidecm = document.getElementById("{{ form.hidecm.id_for_label }}");
|
||||
const passApproveMode = document.getElementById("{{ form.passApproveMode.id_for_label }}");
|
||||
const permanentPassword = document.getElementById("{{ form.permanentPassword.id_for_label }}");
|
||||
hidecm.addEventListener('change', function () {
|
||||
const tip = document.getElementById('passwordRequirement');
|
||||
if (this.checked) {
|
||||
tip.style.display = 'block';
|
||||
permanentPassword.focus();
|
||||
passApproveMode.value = 'password';
|
||||
} else {
|
||||
tip.style.display = 'none';
|
||||
passApproveMode.value = 'password-click';
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- 权限预设联动 ---------- */
|
||||
const permissionChecks = [
|
||||
"{{ form.enableKeyboard.id_for_label }}", "{{ form.enableClipboard.id_for_label }}",
|
||||
"{{ form.enableFileTransfer.id_for_label }}", "{{ form.enableAudio.id_for_label }}",
|
||||
"{{ form.enableTCP.id_for_label }}", "{{ form.enableRemoteRestart.id_for_label }}",
|
||||
"{{ form.enableRecording.id_for_label }}", "{{ form.enableBlockingInput.id_for_label }}",
|
||||
"{{ form.enableRemoteModi.id_for_label }}", "{{ form.enablePrinter.id_for_label }}",
|
||||
"{{ form.enableCamera.id_for_label }}", "{{ form.enableTerminal.id_for_label }}"
|
||||
].map(id => document.getElementById(id));
|
||||
|
||||
function setChecks(checked, disabled) {
|
||||
permissionChecks.forEach(cb => { cb.checked = checked; cb.disabled = disabled; });
|
||||
}
|
||||
document.getElementById("{{ form.permissionsType.id_for_label }}").addEventListener('change', function () {
|
||||
if (this.value === 'full') {
|
||||
setChecks(true, true);
|
||||
} else if (this.value === 'view') {
|
||||
setChecks(false, true);
|
||||
} else {
|
||||
// 自定义:恢复可编辑,不改变当前勾选
|
||||
permissionChecks.forEach(cb => { cb.disabled = false; });
|
||||
}
|
||||
});
|
||||
|
||||
/* ---------- 重复提交保护 ---------- */
|
||||
document.getElementById('myForm').addEventListener('submit', function () {
|
||||
const btn = document.getElementById('submitBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = '正在提交构建任务…';
|
||||
});
|
||||
|
||||
/* ---------- 配置数据收集 / 填充(账号方案、JSON 导入导出共用) ---------- */
|
||||
const form = document.getElementById('myForm');
|
||||
|
||||
async function readFileAsBase64(file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = e => resolve(e.target.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
// 导入配置中遇到未知字段时原样保留,避免另存时丢失
|
||||
let carriedUnknownFields = {};
|
||||
|
||||
// 收集当前表单为可序列化 JSON(图片转 base64 data URL)
|
||||
async function collectFormData() {
|
||||
const formData = {};
|
||||
new FormData(form).forEach((value, key) => {
|
||||
// File 对象序列化为 {},需跳过并在下面单独读取
|
||||
if (value instanceof File) return;
|
||||
formData[key] = value;
|
||||
});
|
||||
|
||||
form.querySelectorAll('select').forEach(sel => { formData[sel.name] = sel.value; });
|
||||
form.querySelectorAll('input[type="checkbox"]').forEach(input => {
|
||||
if (input.name) { formData[input.name] = input.checked; }
|
||||
});
|
||||
form.querySelectorAll('input[type="radio"]:checked').forEach(input => {
|
||||
if (input.name) { formData[input.name] = input.value; }
|
||||
});
|
||||
|
||||
// 图片:优先取已预览的 base64,其次读取新选择的文件
|
||||
async function collectImage(fieldName, previewId, fileInputId) {
|
||||
const preview = document.getElementById(previewId);
|
||||
if (preview.firstChild && preview.firstChild.src && preview.firstChild.src.startsWith('data:image/')) {
|
||||
formData[fieldName] = preview.firstChild.src;
|
||||
} else {
|
||||
const file = document.getElementById(fileInputId).files[0];
|
||||
if (file) { formData[fieldName] = await readFileAsBase64(file); }
|
||||
}
|
||||
}
|
||||
await collectImage('iconfile', 'icon-preview', "{{ form.iconfile.id_for_label }}");
|
||||
await collectImage('logofile', 'logo-preview', "{{ form.logofile.id_for_label }}");
|
||||
await collectImage('privacyfile', 'privacy-preview', "{{ form.privacyfile.id_for_label }}");
|
||||
|
||||
for (const k in carriedUnknownFields) {
|
||||
if (!Object.prototype.hasOwnProperty.call(formData, k)) formData[k] = carriedUnknownFields[k];
|
||||
}
|
||||
return formData;
|
||||
}
|
||||
|
||||
// 导出为本地 JSON 文件(沿用配置名称作为文件名)
|
||||
window.saveFormData = async function () {
|
||||
const filenameInput = document.getElementById("{{ form.exename.id_for_label }}");
|
||||
const filename = filenameInput.value.trim();
|
||||
const errorBox = document.getElementById('filenameError');
|
||||
if (!filename) {
|
||||
errorBox.textContent = '请先填写配置名称。';
|
||||
filenameInput.focus();
|
||||
return;
|
||||
}
|
||||
errorBox.textContent = '';
|
||||
|
||||
const formData = await collectFormData();
|
||||
const blob = new Blob([JSON.stringify(formData, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename + '.json';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
function showLoadWarnings(keys) {
|
||||
let banner = document.getElementById('loadWarnings');
|
||||
if (!keys.length) {
|
||||
if (banner) banner.remove();
|
||||
return;
|
||||
}
|
||||
if (!banner) {
|
||||
banner = document.createElement('div');
|
||||
banner.id = 'loadWarnings';
|
||||
banner.className = 'alert alert-warning';
|
||||
form.parentNode.insertBefore(banner, form);
|
||||
}
|
||||
banner.textContent = '以下字段包含无效值,已被忽略:' + keys.join('、') + '。';
|
||||
}
|
||||
|
||||
function fillPreview(key, fieldId, previewId) {
|
||||
// 兼容历史导出中 iconbase64 命名
|
||||
const hidden = document.getElementById(fieldId);
|
||||
if (hidden) hidden.value = key;
|
||||
document.getElementById(previewId).innerHTML =
|
||||
'<img src="' + key + '" alt="预览图">';
|
||||
}
|
||||
|
||||
// 用 JSON 数据填充整个表单,返回被忽略的异常字段名列表
|
||||
function fillFormFromData(data) {
|
||||
carriedUnknownFields = {};
|
||||
const skippedKeys = [];
|
||||
for (const key in data) {
|
||||
const elements = document.querySelectorAll('[name="' + key + '"], [id="' + key + '"]');
|
||||
if (elements.length > 0) {
|
||||
elements.forEach(el => {
|
||||
const v = data[key];
|
||||
if ((typeof v === 'object' && v !== null) || v === '[object Object]') {
|
||||
if (!skippedKeys.includes(key)) skippedKeys.push(key);
|
||||
return;
|
||||
}
|
||||
if (el.type === 'radio') {
|
||||
el.checked = (el.value === String(v));
|
||||
} else if (el.type === 'checkbox') {
|
||||
el.checked = !!v;
|
||||
} else if (el.type !== 'file') {
|
||||
el.value = v;
|
||||
}
|
||||
});
|
||||
} else if (['string', 'number', 'boolean'].includes(typeof v)) {
|
||||
carriedUnknownFields[key] = v;
|
||||
}
|
||||
}
|
||||
|
||||
// 图片字段(兼容 iconfile / iconbase64 两种命名)
|
||||
const imgFields = [
|
||||
['iconfile', 'id_iconbase64', 'icon-preview'],
|
||||
['logofile', 'id_logobase64', 'logo-preview'],
|
||||
['privacyfile', 'id_privacybase64', 'privacy-preview'],
|
||||
];
|
||||
imgFields.forEach(([jsonKey, hiddenId, previewId]) => {
|
||||
const v = data[jsonKey] || data[hiddenId];
|
||||
if (v && typeof v === 'string' && v.startsWith('data:image/')) {
|
||||
fillPreview(v, hiddenId, previewId);
|
||||
}
|
||||
});
|
||||
|
||||
// 同步联动控件状态
|
||||
document.querySelectorAll('.platform-card').forEach(c => {
|
||||
c.classList.toggle('active', c.dataset.platform === platformSelect.value);
|
||||
});
|
||||
document.getElementById("{{ form.permissionsType.id_for_label }}").dispatchEvent(new Event('change'));
|
||||
return skippedKeys;
|
||||
}
|
||||
|
||||
window.loadFormData = function () {
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
fileInput.value = '';
|
||||
fileInput.click();
|
||||
fileInput.onchange = function (event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = function (e) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(e.target.result);
|
||||
} catch (err) {
|
||||
alert('加载失败:配置文件不是有效的 JSON。');
|
||||
return;
|
||||
}
|
||||
showLoadWarnings(fillFormFromData(data));
|
||||
showProfileMsg('已从 JSON 文件导入配置,可直接提交构建或继续修改。', true);
|
||||
};
|
||||
reader.readAsText(file);
|
||||
};
|
||||
};
|
||||
|
||||
/* ---------- 账号绑定的配置方案 ---------- */
|
||||
const profileSelect = document.getElementById('profileSelect');
|
||||
const profileName = document.getElementById('profileName');
|
||||
const profileMsg = document.getElementById('profileMsg');
|
||||
const csrfInput = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||
|
||||
function showProfileMsg(text, ok) {
|
||||
profileMsg.className = 'profile-msg ' + (ok ? 'is-ok' : 'is-bad');
|
||||
profileMsg.textContent = text;
|
||||
}
|
||||
|
||||
async function loadProfileById(id) {
|
||||
try {
|
||||
const resp = await fetch('{% url "saved_configs" %}' + id + '/data/');
|
||||
if (!resp.ok) {
|
||||
showProfileMsg('方案不存在或已被删除,请刷新页面后重试。', false);
|
||||
profileSelect.value = '';
|
||||
return;
|
||||
}
|
||||
const json = await resp.json();
|
||||
showLoadWarnings(fillFormFromData(json.data));
|
||||
profileSelect.value = id;
|
||||
showProfileMsg('已载入方案「' + json.name + '」,可直接提交构建或继续修改。', true);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
} catch (e) {
|
||||
showProfileMsg('载入方案失败:' + e, false);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('loadProfileBtn').addEventListener('click', function () {
|
||||
const id = profileSelect.value;
|
||||
if (!id) { showProfileMsg('请先在下拉框中选择一个配置方案。', false); return; }
|
||||
loadProfileById(id);
|
||||
});
|
||||
|
||||
document.getElementById('saveProfileBtn').addEventListener('click', async function () {
|
||||
const btn = this;
|
||||
const name = profileName.value.trim();
|
||||
if (!name) {
|
||||
showProfileMsg('请先在左侧输入方案名称。', false);
|
||||
profileName.focus();
|
||||
return;
|
||||
}
|
||||
const payload = { name: name, data: await collectFormData(), overwrite: false };
|
||||
btn.disabled = true;
|
||||
const originalHtml = btn.innerHTML;
|
||||
btn.textContent = '正在保存…';
|
||||
|
||||
async function submit(overwrite) {
|
||||
payload.overwrite = overwrite;
|
||||
const resp = await fetch('{% url "save_config" %}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrfInput },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const json = await resp.json().catch(() => ({}));
|
||||
if (resp.ok && json.ok) {
|
||||
let option = profileSelect.querySelector('option[value="' + json.id + '"]');
|
||||
if (!option) {
|
||||
option = document.createElement('option');
|
||||
option.value = json.id;
|
||||
profileSelect.appendChild(option);
|
||||
}
|
||||
option.textContent = name;
|
||||
profileSelect.value = json.id;
|
||||
showProfileMsg(json.overwritten ? '方案「' + name + '」已更新。' : '方案「' + name + '」已保存,下次可直接选择载入。', true);
|
||||
} else if (resp.status === 409 && json.duplicate) {
|
||||
if (window.confirm('已存在同名方案「' + name + '」,是否用当前配置覆盖它?')) {
|
||||
return submit(true);
|
||||
}
|
||||
} else {
|
||||
showProfileMsg(json.message || '保存失败,请稍后重试。', false);
|
||||
}
|
||||
}
|
||||
|
||||
submit(false).catch(e => showProfileMsg('保存失败:' + e, false)).finally(() => {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = originalHtml;
|
||||
});
|
||||
});
|
||||
|
||||
// 从「配置文件管理」页点「载入并构建」跳转过来时自动填充
|
||||
const autoConfigId = new URLSearchParams(window.location.search).get('config');
|
||||
if (autoConfigId) {
|
||||
loadProfileById(autoConfigId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,39 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>RustDesk 客户端定制平台</title>
|
||||
<style>
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
background: radial-gradient(circle at center, #fff 0%, #f8f8f8 75%, #ebebeb 100%);
|
||||
color: #222;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", Roboto, sans-serif;
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
main { padding: 1rem; text-align: center; }
|
||||
h1 { font-size: 2.5rem; line-height: 1.1; margin: 0; }
|
||||
@media screen and (max-width: 480px) { h1 { font-size: 1.5rem; } }
|
||||
h1::after {
|
||||
content: "";
|
||||
background: repeating-linear-gradient(45deg, #ffe800, #ffe800 0.5rem, #222 0.5rem, #222 1.0rem);
|
||||
display: block;
|
||||
height: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
p { margin: 1rem 0 0 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>RustDesk 客户端定制平台</h1>
|
||||
<p>平台正在维护升级中,请稍后再访问。</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,147 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}维护设置 · RustDesk 客户端定制平台{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav class="breadcrumb">
|
||||
<a href="{% url 'dashboard' %}">后台管理</a>
|
||||
<span class="breadcrumb-sep">/</span>
|
||||
<span>维护设置</span>
|
||||
</nav>
|
||||
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">
|
||||
<svg class="icon"><use href="#i-clock"/></svg>
|
||||
维护设置
|
||||
</h1>
|
||||
<p class="page-subtitle">
|
||||
定时清理过期构建任务与安装包产物,并自动备份数据库。维护进程随容器启动常驻运行,每小时检查一次。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="stat-grid">
|
||||
<div class="stat-card"><div class="stat-value">{{ status.total_builds }}</div><div class="stat-label">构建任务总数</div></div>
|
||||
<div class="stat-card is-info"><div class="stat-value">{{ status.in_progress_builds }}</div><div class="stat-label">进行中(不清理)</div></div>
|
||||
<div class="stat-card is-danger"><div class="stat-value">{{ status.expired_builds }}</div><div class="stat-label">已到期待清理</div></div>
|
||||
<div class="stat-card"><div class="stat-value">{{ status.db_size|filesizeformat }}</div><div class="stat-label">数据库大小</div></div>
|
||||
<div class="stat-card"><div class="stat-value">{{ status.artifacts_size|filesizeformat }}</div><div class="stat-label">安装包 / 图片占用</div></div>
|
||||
<div class="stat-card{% if status.backup_enabled %} is-success{% endif %}"><div class="stat-value">{{ status.backups|length }}</div><div class="stat-label">现存备份份数</div></div>
|
||||
</div>
|
||||
|
||||
<div class="grid-2" style="align-items:start">
|
||||
{# ===== 维护策略表单 ===== #}
|
||||
<div class="card">
|
||||
<div class="card-header"><svg class="icon"><use href="#i-sliders"/></svg> 自动维护策略</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="{% url 'maintenance_settings' %}">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label class="setting-label" for="{{ form.build_retention_days.id_for_label }}">{{ form.build_retention_days.label }}</label>
|
||||
{{ form.build_retention_days }}
|
||||
<div class="field-hint">{{ form.build_retention_days.help_text }}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="clear-check" for="{{ form.db_backup_enabled.id_for_label }}">
|
||||
{{ form.db_backup_enabled }} {{ form.db_backup_enabled.label }}
|
||||
</label>
|
||||
<div class="field-hint">{{ form.db_backup_enabled.help_text }}</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="setting-label" for="{{ form.db_backup_keep.id_for_label }}">{{ form.db_backup_keep.label }}</label>
|
||||
{{ form.db_backup_keep }}
|
||||
<div class="field-hint">{{ form.db_backup_keep.help_text }}</div>
|
||||
</div>
|
||||
<div class="settings-actions">
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<svg class="icon"><use href="#i-save"/></svg> 保存设置
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ===== 立即执行 ===== #}
|
||||
<div class="card">
|
||||
<div class="card-header"><svg class="icon"><use href="#i-play"/></svg> 手动维护</div>
|
||||
<div class="card-body">
|
||||
<p class="card-desc">
|
||||
立即执行一轮维护:清理超过保留期的已结束任务、7 天前的临时配置包,并<strong>立即创建一份新的数据库备份</strong>
|
||||
(不受每日一次的自动节奏限制)。
|
||||
</p>
|
||||
<ul class="config-check-list">
|
||||
<li class="is-ok"><svg class="icon"><use href="#i-check-circle"/></svg>
|
||||
当前保留策略:{% if status.retention_days > 0 %}{{ status.retention_days }} 天,预计可清理 {{ status.expired_builds }} 条已结束任务{% else %}永不自动清理{% endif %}
|
||||
</li>
|
||||
<li class="{% if status.backup_enabled %}is-ok{% else %}is-warn{% endif %}">
|
||||
<svg class="icon"><use href="{% if status.backup_enabled %}#i-check-circle{% else %}#i-alert{% endif %}"/></svg>
|
||||
定时备份:{% if status.backup_enabled %}已开启(每 {{ status.backup_interval_hours }} 小时一次,保留 {{ status.backup_keep }} 份){% else %}已关闭{% endif %}
|
||||
</li>
|
||||
<li class="is-ok"><svg class="icon"><use href="#i-database"/></svg>
|
||||
数据库:<span class="mono text-muted">{{ status.db_path }}</span>({{ status.db_size|filesizeformat }})
|
||||
</li>
|
||||
</ul>
|
||||
<form method="post" action="{% url 'maintenance_run_now' %}"
|
||||
onsubmit="return confirm('立即执行清理并备份数据库?已到期的任务记录与安装包将被删除。');">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-secondary">
|
||||
<svg class="icon"><use href="#i-zap"/></svg> 立即执行清理与备份
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ===== 备份列表 ===== #}
|
||||
<div class="card">
|
||||
<div class="card-header"><svg class="icon"><use href="#i-database"/></svg> 数据库备份</div>
|
||||
{% if status.backups %}
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>备份文件</th>
|
||||
<th>大小</th>
|
||||
<th>备份时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for backup in status.backups %}
|
||||
<tr>
|
||||
<td class="mono">{{ backup.name }}</td>
|
||||
<td class="text-muted">{{ backup.size|filesizeformat }}</td>
|
||||
<td class="text-muted" style="white-space:nowrap">{{ backup.mtime|date:"Y-m-d H:i" }}</td>
|
||||
<td>
|
||||
<a class="btn btn-secondary btn-sm" href="{% url 'download_backup' backup.name %}">
|
||||
<svg class="icon"><use href="#i-download"/></svg> 下载
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon"><svg class="icon" style="width:42px;height:42px"><use href="#i-database"/></svg></div>
|
||||
<h3>暂无数据库备份</h3>
|
||||
<p>开启定时备份后会自动生成;也可以点击上方「立即执行清理与备份」马上创建一份。</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><svg class="icon"><use href="#i-server"/></svg> 说明</div>
|
||||
<div class="card-body">
|
||||
<p class="card-desc">
|
||||
备份文件是完整的 SQLite 数据库(包含账号、配置方案、构建记录与站点设置),存放在持久化数据卷
|
||||
<code>data/backups/</code> 中,重建镜像与重新部署均不丢失。<strong>恢复方式</strong>:停止容器,
|
||||
用备份文件替换数据卷内的 <code>db.sqlite3</code> 后重新启动即可。
|
||||
也可通过 docker-compose 环境变量 <code>BUILD_RETENTION_DAYS</code>、
|
||||
<code>DB_BACKUP_ENABLED</code>、<code>DB_BACKUP_KEEP</code> 配置(本页保存的优先级更高)。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,78 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}我的构建 · RustDesk 客户端定制平台{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">我的构建任务</h1>
|
||||
<p class="page-subtitle">这里展示你提交的全部自定义客户端构建任务,单次构建通常需要 30~45 分钟。</p>
|
||||
</div>
|
||||
|
||||
{% if builds %}
|
||||
<div class="card">
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>提交时间</th>
|
||||
<th>配置名称</th>
|
||||
<th>平台</th>
|
||||
<th>状态</th>
|
||||
<th>任务编号</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for run in builds %}
|
||||
<tr>
|
||||
<td class="text-muted" style="white-space:nowrap">{{ run.created_at|date:"Y-m-d H:i" }}</td>
|
||||
<td><strong>{{ run.filename|default:"—" }}</strong></td>
|
||||
<td>{{ run.platform_label }}</td>
|
||||
<td><span class="badge {{ run.status_badge }} {% if not run.is_finished %}badge-dot{% endif %}">{{ run.status_label }}</span></td>
|
||||
<td class="mono text-muted">{{ run.short_uuid }}</td>
|
||||
<td style="white-space:nowrap">
|
||||
<a class="btn btn-secondary btn-sm"
|
||||
href="/check_for_file?filename={{ run.filename|urlencode }}&uuid={{ run.uuid|urlencode }}&platform={{ run.platform|urlencode }}">
|
||||
{% if run.status == 'success' %}<svg class="icon"><use href="#i-download"/></svg> 下载产物{% elif run.is_finished %}查看详情{% else %}查看进度{% endif %}
|
||||
</a>
|
||||
{% if run.github_log_url %}
|
||||
<a class="btn btn-secondary btn-sm" href="{{ run.github_log_url }}" target="_blank" rel="noopener">
|
||||
<svg class="icon"><use href="#i-external"/></svg> 日志
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if run.is_finished and run.config_data %}
|
||||
<form method="post" class="inline-form" action="{% url 'retry_build' run.id %}"
|
||||
onsubmit="return confirm('将使用该任务的原始配置重新提交一次构建(原任务记录保留),是否继续?');">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-secondary btn-sm">
|
||||
<svg class="icon"><use href="#i-refresh"/></svg> 重试
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if run.is_finished %}
|
||||
<form method="post" class="inline-form" action="{% url 'delete_my_build' run.id %}"
|
||||
onsubmit="return confirm('确定删除任务「{{ run.filename|default:run.short_uuid|escapejs }}」吗?将同时删除服务器上的安装包文件,且不可恢复。');">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-danger btn-sm">
|
||||
<svg class="icon"><use href="#i-trash"/></svg> 删除
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon"><svg class="icon" style="width:42px;height:42px"><use href="#i-package"/></svg></div>
|
||||
<h3>还没有构建任务</h3>
|
||||
<p>填写一份客户端配置,即可开始生成你的专属 RustDesk 客户端。</p>
|
||||
<a class="btn btn-primary" href="{% url 'generator' %}">立即定制客户端</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,43 @@
|
||||
{% load static %}<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>登录 · RustDesk 客户端定制平台</title>
|
||||
<link rel="stylesheet" href="{% static 'rdgenerator/css/app.css' %}">
|
||||
</head>
|
||||
<body class="login-page">
|
||||
<div class="login-card">
|
||||
<div class="login-brand">
|
||||
<span class="brand-logo">RD</span>
|
||||
<div>
|
||||
<h1>RustDesk 客户端定制平台</h1>
|
||||
<p>登录后提交和管理自定义客户端构建任务</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if form.errors %}
|
||||
<div class="alerts" style="margin-top:20px">
|
||||
<div class="alert alert-error">用户名或密码不正确,请重新输入。</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="{% url 'login' %}">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label for="id_username">用户名</label>
|
||||
<input type="text" name="username" id="id_username" class="form-control"
|
||||
value="{{ form.username.value|default:'' }}" autofocus required
|
||||
autocomplete="username" placeholder="请输入用户名">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="id_password">密码</label>
|
||||
<input type="password" name="password" id="id_password" class="form-control"
|
||||
required autocomplete="current-password" placeholder="请输入密码">
|
||||
</div>
|
||||
<input type="hidden" name="next" value="{{ next }}">
|
||||
<button type="submit" class="btn btn-primary">登录</button>
|
||||
</form>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,125 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}配置文件管理 · RustDesk 客户端定制平台{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav class="breadcrumb">
|
||||
<a href="{% url 'generator' %}">客户端定制</a>
|
||||
<span class="breadcrumb-sep">/</span>
|
||||
<span>配置文件管理</span>
|
||||
</nav>
|
||||
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">
|
||||
<svg class="icon"><use href="#i-save"/></svg>
|
||||
构建配置文件
|
||||
</h1>
|
||||
<p class="page-subtitle">
|
||||
在客户端定制页可把当前表单「另存为方案」,方案与你的账号绑定,下次直接选择即可创建构建任务;
|
||||
也可以在此导出为 JSON 文件备份/迁移,或从 JSON 文件导入。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><svg class="icon"><use href="#i-file-up"/></svg> 从 JSON 文件导入</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="{% url 'saved_configs' %}" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="id_name">方案名称</label>
|
||||
<input type="text" name="name" id="id_name" maxlength="100" class="form-control"
|
||||
placeholder="留空则使用文件名">
|
||||
<div class="field-hint">同一账号下方案名称不可重复。</div>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="id_config_file">配置文件</label>
|
||||
<input type="file" name="config_file" id="id_config_file"
|
||||
accept=".json,application/json" hidden>
|
||||
<div class="file-pick-row">
|
||||
<label for="id_config_file" class="btn btn-secondary">
|
||||
<svg class="icon"><use href="#i-file-up"/></svg> 选择文件
|
||||
</label>
|
||||
<span id="configFileName" class="file-pick-name">未选择任何文件</span>
|
||||
</div>
|
||||
<div class="field-hint">支持本平台导出的配置文件,也兼容 rdgen-cli 使用的 JSON 参数。</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<svg class="icon"><use href="#i-file-up"/></svg> 导入为配置方案
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><svg class="icon"><use href="#i-list"/></svg> 我的配置方案({{ configs|length }})</div>
|
||||
{% if configs %}
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>方案名称</th>
|
||||
<th>目标平台</th>
|
||||
<th>版本</th>
|
||||
<th>更新时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for cfg in configs %}
|
||||
<tr>
|
||||
<td><strong>{{ cfg.name }}</strong></td>
|
||||
<td>{{ cfg.platform_label }}</td>
|
||||
<td class="mono">{{ cfg.version_label }}</td>
|
||||
<td class="text-muted" style="white-space:nowrap">{{ cfg.updated_at|date:"Y-m-d H:i" }}</td>
|
||||
<td style="white-space:nowrap">
|
||||
<a class="btn btn-secondary btn-sm" href="{% url 'generator' %}?config={{ cfg.id }}">
|
||||
<svg class="icon"><use href="#i-zap"/></svg> 载入并构建
|
||||
</a>
|
||||
<a class="btn btn-secondary btn-sm" href="{% url 'export_config' cfg.id %}">
|
||||
<svg class="icon"><use href="#i-file-down"/></svg> 导出
|
||||
</a>
|
||||
<form method="post" class="inline-form" action="{% url 'delete_config' cfg.id %}"
|
||||
onsubmit="return confirm('确定删除配置方案「{{ cfg.name|escapejs }}」吗?不影响已提交的构建任务。');">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-danger btn-sm">
|
||||
<svg class="icon"><use href="#i-trash"/></svg> 删除
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon"><svg class="icon" style="width:42px;height:42px"><use href="#i-save"/></svg></div>
|
||||
<h3>暂无保存的配置方案</h3>
|
||||
<p>在「客户端定制」页填好配置后点击「另存为方案」,下次即可一键载入,无需重复填写。</p>
|
||||
<a class="btn btn-primary" href="{% url 'generator' %}">
|
||||
<svg class="icon"><use href="#i-zap"/></svg> 去定制页创建方案
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><svg class="icon"><use href="#i-shield"/></svg> 说明</div>
|
||||
<div class="card-body">
|
||||
<ul class="config-check-list">
|
||||
<li class="is-warn"><svg class="icon"><use href="#i-alert"/></svg> 方案内容包含「固定访问密码」等字段,仅自己可见,请勿把导出的 JSON 随意分享给他人。</li>
|
||||
<li class="is-ok"><svg class="icon"><use href="#i-check-circle"/></svg> 应用图标 / Logo / 隐私屏图片会以 base64 内嵌在方案中,无需重新上传。</li>
|
||||
<li class="is-ok"><svg class="icon"><use href="#i-check-circle"/></svg> 导出的 JSON 与定制页「导出配置为 JSON」格式一致,也可直接用于 rdgen-cli 命令行构建。</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('id_config_file').addEventListener('change', function () {
|
||||
document.getElementById('configFileName').textContent =
|
||||
this.files.length ? this.files[0].name : '未选择任何文件';
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,351 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}构建平台配置 · RustDesk 客户端定制平台{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<nav class="breadcrumb">
|
||||
<a href="{% url 'dashboard' %}">后台管理</a>
|
||||
<span class="breadcrumb-sep">/</span>
|
||||
<span>构建平台配置</span>
|
||||
</nav>
|
||||
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">
|
||||
<svg class="icon"><use href="#i-sliders"/></svg>
|
||||
构建平台配置
|
||||
</h1>
|
||||
<p class="page-subtitle">
|
||||
选择实际执行构建任务的平台:GitHub Actions(云托管构建机)或自建 Gitea Actions(构建机自备、可全内网运行)。
|
||||
支持在本页直接配置(优先级最高),也继续兼容 docker-compose 环境变量方式。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{# ===== 配置状态总览 ===== #}
|
||||
<div class="card config-card">
|
||||
<div class="card-header">
|
||||
<svg class="icon"><use href="#i-shield"/></svg> 配置状态检查<span class="platform-tag" style="margin-left:8px">当前平台:{{ build_platform_label }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if build_ready and not warnings %}
|
||||
<div class="config-ok">
|
||||
<svg class="icon"><use href="#i-check-circle"/></svg>
|
||||
{{ build_platform_label }} 构建服务配置完整,可以正常提交客户端构建任务。
|
||||
</div>
|
||||
{% else %}
|
||||
<ul class="config-check-list">
|
||||
{% if build_ready %}
|
||||
<li class="is-ok"><svg class="icon"><use href="#i-check-circle"/></svg> {{ build_platform_label }} 连接信息已配置</li>
|
||||
{% else %}
|
||||
<li class="is-bad"><svg class="icon"><use href="#i-alert"/></svg> {{ build_platform_label }} 连接信息缺失,暂无法触发构建</li>
|
||||
{% endif %}
|
||||
{% for w in warnings %}
|
||||
<li class="is-warn"><svg class="icon"><use href="#i-alert"/></svg> {{ w }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ===== 配置表单 ===== #}
|
||||
<form method="post" action="{% url 'github_settings' %}" class="settings-form">
|
||||
{% csrf_token %}
|
||||
|
||||
{# --- 构建平台选择(自定义平台卡,单选) --- #}
|
||||
<div class="card">
|
||||
<div class="card-header"><svg class="icon"><use href="#i-sliders"/></svg> 构建平台</div>
|
||||
<div class="card-body">
|
||||
<p class="card-desc">切换后仅影响新提交的构建任务;历史任务仍保存在原平台并可继续查询状态与日志。</p>
|
||||
<div class="build-platform-grid">
|
||||
<label class="platform-card {% if build_platform != 'gitea' %}active{% endif %}" data-platform-card="github">
|
||||
<input type="radio" name="build_platform" value="github" class="visually-hidden"
|
||||
{% if build_platform != 'gitea' %}checked{% endif %}>
|
||||
<svg class="icon brand-github"><use href="#b-github"/></svg>
|
||||
<div class="platform-name">GitHub Actions</div>
|
||||
<div class="platform-desc">官方云托管 Windows / macOS / Linux 构建机,零运维;国内网络可能需要代理。</div>
|
||||
</label>
|
||||
<label class="platform-card {% if build_platform == 'gitea' %}active{% endif %}" data-platform-card="gitea">
|
||||
<input type="radio" name="build_platform" value="gitea" class="visually-hidden"
|
||||
{% if build_platform == 'gitea' %}checked{% endif %}>
|
||||
<svg class="icon"><use href="#b-gitea"/></svg>
|
||||
<div class="platform-name">Gitea Actions(自建)</div>
|
||||
<div class="platform-desc">API 与工作流语法兼容 GitHub,可全内网运行、回传快;需自备全部构建机。</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# --- 各配置分组(platform 组已在上方自定义渲染) --- #}
|
||||
{% for group_key, group_title, group_desc in groups %}{% if group_key != 'platform' %}
|
||||
<div class="card backend-panel
|
||||
{% if group_key == 'gitea' %}panel-gitea{% if build_platform != 'gitea' %} panel-hidden{% endif %}
|
||||
{% elif group_key == 'basic' or group_key == 'network' %}panel-github{% if build_platform == 'gitea' %} panel-hidden{% endif %}{% endif %}">
|
||||
<div class="card-header"><svg class="icon"><use href="#i-sliders"/></svg> {{ group_title }}</div>
|
||||
<div class="card-body">
|
||||
<p class="card-desc">{{ group_desc }}</p>
|
||||
{% for row in rows %}{% if row.group == group_key %}
|
||||
<div class="field setting-row{% if row.key == 'SH_SECRET' %} panel-github{% if build_platform == 'gitea' %} panel-hidden{% endif %}{% endif %}">
|
||||
<label class="setting-label" for="{{ row.field.id_for_label }}">
|
||||
{{ row.label }}
|
||||
<span class="source-badge source-{{ row.source }}">{{ row.source_label }}</span>
|
||||
</label>
|
||||
{{ row.field }}
|
||||
{% if row.sensitive %}
|
||||
<div class="secret-meta">
|
||||
<label class="clear-check" for="{{ row.clear_field.id_for_label }}">
|
||||
{{ row.clear_field }} 清除后台配置,回退到环境变量/默认值
|
||||
</label>
|
||||
{% if row.source == 'db' %}
|
||||
<span class="secret-state is-set">已在后台保存</span>
|
||||
{% elif row.source == 'env' %}
|
||||
<span class="secret-state is-env">已通过环境变量配置</span>
|
||||
{% else %}
|
||||
<span class="secret-state is-empty">未配置(使用默认值)</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if row.help %}<div class="field-hint">{{ row.help }}</div>{% endif %}
|
||||
</div>
|
||||
{% endif %}{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}{% endfor %}
|
||||
|
||||
{# --- 保存与连接测试(始终可见,测试目标跟随当前所选平台) --- #}
|
||||
<div class="card settings-submit-card">
|
||||
<div class="card-body settings-actions">
|
||||
<div class="btn-row">
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<svg class="icon"><use href="#i-save"/></svg> 保存配置
|
||||
</button>
|
||||
<button type="button" class="btn btn-secondary" id="testBuildBtn">
|
||||
<svg class="icon"><use href="#i-zap"/></svg> <span id="testBuildBtnLabel">测试 GitHub 连接</span>
|
||||
</button>
|
||||
</div>
|
||||
<div id="testResult" class="test-result" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{# ===== GitHub 配置引导 ===== #}
|
||||
<div class="card guide-card guide-panel guide-github {% if build_platform == 'gitea' %}panel-hidden{% endif %}">
|
||||
<div class="card-header">
|
||||
<svg class="icon brand-github"><use href="#b-github"/></svg> GitHub 首次配置引导(约 5 分钟)
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ol class="guide-steps">
|
||||
<li>
|
||||
<div class="guide-title">Fork 构建仓库</div>
|
||||
<p>
|
||||
打开上游仓库 <a href="https://github.com/bryangerlach/rdgen" target="_blank" rel="noopener">github.com/bryangerlach/rdgen
|
||||
<svg class="icon icon-inline"><use href="#i-external"/></svg></a>,
|
||||
点击右上角 <strong>Fork</strong> 复制到你自己的 GitHub 账号下。
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<div class="guide-title">启用 GitHub Actions</div>
|
||||
<p>
|
||||
进入 Fork 后的仓库页面,打开
|
||||
<a href="https://github.com/{% if gh_user %}{{ gh_user }}{% else %}你的用户名{% endif %}/{% if gh_repo %}{{ gh_repo }}{% else %}rdgen{% endif %}/actions"
|
||||
target="_blank" rel="noopener">Actions 页面
|
||||
<svg class="icon icon-inline"><use href="#i-external"/></svg></a>,
|
||||
如页面提示则点击绿色的 <strong>Enable Actions</strong> 按钮。
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<div class="guide-title">创建 Fine-grained 访问令牌(PAT)</div>
|
||||
<p>
|
||||
打开
|
||||
<a href="https://github.com/settings/personal-access-tokens/new" target="_blank" rel="noopener">
|
||||
Generate new token <svg class="icon icon-inline"><use href="#i-external"/></svg></a>,
|
||||
按如下设置后生成,并把令牌(github_pat_ 开头)填入上方「GitHub 访问令牌」:
|
||||
</p>
|
||||
<ul class="guide-sublist">
|
||||
<li><strong>Repository access</strong>:选择 Only select repositories,勾选你 Fork 的仓库</li>
|
||||
<li><strong>Repository permissions</strong>:Actions 设为 <strong>Read and write</strong>,Workflows 设为 <strong>Read and write</strong></li>
|
||||
<li>Expiration 建议按需设置,过期后需要回到这里更新令牌</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<div class="guide-title">添加两个仓库 Secret</div>
|
||||
<p>
|
||||
打开仓库
|
||||
<a href="https://github.com/{% if gh_user %}{{ gh_user }}{% else %}你的用户名{% endif %}/{% if gh_repo %}{{ gh_repo }}{% else %}rdgen{% endif %}/settings/secrets/actions"
|
||||
target="_blank" rel="noopener">Settings → Secrets and variables → Actions
|
||||
<svg class="icon icon-inline"><use href="#i-external"/></svg></a>,
|
||||
分别 New repository secret:
|
||||
</p>
|
||||
<ul class="guide-sublist">
|
||||
<li><strong>GENURL</strong>:本平台的公网访问地址,须与上方「本平台外部访问地址」一致,例如 https://rdgen.example.com</li>
|
||||
<li><strong>ZIP_PASSWORD</strong>:一个随机强密码(可用 <code>python3 -c 'import secrets; print(secrets.token_hex(100))'</code> 生成),
|
||||
须与上方「配置压缩包密码」<strong>完全一致</strong></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<div class="guide-title">保存并验证</div>
|
||||
<p>填写上方表单后点击「保存配置」,再点击「测试 GitHub 连接」,看到「连接正常」提示即配置完成,可以去客户端定制页提交构建了。</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ===== Gitea 配置引导 ===== #}
|
||||
<div class="card guide-card guide-panel guide-gitea {% if build_platform != 'gitea' %}panel-hidden{% endif %}">
|
||||
<div class="card-header">
|
||||
<svg class="icon"><use href="#b-gitea"/></svg> Gitea 首次配置引导(需自备 Gitea 与构建机)
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<ol class="guide-steps">
|
||||
<li>
|
||||
<div class="guide-title">部署 Gitea 1.27+ 并准备构建仓库</div>
|
||||
<p>
|
||||
自建一个 Gitea 实例(建议 1.27 或更高版本,低版本不支持运行详情回传),
|
||||
把本项目仓库推送到该实例(仓库内含 <code>.gitea/workflows</code> 适配工作流)。
|
||||
<a href="https://docs.gitea.com/usage/actions/quickstart" target="_blank" rel="noopener">
|
||||
官方 Quick Start <svg class="icon icon-inline"><use href="#i-external"/></svg></a>
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<div class="guide-title">启用仓库 Actions</div>
|
||||
<p>
|
||||
进入
|
||||
{% if gitea_server and gitea_owner %}
|
||||
<a href="{{ gitea_server }}/{{ gitea_owner }}/{{ gitea_repo|default:'rdgen' }}/settings" target="_blank" rel="noopener">
|
||||
仓库设置页 <svg class="icon icon-inline"><use href="#i-external"/></svg></a>
|
||||
{% else %}仓库设置页{% endif %}
|
||||
,勾选 <strong>Enable Repository Actions</strong>。
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<div class="guide-title">注册 act_runner 构建机</div>
|
||||
<p>在仓库 Settings → Actions → Runners 创建注册令牌,并在构建机上安装注册 act_runner:</p>
|
||||
<ul class="guide-sublist">
|
||||
<li><strong>Linux</strong>:可用 Docker 模式(需预装 QEMU 以构建 arm64);</li>
|
||||
<li><strong>Windows</strong>:只能 host 模式直接跑在 Windows 机器上(MSVC/Flutter/vcpkg 环境);</li>
|
||||
<li><strong>macOS</strong>:只能 host 模式跑在 Apple 硬件上;</li>
|
||||
<li>runner 自定义标签需与工作流一致:<code>ubuntu-22.04</code>、<code>ubuntu-22.04-arm</code>、<code>windows-2022</code>、<code>macos-14</code>、<code>macos-15-intel</code>。</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li>
|
||||
<div class="guide-title">配置仓库 Secrets</div>
|
||||
<p>
|
||||
在仓库 Settings → Actions → Secrets 中添加
|
||||
<strong>GENURL</strong>(本平台地址)与 <strong>ZIP_PASSWORD</strong>(与上方完全一致);
|
||||
如需 Android/macOS 签名,再添加 ANDROID_SIGNING_KEY、MACOS_P12_BASE64、SIGN_BASE_URL、SIGN_API_KEY。
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<div class="guide-title">第三方 actions 的网络说明</div>
|
||||
<p>
|
||||
工作流使用的第三方 action 默认从 github.com 拉取;纯内网环境请为 runner 配置出网白名单,
|
||||
或在 Gitea 的 <code>app.ini</code> 配置 <code>[actions].DEFAULT_ACTIONS_URL</code> 走自建 action 镜像。
|
||||
完整部署步骤(含 vcpkg 文件缓存配置)见项目 setup.md 的「Gitea 部署附录」。
|
||||
</p>
|
||||
</li>
|
||||
<li>
|
||||
<div class="guide-title">保存并验证</div>
|
||||
<p>填写上方 Gitea 配置后点击「保存配置」,再点击「测试 Gitea 连接」,看到「连接正常」提示即可提交构建。</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ===== 环境变量方式说明 ===== #}
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<svg class="icon"><use href="#i-server"/></svg> 环境变量方式(可选,继续支持)
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="card-desc">
|
||||
也可以在 <code>docker-compose.yml</code> 的 environment 中配置以下变量,容器重启后自动生效;
|
||||
本页保存的同名配置优先级更高(值与环境变量相同时会自动回退,不产生重复覆盖)。
|
||||
</p>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead><tr><th>环境变量</th><th>对应配置</th><th>说明</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td class="mono">BUILD_PLATFORM</td><td>构建平台</td><td>github(默认)或 gitea</td></tr>
|
||||
<tr><td class="mono">GHUSER</td><td>GitHub 用户名</td><td>Fork 仓库所在账号</td></tr>
|
||||
<tr><td class="mono">GHBEARER</td><td>GitHub 访问令牌</td><td>Fine-grained PAT,Actions/Workflows 读写</td></tr>
|
||||
<tr><td class="mono">GENURL</td><td>平台外部访问地址</td><td>两平台共用,供构建机回传产物,公网/内网可访问</td></tr>
|
||||
<tr><td class="mono">ZIP_PASSWORD</td><td>配置压缩包密码</td><td>两平台共用,须与仓库 Secret 一致</td></tr>
|
||||
<tr><td class="mono">REPONAME</td><td>GitHub 仓库名称</td><td>默认 rdgen</td></tr>
|
||||
<tr><td class="mono">GHBRANCH</td><td>GitHub 工作流分支</td><td>默认 master</td></tr>
|
||||
<tr><td class="mono">PROTOCOL</td><td>平台访问协议</td><td>https(默认)或 http</td></tr>
|
||||
<tr><td class="mono">SH_SECRET</td><td>GitHub 自托管 Runner 密钥</td><td>仅 GitHub 自托管构建时使用</td></tr>
|
||||
<tr><td class="mono">WEBHOOK_SECRET</td><td>回调校验密钥</td><td>两平台共用,留空不校验</td></tr>
|
||||
<tr><td class="mono">GH_PROXY</td><td>GitHub 访问代理</td><td>如 http://host:port,留空直连</td></tr>
|
||||
<tr><td class="mono">GITEA_SERVER_URL</td><td>Gitea 服务器地址</td><td>如 https://gitea.example.com</td></tr>
|
||||
<tr><td class="mono">GITEA_TOKEN</td><td>Gitea 访问令牌</td><td>需仓库 Actions 读写权限</td></tr>
|
||||
<tr><td class="mono">GITEA_OWNER</td><td>Gitea 仓库属主</td><td>用户名或组织名</td></tr>
|
||||
<tr><td class="mono">GITEA_REPO</td><td>Gitea 仓库名称</td><td>默认 rdgen</td></tr>
|
||||
<tr><td class="mono">GITEA_BRANCH</td><td>Gitea 工作流分支</td><td>默认 master</td></tr>
|
||||
<tr><td class="mono">GITEA_PROXY</td><td>Gitea 访问代理</td><td>内网直连通常留空</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
var radios = document.querySelectorAll('input[name=build_platform]');
|
||||
var btn = document.getElementById('testBuildBtn');
|
||||
var btnLabel = document.getElementById('testBuildBtnLabel');
|
||||
var box = document.getElementById('testResult');
|
||||
|
||||
function currentPlatform() {
|
||||
var checked = document.querySelector('input[name=build_platform]:checked');
|
||||
return checked ? checked.value : 'github';
|
||||
}
|
||||
|
||||
function syncPanels() {
|
||||
var platform = currentPlatform();
|
||||
document.querySelectorAll('.panel-gitea, .guide-gitea').forEach(function (el) {
|
||||
el.classList.toggle('panel-hidden', platform !== 'gitea');
|
||||
});
|
||||
document.querySelectorAll('.panel-github, .guide-github').forEach(function (el) {
|
||||
el.classList.toggle('panel-hidden', platform !== 'github');
|
||||
});
|
||||
document.querySelectorAll('[data-platform-card]').forEach(function (card) {
|
||||
card.classList.toggle('active', card.getAttribute('data-platform-card') === platform);
|
||||
});
|
||||
btnLabel.textContent = platform === 'gitea' ? '测试 Gitea 连接' : '测试 GitHub 连接';
|
||||
}
|
||||
|
||||
radios.forEach(function (radio) { radio.addEventListener('change', syncPanels); });
|
||||
syncPanels();
|
||||
|
||||
btn.addEventListener('click', function () {
|
||||
var platform = currentPlatform();
|
||||
btn.disabled = true;
|
||||
box.hidden = false;
|
||||
box.className = 'test-result is-loading';
|
||||
box.textContent = platform === 'gitea' ? '正在调用 Gitea API 验证配置…' : '正在调用 GitHub API 验证配置…';
|
||||
|
||||
var csrfToken = document.querySelector('[name=csrfmiddlewaretoken]').value;
|
||||
fetch('{% url "test_build" %}', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-CSRFToken': csrfToken,
|
||||
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8'
|
||||
},
|
||||
body: 'platform=' + encodeURIComponent(platform),
|
||||
})
|
||||
.then(function (r) { return r.json().then(function (j) { return {ok: r.ok && j.ok, message: j.message}; }); })
|
||||
.then(function (res) {
|
||||
box.className = 'test-result ' + (res.ok ? 'is-ok' : 'is-bad');
|
||||
box.textContent = res.message;
|
||||
})
|
||||
.catch(function () {
|
||||
box.className = 'test-result is-bad';
|
||||
box.textContent = '测试请求失败,请检查网络或服务日志。';
|
||||
})
|
||||
.finally(function () {
|
||||
btn.disabled = false;
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,111 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}接口令牌 · RustDesk 客户端定制平台{% endblock %}
|
||||
{% block page_class %}page-narrow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">接口令牌(API Token)</h1>
|
||||
<p class="page-subtitle">
|
||||
通过 JSON API 或 rdgen-cli 命令行提交构建任务时,请在请求头携带令牌:
|
||||
<code>Authorization: Token <你的令牌></code>。请妥善保管,不要泄露给他人。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{% if new_plain_key %}
|
||||
<div class="alerts">
|
||||
<div class="alert alert-success">
|
||||
<strong>新令牌已生成,请立即复制保存:</strong>
|
||||
<div class="key-box" style="margin-top:10px">
|
||||
<span id="newKey">{{ new_plain_key }}</span>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="copyKey()">
|
||||
<svg class="icon"><use href="#i-copy"/></svg> 复制
|
||||
</button>
|
||||
</div>
|
||||
<div style="margin-top:8px">为保障安全,离开本页后将无法再次查看完整令牌。</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><svg class="icon"><use href="#i-plus"/></svg> 新建令牌</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="{% url 'api_tokens' %}">
|
||||
{% csrf_token %}
|
||||
<div class="field" style="margin-bottom:0">
|
||||
<label for="{{ form.name.id_for_label }}">{{ form.name.label }}</label>
|
||||
<div class="input-row">
|
||||
{{ form.name }}
|
||||
<button type="submit" class="btn btn-primary">生成令牌</button>
|
||||
</div>
|
||||
{% if form.name.help_text %}<div class="field-hint">{{ form.name.help_text }}</div>{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><svg class="icon"><use href="#i-key"/></svg> 我的令牌</div>
|
||||
{% if tokens %}
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>名称</th>
|
||||
<th>令牌(仅显示前缀)</th>
|
||||
<th>状态</th>
|
||||
<th>创建时间</th>
|
||||
<th>最后使用</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for token in tokens %}
|
||||
<tr>
|
||||
<td><strong>{{ token.name }}</strong></td>
|
||||
<td class="mono text-muted">{{ token.key|slice:":12" }}…</td>
|
||||
<td>
|
||||
{% if token.is_active %}
|
||||
<span class="badge badge-success">启用中</span>
|
||||
{% else %}
|
||||
<span class="badge badge-secondary">已停用</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-muted" style="white-space:nowrap">{{ token.created_at|date:"Y-m-d H:i" }}</td>
|
||||
<td class="text-muted" style="white-space:nowrap">
|
||||
{% if token.last_used_at %}{{ token.last_used_at|date:"Y-m-d H:i" }}{% else %}从未使用{% endif %}
|
||||
</td>
|
||||
<td style="white-space:nowrap">
|
||||
<form method="post" class="inline-form" action="{% url 'delete_token' token.key %}"
|
||||
onsubmit="return confirm('确定删除令牌「{{ token.name|escapejs }}」吗?删除后使用该令牌的程序将立即无法调用接口。');">
|
||||
{% csrf_token %}
|
||||
<button type="submit" class="btn btn-danger btn-sm">
|
||||
<svg class="icon"><use href="#i-trash"/></svg> 删除
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon"><svg class="icon" style="width:42px;height:42px"><use href="#i-key"/></svg></div>
|
||||
<h3>暂无接口令牌</h3>
|
||||
<p>当你需要通过命令行或 API 自动提交构建时,在此创建一个令牌。</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<script>
|
||||
function copyKey() {
|
||||
var text = document.getElementById('newKey').textContent.trim();
|
||||
navigator.clipboard.writeText(text).then(function () {
|
||||
alert('令牌已复制到剪贴板');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,44 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}编辑用户 · 后台管理{% endblock %}
|
||||
{% block page_class %}page-narrow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="breadcrumb">
|
||||
<a href="{% url 'dashboard' %}">后台管理</a><span class="sep">/</span>
|
||||
<a href="{% url 'user_list' %}">用户管理</a><span class="sep">/</span>编辑用户
|
||||
</div>
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">编辑用户</h1>
|
||||
<p class="page-subtitle">正在编辑用户:<strong>{{ target.username }}</strong></p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post" action="{% url 'user_edit' target.id %}">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label for="{{ form.email.id_for_label }}">{{ form.email.label }}</label>
|
||||
{{ form.email }}
|
||||
</div>
|
||||
<label class="check-item" style="margin:12px 0">
|
||||
{{ form.is_active }} <span>账号启用(取消勾选后该用户将无法登录和提交构建)</span>
|
||||
</label>
|
||||
<label class="check-item" style="margin:12px 0 20px">
|
||||
{{ form.is_staff }} <span>管理员权限(可访问后台管理、查看所有用户的构建任务)</span>
|
||||
</label>
|
||||
<div class="field">
|
||||
<label for="{{ form.new_password.id_for_label }}">{{ form.new_password.label }}</label>
|
||||
{{ form.new_password }}
|
||||
{{ form.new_password.errors }}
|
||||
</div>
|
||||
<div class="btn-row" style="margin-top:22px">
|
||||
<button type="submit" class="btn btn-primary">保存修改</button>
|
||||
<a href="{% url 'user_list' %}" class="btn btn-secondary">
|
||||
<svg class="icon"><use href="#i-arrow-left"/></svg> 返回列表
|
||||
</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,97 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}用户管理 · 后台管理{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="breadcrumb">
|
||||
<a href="{% url 'dashboard' %}">后台管理</a><span class="sep">/</span>用户管理
|
||||
</div>
|
||||
<div class="page-head">
|
||||
<h1 class="page-title">用户管理</h1>
|
||||
<p class="page-subtitle">平台不开放公开注册,账号由管理员在此统一创建和管理。</p>
|
||||
</div>
|
||||
|
||||
<div class="card form-narrow">
|
||||
<div class="card-header"><svg class="icon"><use href="#i-user"/></svg> 新建用户</div>
|
||||
<div class="card-body">
|
||||
<form method="post" action="{% url 'user_list' %}">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<label for="{{ form.username.id_for_label }}">{{ form.username.label }}</label>
|
||||
{{ form.username }}
|
||||
{{ form.username.errors }}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="{{ form.email.id_for_label }}">{{ form.email.label }}</label>
|
||||
{{ form.email }}
|
||||
</div>
|
||||
<div class="grid-2">
|
||||
<div class="field">
|
||||
<label for="{{ form.password.id_for_label }}">{{ form.password.label }}</label>
|
||||
{{ form.password }}
|
||||
{{ form.password.errors }}
|
||||
{% if form.password.help_text %}<div class="field-hint">{{ form.password.help_text }}</div>{% endif %}
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="{{ form.password_confirm.id_for_label }}">{{ form.password_confirm.label }}</label>
|
||||
{{ form.password_confirm }}
|
||||
{{ form.password_confirm.errors }}
|
||||
</div>
|
||||
</div>
|
||||
<label class="check-item" style="margin:4px 0 18px">
|
||||
{{ form.is_staff }} <span>设为管理员(可访问后台管理、查看所有任务)</span>
|
||||
</label>
|
||||
<button type="submit" class="btn btn-primary btn-block">创建用户</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><svg class="icon"><use href="#i-users"/></svg> 用户列表({{ users|length }})</div>
|
||||
{% if users %}
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>用户名</th>
|
||||
<th>角色</th>
|
||||
<th>状态</th>
|
||||
<th>构建数</th>
|
||||
<th>注册时间</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>
|
||||
<strong>{{ u.username }}</strong>
|
||||
{% if u.email %}<div class="text-muted" style="font-size:12px;margin-top:2px">{{ u.email }}</div>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if u.is_superuser %}<span class="badge badge-danger">超级管理员</span>
|
||||
{% elif u.is_staff %}<span class="badge badge-info">管理员</span>
|
||||
{% else %}<span class="badge badge-secondary">普通用户</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if u.is_active %}<span class="badge badge-success">启用</span>
|
||||
{% else %}<span class="badge badge-secondary">已停用</span>{% endif %}
|
||||
</td>
|
||||
<td>{{ u.build_count }}</td>
|
||||
<td class="text-muted" style="white-space:nowrap">{{ u.date_joined|date:"Y-m-d" }}</td>
|
||||
<td>
|
||||
<a class="btn btn-secondary btn-sm" href="{% url 'user_edit' u.id %}">编辑</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon"><svg class="icon" style="width:42px;height:42px"><use href="#i-user"/></svg></div>
|
||||
<h3>暂无用户</h3>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,77 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}构建进行中 · RustDesk 客户端定制平台{% endblock %}
|
||||
{% block page_class %}page-narrow{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card">
|
||||
<div class="card-body result-wrap">
|
||||
<div class="spinner"></div>
|
||||
<h2 class="result-title">正在构建自定义客户端</h2>
|
||||
<p class="result-desc">
|
||||
构建通常需要 <strong>30~45 分钟</strong>,本页每 30 秒自动刷新一次状态。<br>
|
||||
你也可以关闭本页,稍后到「我的构建」中查看结果。
|
||||
</p>
|
||||
|
||||
<div class="progress-bar"><div class="progress-bar-fill"></div></div>
|
||||
|
||||
<p style="margin:0 0 6px">
|
||||
当前状态:
|
||||
<span class="badge {{ status_badge }} badge-dot" id="statusBadge">{{ status_label }}</span>
|
||||
<span id="stageText" class="text-muted" style="font-size:12.5px;margin-left:8px"></span>
|
||||
</p>
|
||||
|
||||
<div class="info-box" style="margin-top:24px">
|
||||
<div class="info-box-title"><svg class="icon"><use href="#i-package"/></svg> 任务信息</div>
|
||||
<div>配置名称:<strong>{{ filename }}</strong></div>
|
||||
<div>目标平台:<strong id="platformText">{{ platform }}</strong></div>
|
||||
{% if log_url %}
|
||||
<div style="margin-top:8px">
|
||||
<a href="{{ log_url }}" target="_blank" rel="noopener">
|
||||
<svg class="icon"><use href="#i-external"/></svg> 技术排查:查看 GitHub Actions 构建日志
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="btn-row" style="justify-content:center">
|
||||
<a class="btn btn-secondary btn-sm" href="{% url 'my_builds' %}">查看我的构建列表</a>
|
||||
<a class="btn btn-secondary btn-sm" href="{% url 'generator' %}">继续新建任务</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<script>
|
||||
(function () {
|
||||
var platformLabels = {
|
||||
'windows': 'Windows 64 位',
|
||||
'windows-x86': 'Windows 32 位',
|
||||
'linux': 'Linux',
|
||||
'android': 'Android',
|
||||
'macos': 'macOS'
|
||||
};
|
||||
var platform = ('' + '{{ platform|escapejs }}').toLowerCase();
|
||||
document.getElementById('platformText').textContent = platformLabels[platform] || platform;
|
||||
document.title = '正在构建客户端';
|
||||
|
||||
// 状态阶段提示(仅用于本地动画展示,实际状态以服务端轮询为准)
|
||||
var stages = ['正在初始化构建环境…', '正在拉取并编译源码…', '正在生成平台安装包…', '正在打包与上传产物…'];
|
||||
var i = 0;
|
||||
var stageEl = document.getElementById('stageText');
|
||||
if (stageEl) {
|
||||
stageEl.textContent = stages[0];
|
||||
setInterval(function () {
|
||||
i = (i + 1) % stages.length;
|
||||
stageEl.textContent = stages[i];
|
||||
}, 8000);
|
||||
}
|
||||
|
||||
// 30 秒后整页轮询一次服务端状态
|
||||
setTimeout(function () {
|
||||
window.location.replace('/check_for_file?filename={{ filename|urlencode }}&uuid={{ uuid|urlencode }}&platform={{ platform|urlencode }}');
|
||||
}, 30000);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,3 @@
|
||||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
RustDesk 可选版本列表。
|
||||
|
||||
优先从 GitHub 官方仓库 rustdesk/rustdesk 的 tags 动态获取(跟随官方发版自动更新),
|
||||
进程内缓存 6 小时;网络失败时回退内置版本列表,且失败结果短缓存 10 分钟,
|
||||
避免每次打开页面都等待 GitHub 超时。所有请求经由 ghnet,自动使用代理配置。
|
||||
"""
|
||||
import re
|
||||
import time
|
||||
|
||||
from . import ghnet
|
||||
|
||||
TAGS_URL = 'https://api.github.com/repos/rustdesk/rustdesk/tags?per_page=100'
|
||||
|
||||
# 离线兜底(截至本版本发布时的近期正式版)
|
||||
FALLBACK_VERSIONS = [
|
||||
'1.4.9', '1.4.8', '1.4.7', '1.4.6', '1.4.5',
|
||||
'1.4.4', '1.4.3', '1.4.2', '1.4.1', '1.4.0',
|
||||
]
|
||||
|
||||
# 形如 1.4.9 / 1.4.10 / 1.5.0-rc.1 的正式 tag
|
||||
TAG_RE = re.compile(r'^\d+\.\d+\.\d+(?:[.-][0-9A-Za-z.]+)?$')
|
||||
# 表单/API 对用户提交版本号的宽松校验,兼容动态列表刷新窗口期
|
||||
VERSION_RE = re.compile(r'^(?:master|main|\d+\.\d+\.\d+(?:[.-][0-9A-Za-z.]+)?)$')
|
||||
|
||||
MAX_VERSIONS = 20
|
||||
CACHE_TTL = 6 * 3600 # 成功结果缓存 6 小时
|
||||
FALLBACK_TTL = 10 * 60 # 失败回退缓存 10 分钟
|
||||
|
||||
_memo = {
|
||||
'ts': 0.0,
|
||||
'ttl': CACHE_TTL,
|
||||
'versions': FALLBACK_VERSIONS,
|
||||
'source': 'builtin',
|
||||
}
|
||||
|
||||
|
||||
def _sort_versions(versions):
|
||||
"""按版本号数字段降序排列,预发布标识排在同正式版之后。"""
|
||||
def key(v):
|
||||
main, _, suffix = v.partition('-')
|
||||
parts = [int(x) if x.isdigit() else 0 for x in main.split('.')]
|
||||
parts += [0] * (3 - len(parts))
|
||||
# 无后缀(正式版)排在 rc/beta 之前
|
||||
return (parts, 1 if not suffix else 0, suffix)
|
||||
return sorted(versions, key=key, reverse=True)
|
||||
|
||||
|
||||
def _fetch_from_github():
|
||||
resp = ghnet.get(TAGS_URL, timeout=8)
|
||||
resp.raise_for_status()
|
||||
tags = [item['name'].lstrip('v') for item in resp.json() if item.get('name')]
|
||||
versions = _sort_versions({t for t in tags if TAG_RE.match(t)})
|
||||
return versions[:MAX_VERSIONS]
|
||||
|
||||
|
||||
def get_versions(force=False):
|
||||
"""返回 (版本号列表, 来源 source, 缓存时间戳);source: github / builtin。"""
|
||||
now = time.time()
|
||||
if not force and now - _memo['ts'] < _memo['ttl']:
|
||||
return _memo['versions'], _memo['source'], _memo['ts']
|
||||
|
||||
try:
|
||||
versions = _fetch_from_github()
|
||||
if versions:
|
||||
_memo.update(ts=now, ttl=CACHE_TTL, versions=versions, source='github')
|
||||
else:
|
||||
_memo.update(ts=now, ttl=FALLBACK_TTL, versions=FALLBACK_VERSIONS, source='builtin')
|
||||
except Exception:
|
||||
_memo.update(ts=now, ttl=FALLBACK_TTL, versions=FALLBACK_VERSIONS, source='builtin')
|
||||
return _memo['versions'], _memo['source'], _memo['ts']
|
||||
|
||||
|
||||
def version_choices():
|
||||
"""供 ChoiceField 使用的 (value, label) 列表。"""
|
||||
versions, source, _ = get_versions()
|
||||
label_suffix = '' if source == 'github' else '(内置列表,获取官方列表失败)'
|
||||
choices = [('master', '开发版(nightly 每夜构建)')]
|
||||
choices += [(v, v) for v in versions]
|
||||
return choices
|
||||
|
||||
|
||||
def is_valid_version(value):
|
||||
"""API / 表单提交的版本号合法性校验。"""
|
||||
return bool(value) and bool(VERSION_RE.match(str(value)))
|
||||
@@ -0,0 +1,695 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
import base64
|
||||
import pyzipper
|
||||
from PIL import Image
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.files.base import ContentFile
|
||||
from django.db.models import Q
|
||||
from django.http import (
|
||||
HttpResponse,
|
||||
JsonResponse,
|
||||
HttpResponseForbidden,
|
||||
Http404,
|
||||
)
|
||||
from django.shortcuts import render
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from . import app_settings, build_backends, versions as rdeskVersions
|
||||
from .forms import GenerateForm
|
||||
from .models import GithubRun, STATUS_LABELS, STATUS_BADGE
|
||||
|
||||
|
||||
def _webhook_denied(request):
|
||||
"""配置了 WEBHOOK_SECRET 时,校验 GitHub Actions 回调请求头;未配置则放行(兼容旧工作流)。"""
|
||||
secret = app_settings.get_value('WEBHOOK_SECRET')
|
||||
if secret and request.headers.get('X-Webhook-Secret') != secret:
|
||||
return HttpResponseForbidden('无效的 Webhook 密钥')
|
||||
return None
|
||||
|
||||
|
||||
def _status_extra(status_code):
|
||||
return (
|
||||
STATUS_LABELS.get(status_code, status_code),
|
||||
STATUS_BADGE.get(status_code, 'badge-secondary'),
|
||||
)
|
||||
|
||||
|
||||
def _can_access_run(request, run):
|
||||
"""任务提交者本人或管理员可访问;历史任务无归属人时登录用户均可访问。"""
|
||||
if request.user.is_staff:
|
||||
return True
|
||||
return not run.created_by_id or run.created_by_id == request.user.id
|
||||
|
||||
|
||||
def generate_custom_client(params, full_url, user=None):
|
||||
"""
|
||||
网页表单与 JSON API 共用的核心生成逻辑。
|
||||
|
||||
Args:
|
||||
params: 包含全部配置项的字典(键名与 GenerateForm 字段一致)
|
||||
full_url: 本服务的完整 URL(协议 + 主机名)
|
||||
user: 提交任务的登录用户(可为 None,如匿名 API 调用)
|
||||
|
||||
Returns:
|
||||
成功时包含 success/uuid/filename/platform/log_url;
|
||||
失败时包含 success=False、error(中文提示)及 status_code。
|
||||
"""
|
||||
user_secret = params.get('sh_secret_field', '')
|
||||
selfhosted = (app_settings.get_value('SH_SECRET') == user_secret)
|
||||
platform = params.get('platform', 'windows')
|
||||
version = params.get('version', '1.4.9')
|
||||
delayFix = params.get('delayFix', True)
|
||||
xOffline = params.get('xOffline', False)
|
||||
hidecm = params.get('hidecm', False)
|
||||
removeNewVersionNotif = params.get('removeNewVersionNotif', False)
|
||||
server = params.get('serverIP', '')
|
||||
serverPort = params.get('serverPort', '')
|
||||
key = params.get('key', '')
|
||||
apiServer = params.get('apiServer', '')
|
||||
urlLink = params.get('urlLink', '')
|
||||
downloadLink = params.get('downloadLink', '')
|
||||
if not server:
|
||||
server = 'rs-ny.rustdesk.com' # RustDesk 默认服务器
|
||||
if not serverPort:
|
||||
serverPort = '21116' # RustDesk 默认会合端口
|
||||
if not key:
|
||||
key = 'OeVuKk5nlHiXp+APNn0Y3pC1Iwpwn44JGqrQCsWqmBw=' # RustDesk 默认公钥
|
||||
if not apiServer:
|
||||
apiServer = server+":21114"
|
||||
if not urlLink:
|
||||
urlLink = "https://rustdesk.com"
|
||||
if not downloadLink:
|
||||
downloadLink = "https://rustdesk.com/download"
|
||||
direction = params.get('direction', 'both')
|
||||
installation = params.get('installation', 'installationY')
|
||||
settings = params.get('settings', 'settingsY')
|
||||
appname = params.get('appname', '')
|
||||
if not appname:
|
||||
appname = "rustdesk"
|
||||
filename = params.get('exename', 'rustdesk')
|
||||
compname = params.get('compname', '')
|
||||
if not compname:
|
||||
compname = "Purslane Ltd"
|
||||
androidappid = params.get('androidappid', '')
|
||||
if not androidappid:
|
||||
androidappid = "com.carriez.flutter_hbb"
|
||||
compname = compname.replace("&","\\&")
|
||||
permPass = params.get('permanentPassword', '')
|
||||
theme = params.get('theme', 'system')
|
||||
themeDorO = params.get('themeDorO', 'default')
|
||||
passApproveMode = params.get('passApproveMode', 'password-click')
|
||||
denyLan = params.get('denyLan', False)
|
||||
enableDirectIP = params.get('enableDirectIP', False)
|
||||
autoClose = params.get('autoClose', False)
|
||||
permissionsDorO = params.get('permissionsDorO', 'default')
|
||||
permissionsType = params.get('permissionsType', 'custom')
|
||||
enableKeyboard = params.get('enableKeyboard', True)
|
||||
enableClipboard = params.get('enableClipboard', True)
|
||||
enableFileTransfer = params.get('enableFileTransfer', True)
|
||||
enableAudio = params.get('enableAudio', True)
|
||||
enableTCP = params.get('enableTCP', True)
|
||||
enableRemoteRestart = params.get('enableRemoteRestart', True)
|
||||
enableRecording = params.get('enableRecording', True)
|
||||
enableBlockingInput = params.get('enableBlockingInput', True)
|
||||
enableRemoteModi = params.get('enableRemoteModi', False)
|
||||
removeWallpaper = params.get('removeWallpaper', True)
|
||||
defaultManual = params.get('defaultManual', '')
|
||||
overrideManual = params.get('overrideManual', '')
|
||||
enablePrinter = params.get('enablePrinter', True)
|
||||
enableCamera = params.get('enableCamera', True)
|
||||
enableTerminal = params.get('enableTerminal', True)
|
||||
|
||||
if all(char.isascii() for char in filename):
|
||||
filename = re.sub(r'[^\w\s-]', '_', filename).strip()
|
||||
filename = filename.replace(" ","_")
|
||||
else:
|
||||
filename = "rustdesk"
|
||||
if not all(char.isascii() for char in appname):
|
||||
appname = "rustdesk"
|
||||
myuuid = str(uuid.uuid4())
|
||||
|
||||
try:
|
||||
iconfile = params.get('iconfile')
|
||||
if not iconfile:
|
||||
iconfile = params.get('iconbase64')
|
||||
iconlink_url, iconlink_uuid, iconlink_file = save_png(iconfile,myuuid,full_url,"icon.png")
|
||||
except Exception:
|
||||
print("获取图标失败,使用默认图标")
|
||||
iconlink_url = "false"
|
||||
iconlink_uuid = "false"
|
||||
iconlink_file = "false"
|
||||
try:
|
||||
logofile = params.get('logofile')
|
||||
if not logofile:
|
||||
logofile = params.get('logobase64')
|
||||
logolink_url, logolink_uuid, logolink_file = save_png(logofile,myuuid,full_url,"logo.png")
|
||||
except Exception:
|
||||
print("获取 Logo 失败")
|
||||
logolink_url = "false"
|
||||
logolink_uuid = "false"
|
||||
logolink_file = "false"
|
||||
try:
|
||||
privacyfile = params.get('privacyfile')
|
||||
if not privacyfile:
|
||||
privacyfile = params.get('privacybase64')
|
||||
privacylink_url, privacylink_uuid, privacylink_file = save_png(privacyfile,myuuid,full_url,"privacy.png")
|
||||
except Exception:
|
||||
print("获取隐私屏图片失败")
|
||||
privacylink_url = "false"
|
||||
privacylink_uuid = "false"
|
||||
privacylink_file = "false"
|
||||
|
||||
### 生成 custom.txt 配置 JSON,作为工作流输入
|
||||
decodedCustom = {}
|
||||
if direction != "Both":
|
||||
decodedCustom['conn-type'] = direction
|
||||
if installation == "installationN":
|
||||
decodedCustom['disable-installation'] = 'Y'
|
||||
if settings == "settingsN":
|
||||
decodedCustom['disable-settings'] = 'Y'
|
||||
if appname.lower() != "rustdesk" and appname != "":
|
||||
decodedCustom['app-name'] = appname
|
||||
decodedCustom['override-settings'] = {}
|
||||
decodedCustom['default-settings'] = {}
|
||||
if permPass != "":
|
||||
decodedCustom['password'] = permPass
|
||||
if theme != "system":
|
||||
if themeDorO == "default":
|
||||
if platform == "windows-x86":
|
||||
decodedCustom['default-settings']['allow-darktheme'] = 'Y' if theme == "dark" else 'N'
|
||||
else:
|
||||
decodedCustom['default-settings']['theme'] = theme
|
||||
elif themeDorO == "override":
|
||||
if platform == "windows-x86":
|
||||
decodedCustom['override-settings']['allow-darktheme'] = 'Y' if theme == "dark" else 'N'
|
||||
else:
|
||||
decodedCustom['override-settings']['theme'] = theme
|
||||
decodedCustom['enable-lan-discovery'] = 'N' if denyLan else 'Y'
|
||||
#decodedCustom['direct-server'] = 'Y' if enableDirectIP else 'N'
|
||||
decodedCustom['allow-auto-disconnect'] = 'Y' if autoClose else 'N'
|
||||
|
||||
if permissionsDorO == "default":
|
||||
decodedCustom['default-settings']['access-mode'] = permissionsType
|
||||
decodedCustom['default-settings']['enable-keyboard'] = 'Y' if enableKeyboard else 'N'
|
||||
decodedCustom['default-settings']['enable-clipboard'] = 'Y' if enableClipboard else 'N'
|
||||
decodedCustom['default-settings']['enable-file-transfer'] = 'Y' if enableFileTransfer else 'N'
|
||||
decodedCustom['default-settings']['enable-audio'] = 'Y' if enableAudio else 'N'
|
||||
decodedCustom['default-settings']['enable-tunnel'] = 'Y' if enableTCP else 'N'
|
||||
decodedCustom['default-settings']['enable-remote-restart'] = 'Y' if enableRemoteRestart else 'N'
|
||||
decodedCustom['default-settings']['enable-record-session'] = 'Y' if enableRecording else 'N'
|
||||
decodedCustom['default-settings']['enable-block-input'] = 'Y' if enableBlockingInput else 'N'
|
||||
decodedCustom['default-settings']['allow-remote-config-modification'] = 'Y' if enableRemoteModi else 'N'
|
||||
decodedCustom['default-settings']['direct-server'] = 'Y' if enableDirectIP else 'N'
|
||||
decodedCustom['default-settings']['verification-method'] = 'use-permanent-password' if hidecm else 'use-both-passwords'
|
||||
decodedCustom['default-settings']['approve-mode'] = passApproveMode
|
||||
decodedCustom['default-settings']['allow-hide-cm'] = 'Y' if hidecm else 'N'
|
||||
decodedCustom['default-settings']['allow-remove-wallpaper'] = 'Y' if removeWallpaper else 'N'
|
||||
decodedCustom['default-settings']['enable-remote-printer'] = 'Y' if enablePrinter else 'N'
|
||||
decodedCustom['default-settings']['enable-camera'] = 'Y' if enableCamera else 'N'
|
||||
decodedCustom['default-settings']['enable-terminal'] = 'Y' if enableTerminal else 'N'
|
||||
|
||||
|
||||
else:
|
||||
decodedCustom['override-settings']['access-mode'] = permissionsType
|
||||
decodedCustom['override-settings']['enable-keyboard'] = 'Y' if enableKeyboard else 'N'
|
||||
decodedCustom['override-settings']['enable-clipboard'] = 'Y' if enableClipboard else 'N'
|
||||
decodedCustom['override-settings']['enable-file-transfer'] = 'Y' if enableFileTransfer else 'N'
|
||||
decodedCustom['override-settings']['enable-audio'] = 'Y' if enableAudio else 'N'
|
||||
decodedCustom['override-settings']['enable-tunnel'] = 'Y' if enableTCP else 'N'
|
||||
decodedCustom['override-settings']['enable-remote-restart'] = 'Y' if enableRemoteRestart else 'N'
|
||||
decodedCustom['override-settings']['enable-record-session'] = 'Y' if enableRecording else 'N'
|
||||
decodedCustom['override-settings']['enable-block-input'] = 'Y' if enableBlockingInput else 'N'
|
||||
decodedCustom['override-settings']['allow-remote-config-modification'] = 'Y' if enableRemoteModi else 'N'
|
||||
decodedCustom['override-settings']['direct-server'] = 'Y' if enableDirectIP else 'N'
|
||||
decodedCustom['override-settings']['verification-method'] = 'use-permanent-password' if hidecm else 'use-both-passwords'
|
||||
decodedCustom['override-settings']['approve-mode'] = passApproveMode
|
||||
decodedCustom['override-settings']['allow-hide-cm'] = 'Y' if hidecm else 'N'
|
||||
decodedCustom['override-settings']['allow-remove-wallpaper'] = 'Y' if removeWallpaper else 'N'
|
||||
decodedCustom['override-settings']['enable-remote-printer'] = 'Y' if enablePrinter else 'N'
|
||||
decodedCustom['override-settings']['enable-camera'] = 'Y' if enableCamera else 'N'
|
||||
decodedCustom['override-settings']['enable-terminal'] = 'Y' if enableTerminal else 'N'
|
||||
if direction == 'incoming':
|
||||
decodedCustom['override-settings']['custom-rendezvous-server'] = server
|
||||
decodedCustom['override-settings']['api-server'] = apiServer
|
||||
|
||||
if defaultManual:
|
||||
for line in defaultManual.splitlines():
|
||||
if '=' in line:
|
||||
k, value = line.split('=', 1)
|
||||
decodedCustom['default-settings'][k.strip()] = value.strip()
|
||||
|
||||
if overrideManual:
|
||||
for line in overrideManual.splitlines():
|
||||
if '=' in line:
|
||||
k, value = line.split('=', 1)
|
||||
decodedCustom['override-settings'][k.strip()] = value.strip()
|
||||
|
||||
decodedCustomJson = json.dumps(decodedCustom)
|
||||
|
||||
string_bytes = decodedCustomJson.encode("ascii")
|
||||
base64_bytes = base64.b64encode(string_bytes)
|
||||
encodedCustom = base64_bytes.decode("ascii")
|
||||
|
||||
#### 触发构建平台工作流(GitHub / Gitea 可在后台「构建平台配置」切换)
|
||||
backend = build_backends.get_backend()
|
||||
workflow_file = backend.workflow_file_for(platform, selfhosted=bool(selfhosted))
|
||||
|
||||
inputs_raw = {
|
||||
"server":server,
|
||||
"serverPort":serverPort,
|
||||
"key":key,
|
||||
"apiServer":apiServer,
|
||||
"custom":encodedCustom,
|
||||
"uuid":myuuid,
|
||||
"iconlink_url":iconlink_url,
|
||||
"iconlink_uuid":iconlink_uuid,
|
||||
"iconlink_file":iconlink_file,
|
||||
"logolink_url":logolink_url,
|
||||
"logolink_uuid":logolink_uuid,
|
||||
"logolink_file":logolink_file,
|
||||
"privacylink_url":privacylink_url,
|
||||
"privacylink_uuid":privacylink_uuid,
|
||||
"privacylink_file":privacylink_file,
|
||||
"appname":appname,
|
||||
"genurl":app_settings.get_value('GENURL'),
|
||||
"urlLink":urlLink,
|
||||
"downloadLink":downloadLink,
|
||||
"delayFix": 'true' if delayFix else 'false',
|
||||
"rdgen":'true',
|
||||
"xOffline": 'true' if xOffline else 'false',
|
||||
"removeNewVersionNotif": 'true' if removeNewVersionNotif else 'false',
|
||||
"compname": compname,
|
||||
"androidappid":androidappid,
|
||||
"filename":filename
|
||||
}
|
||||
|
||||
# ZIP_PASSWORD 为空时 pyzipper 的 AES 加密会直接抛 RuntimeError(500),
|
||||
# 这里前置校验,返回可操作的中文提示(网页与 API 共用此函数)。
|
||||
zip_password = app_settings.get_value('ZIP_PASSWORD')
|
||||
if not zip_password.strip():
|
||||
return {
|
||||
"success": False,
|
||||
"error": (
|
||||
"配置压缩包密码(ZIP_PASSWORD)未配置,无法加密构建配置包。"
|
||||
"请联系管理员在后台「GitHub 构建配置 - 回调与加密」中设置,"
|
||||
"并确保与 GitHub 仓库 Secret「ZIP_PASSWORD」完全一致。"
|
||||
),
|
||||
"status_code": 500,
|
||||
}
|
||||
|
||||
temp_json_path = f"data_{uuid.uuid4()}.json"
|
||||
zip_filename = f"secrets_{uuid.uuid4()}.zip"
|
||||
zip_path = "temp_zips/%s" % (zip_filename)
|
||||
Path("temp_zips").mkdir(parents=True, exist_ok=True)
|
||||
|
||||
with open(temp_json_path, "w") as f:
|
||||
json.dump(inputs_raw, f)
|
||||
|
||||
with pyzipper.AESZipFile(zip_path, 'w', compression=pyzipper.ZIP_LZMA, encryption=pyzipper.WZ_AES) as zf:
|
||||
zf.setpassword(zip_password.encode())
|
||||
zf.write(temp_json_path, arcname="secrets.json")
|
||||
|
||||
if os.path.exists(temp_json_path):
|
||||
os.remove(temp_json_path)
|
||||
|
||||
zipJson = {}
|
||||
zipJson['url'] = full_url
|
||||
zipJson['file'] = zip_filename
|
||||
|
||||
zip_url = json.dumps(zipJson)
|
||||
|
||||
dispatch_inputs = {
|
||||
"version": version,
|
||||
"zip_url": zip_url,
|
||||
}
|
||||
# 保存提交时的配置快照,供终态任务「一键重试」。
|
||||
# 仅保留 JSON 安全类型(上传文件对象跳过——网页提交时图片已转为
|
||||
# iconbase64/logobase64/privacybase64 data URL 字符串,不会丢失)。
|
||||
config_snapshot = {}
|
||||
for _k, _v in params.items():
|
||||
if isinstance(_v, (str, int, float, bool)) or _v is None:
|
||||
config_snapshot[_k] = _v
|
||||
|
||||
new_github_run = GithubRun(
|
||||
uuid=myuuid,
|
||||
status="Starting generator...please wait",
|
||||
platform=platform,
|
||||
filename=filename,
|
||||
config_data=config_snapshot,
|
||||
backend=backend.name,
|
||||
created_by=(user if user is not None and getattr(user, 'is_authenticated', False) else None),
|
||||
)
|
||||
try:
|
||||
dispatch_result = backend.dispatch(workflow_file, dispatch_inputs, timeout=20)
|
||||
new_github_run.github_run_id = dispatch_result.run_id
|
||||
new_github_run.status = "in_progress"
|
||||
new_github_run.save()
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"uuid": myuuid,
|
||||
"filename": filename,
|
||||
"platform": platform,
|
||||
"log_url": dispatch_result.html_url,
|
||||
"run": new_github_run,
|
||||
}
|
||||
except build_backends.base.BackendError as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": e.message,
|
||||
"status_code": 502
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"触发构建服务时发生未预期的错误:{str(e)}",
|
||||
"status_code": 502
|
||||
}
|
||||
|
||||
|
||||
def _get_run_status(uuid_val):
|
||||
"""
|
||||
查询构建状态(网页与 JSON API 共用)。
|
||||
"""
|
||||
try:
|
||||
gh_run = GithubRun.objects.get(uuid=uuid_val)
|
||||
except GithubRun.DoesNotExist:
|
||||
return {"found": False}
|
||||
|
||||
# 历史任务按其记录的构建平台查询,平台切换后不影响在途任务
|
||||
backend = build_backends.get_backend(gh_run.backend or 'github')
|
||||
github_log_url = backend.log_url(gh_run.github_run_id) if gh_run.github_run_id else ''
|
||||
|
||||
if gh_run.status not in ['success', 'failure', 'cancelled', 'timed_out', 'skipped'] and gh_run.github_run_id:
|
||||
try:
|
||||
info = backend.get_run(gh_run.github_run_id)
|
||||
if info is not None and info.finished:
|
||||
gh_run.status = info.status
|
||||
gh_run.save()
|
||||
except Exception as e:
|
||||
print(f"查询构建状态出错:{e}")
|
||||
|
||||
return {
|
||||
"found": True,
|
||||
"status": gh_run.status,
|
||||
"github_log_url": github_log_url,
|
||||
"gh_run": gh_run
|
||||
}
|
||||
|
||||
|
||||
@login_required
|
||||
def generator_view(request):
|
||||
# 版本下拉始终刷新为官方仓库最新 tags(内部带缓存与内置兜底,不会拖慢页面)
|
||||
version_choices = rdeskVersions.version_choices()
|
||||
if request.method == 'POST':
|
||||
form = GenerateForm(request.POST, request.FILES)
|
||||
form.fields['version'].choices = version_choices
|
||||
if form.is_valid():
|
||||
params = form.cleaned_data
|
||||
full_url = f"{app_settings.get_value('PROTOCOL')}://{request.get_host()}"
|
||||
result = generate_custom_client(params, full_url, user=request.user)
|
||||
if result['success']:
|
||||
status_label, status_badge = _status_extra("Starting generator...please wait")
|
||||
return render(request, 'waiting.html', {
|
||||
'filename': result['filename'],
|
||||
'uuid': result['uuid'],
|
||||
'status': "Starting generator...please wait",
|
||||
'status_label': status_label,
|
||||
'status_badge': status_badge,
|
||||
'platform': result['platform'],
|
||||
'log_url': result['log_url']
|
||||
})
|
||||
else:
|
||||
messages.error(request, result['error'])
|
||||
else:
|
||||
messages.error(request, "表单校验未通过,请根据下方提示修正后重新提交。")
|
||||
else:
|
||||
form = GenerateForm()
|
||||
form.fields['version'].choices = version_choices
|
||||
|
||||
_, version_source, version_ts = rdeskVersions.get_versions()
|
||||
profiles = request.user.saved_configs.all().values('id', 'name')
|
||||
return render(request, 'generator.html', {
|
||||
'form': form,
|
||||
'profiles': profiles,
|
||||
'version_source': version_source,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
def check_for_file(request):
|
||||
filename = request.GET.get('filename')
|
||||
uuid_val = request.GET.get('uuid')
|
||||
platform = request.GET.get('platform')
|
||||
|
||||
result = _get_run_status(uuid_val)
|
||||
if not result['found']:
|
||||
raise Http404("未找到对应的构建任务")
|
||||
|
||||
gh_run = result['gh_run']
|
||||
if not _can_access_run(request, gh_run):
|
||||
raise PermissionDenied("无权查看该构建任务")
|
||||
|
||||
github_log_url = result['github_log_url']
|
||||
status_label, status_badge = _status_extra(gh_run.status)
|
||||
|
||||
context = {
|
||||
'filename': filename,
|
||||
'uuid': uuid_val,
|
||||
'platform': platform,
|
||||
'status': gh_run.status,
|
||||
'status_label': status_label,
|
||||
'status_badge': status_badge,
|
||||
'log_url': github_log_url,
|
||||
}
|
||||
|
||||
if gh_run.status == "success":
|
||||
return render(request, 'generated.html', context)
|
||||
elif gh_run.status in ['failure', 'cancelled', 'timed_out', 'skipped', 'action_required']:
|
||||
return render(request, 'failure.html', context)
|
||||
else:
|
||||
return render(request, 'waiting.html', context)
|
||||
|
||||
|
||||
@login_required
|
||||
def download(request):
|
||||
filename = request.GET.get('filename', '')
|
||||
uuid_val = request.GET.get('uuid', '')
|
||||
if not filename or not uuid_val:
|
||||
raise Http404
|
||||
|
||||
gh_run = GithubRun.objects.filter(uuid=uuid_val).first()
|
||||
if gh_run is None:
|
||||
raise Http404("未找到对应的构建任务")
|
||||
if not _can_access_run(request, gh_run):
|
||||
raise PermissionDenied("无权下载该文件")
|
||||
|
||||
# 防路径穿越:最终路径必须位于 exe/<uuid>/ 目录内
|
||||
base_dir = os.path.abspath(os.path.join('exe', uuid_val))
|
||||
file_path = os.path.abspath(os.path.join(base_dir, os.path.basename(filename)))
|
||||
if not file_path.startswith(base_dir + os.sep) or not os.path.isfile(file_path):
|
||||
raise Http404("文件不存在或尚未生成完成")
|
||||
|
||||
from django.http import FileResponse
|
||||
return FileResponse(
|
||||
open(file_path, 'rb'),
|
||||
content_type='application/octet-stream',
|
||||
as_attachment=True,
|
||||
filename=os.path.basename(filename),
|
||||
)
|
||||
|
||||
|
||||
def get_png(request):
|
||||
"""供 GitHub Actions 工作流下载构建所用的图片资源。"""
|
||||
filename = request.GET.get('filename', '')
|
||||
uuid_val = request.GET.get('uuid', '')
|
||||
if not filename or not uuid_val:
|
||||
raise Http404
|
||||
|
||||
base_dir = os.path.abspath(os.path.join('png', uuid_val))
|
||||
file_path = os.path.abspath(os.path.join(base_dir, os.path.basename(filename)))
|
||||
if not file_path.startswith(base_dir + os.sep) or not os.path.isfile(file_path):
|
||||
raise Http404("图片不存在")
|
||||
|
||||
from django.http import FileResponse
|
||||
return FileResponse(open(file_path, 'rb'), content_type='image/png')
|
||||
|
||||
|
||||
@csrf_exempt
|
||||
def update_github_run(request):
|
||||
"""GitHub Actions 回调:更新构建状态。"""
|
||||
denied = _webhook_denied(request)
|
||||
if denied:
|
||||
return denied
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return HttpResponse(status=400)
|
||||
myuuid = data.get('uuid')
|
||||
mystatus = data.get('status')
|
||||
if not myuuid or not mystatus:
|
||||
return HttpResponse(status=400)
|
||||
GithubRun.objects.filter(Q(uuid=myuuid)).update(status=mystatus)
|
||||
return HttpResponse('')
|
||||
|
||||
|
||||
def resize_and_encode_icon(imagefile):
|
||||
maxWidth = 200
|
||||
try:
|
||||
with io.BytesIO() as image_buffer:
|
||||
for chunk in imagefile.chunks():
|
||||
image_buffer.write(chunk)
|
||||
image_buffer.seek(0)
|
||||
|
||||
img = Image.open(image_buffer)
|
||||
imgcopy = img.copy()
|
||||
except (IOError, OSError):
|
||||
raise ValueError("上传的文件不是有效的图片格式。")
|
||||
|
||||
# 无需缩放时直接返回
|
||||
if img.size[0] <= maxWidth:
|
||||
with io.BytesIO() as image_buffer:
|
||||
imgcopy.save(image_buffer, format=imagefile.content_type.split('/')[1])
|
||||
image_buffer.seek(0)
|
||||
return_image = ContentFile(image_buffer.read(), name=imagefile.name)
|
||||
return base64.b64encode(return_image.read())
|
||||
|
||||
# 等比例缩放
|
||||
wpercent = (maxWidth / float(img.size[0]))
|
||||
hsize = int((float(img.size[1]) * float(wpercent)))
|
||||
|
||||
# LANCZOS 高质量重采样
|
||||
imgcopy = imgcopy.resize((maxWidth, hsize), Image.Resampling.LANCZOS)
|
||||
|
||||
with io.BytesIO() as resized_image_buffer:
|
||||
imgcopy.save(resized_image_buffer, format=imagefile.content_type.split('/')[1])
|
||||
resized_image_buffer.seek(0)
|
||||
|
||||
resized_imagefile = ContentFile(resized_image_buffer.read(), name=imagefile.name)
|
||||
|
||||
resized64 = base64.b64encode(resized_imagefile.read())
|
||||
return resized64
|
||||
|
||||
# 以下接口供外部来源(如自建 RustDesk API 服务器)调用
|
||||
@csrf_exempt
|
||||
def startgh(request):
|
||||
denied = _webhook_denied(request)
|
||||
if denied:
|
||||
return denied
|
||||
data_ = json.loads(request.body)
|
||||
#### 触发构建平台工作流(GitHub / Gitea 按后台配置切换)
|
||||
backend = build_backends.get_backend()
|
||||
workflow_file = 'generator-' + str(data_.get('platform')) + '.yml'
|
||||
inputs = {
|
||||
"server":data_.get('server'),
|
||||
"key":data_.get('key'),
|
||||
"apiServer":data_.get('apiServer'),
|
||||
"custom":data_.get('custom'),
|
||||
"uuid":data_.get('uuid'),
|
||||
"iconlink":data_.get('iconlink'),
|
||||
"logolink":data_.get('logolink'),
|
||||
"appname":data_.get('appname'),
|
||||
"extras":data_.get('extras'),
|
||||
"filename":data_.get('filename')
|
||||
}
|
||||
try:
|
||||
result = backend.dispatch(workflow_file, inputs, timeout=20)
|
||||
print(result)
|
||||
except build_backends.base.BackendError as e:
|
||||
# 历史行为:本端点无论成败都返回 204,仅记录日志
|
||||
print(f"startgh 触发失败:{e.message}")
|
||||
return HttpResponse(status=204)
|
||||
|
||||
def save_png(file, uuid, domain, name):
|
||||
file_save_path = "png/%s/%s" % (uuid, name)
|
||||
Path("png/%s" % uuid).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if isinstance(file, str): # base64 字符串
|
||||
try:
|
||||
header, encoded = file.split(';base64,')
|
||||
decoded_img = base64.b64decode(encoded)
|
||||
file = ContentFile(decoded_img, name=name) # 类文件对象
|
||||
except ValueError:
|
||||
print("base64 数据无效")
|
||||
return None
|
||||
except Exception as e:
|
||||
print(f"base64 解码出错:{e}")
|
||||
return None
|
||||
|
||||
with open(file_save_path, "wb+") as f:
|
||||
for chunk in file.chunks():
|
||||
f.write(chunk)
|
||||
return domain, uuid, name
|
||||
|
||||
@csrf_exempt
|
||||
def save_custom_client(request):
|
||||
"""GitHub Actions 回调:上传构建产物。"""
|
||||
denied = _webhook_denied(request)
|
||||
if denied:
|
||||
return denied
|
||||
file = request.FILES.get('file')
|
||||
myuuid = request.POST.get('uuid')
|
||||
if not file or not myuuid:
|
||||
return HttpResponse("缺少文件或任务 UUID", status=400)
|
||||
file_save_path = "exe/%s/%s" % (myuuid, os.path.basename(file.name))
|
||||
Path("exe/%s" % myuuid).mkdir(parents=True, exist_ok=True)
|
||||
with open(file_save_path, "wb+") as f:
|
||||
for chunk in file.chunks():
|
||||
f.write(chunk)
|
||||
|
||||
return HttpResponse("文件保存成功")
|
||||
|
||||
@csrf_exempt
|
||||
def cleanup_secrets(request):
|
||||
"""GitHub Actions 回调:清理加密的临时配置包。"""
|
||||
denied = _webhook_denied(request)
|
||||
if denied:
|
||||
return denied
|
||||
try:
|
||||
data = json.loads(request.body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return HttpResponse("请求体不是有效的 JSON", status=400)
|
||||
my_uuid = data.get('uuid')
|
||||
|
||||
if not my_uuid:
|
||||
return HttpResponse("缺少任务 UUID", status=400)
|
||||
|
||||
temp_dir = os.path.join('temp_zips')
|
||||
if not os.path.isdir(temp_dir):
|
||||
return HttpResponse("清理完成", status=200)
|
||||
|
||||
for filename in os.listdir(temp_dir):
|
||||
if my_uuid in filename and filename.endswith('.zip'):
|
||||
file_path = os.path.join(temp_dir, filename)
|
||||
try:
|
||||
os.remove(file_path)
|
||||
print(f"已删除 {file_path}")
|
||||
except OSError as e:
|
||||
print(f"删除文件失败:{e}")
|
||||
|
||||
return HttpResponse("清理完成", status=200)
|
||||
|
||||
def get_zip(request):
|
||||
"""供 GitHub Actions 工作流下载 AES 加密的构建配置包。"""
|
||||
filename = request.GET.get('filename', '')
|
||||
base_dir = os.path.abspath('temp_zips')
|
||||
file_path = os.path.abspath(os.path.join(base_dir, os.path.basename(filename)))
|
||||
if not file_path.startswith(base_dir + os.sep) or not os.path.isfile(file_path):
|
||||
return HttpResponseForbidden("无效的文件名")
|
||||
from django.http import FileResponse
|
||||
return FileResponse(
|
||||
open(file_path, 'rb'),
|
||||
content_type='application/zip',
|
||||
as_attachment=True,
|
||||
filename=os.path.basename(filename),
|
||||
)
|
||||
@@ -0,0 +1,14 @@
|
||||
"""统一的中文错误页面。"""
|
||||
from django.shortcuts import render
|
||||
|
||||
|
||||
def handler403(request, exception=None):
|
||||
return render(request, '403.html', status=403)
|
||||
|
||||
|
||||
def handler404(request, exception=None):
|
||||
return render(request, '404.html', status=404)
|
||||
|
||||
|
||||
def handler500(request):
|
||||
return render(request, '500.html', status=500)
|
||||
@@ -0,0 +1,511 @@
|
||||
"""登录后的业务页面:我的构建、接口令牌、管理仪表盘、用户管理。"""
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
from urllib.parse import quote
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.auth import update_session_auth_hash
|
||||
from django.contrib.auth.decorators import login_required, user_passes_test
|
||||
from django.contrib.auth.models import User
|
||||
from django.db.models import Count
|
||||
from django.http import (
|
||||
JsonResponse, HttpResponseRedirect, HttpResponse, Http404, FileResponse,
|
||||
)
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.views.decorators.http import require_POST
|
||||
|
||||
from . import app_settings, build_backends, maintenance
|
||||
from .forms import (
|
||||
AdminUserCreateForm, AdminUserEditForm, ApiTokenForm, GenerateForm, GitHubSettingsForm,
|
||||
MaintenanceSettingsForm,
|
||||
)
|
||||
from .models import ApiToken, GithubRun, SavedConfig
|
||||
|
||||
|
||||
def staff_required(view_func):
|
||||
"""仅管理员(is_staff)可访问。"""
|
||||
return user_passes_test(lambda u: u.is_authenticated and u.is_staff)(view_func)
|
||||
|
||||
|
||||
@login_required
|
||||
def my_builds(request):
|
||||
builds = GithubRun.objects.filter(created_by=request.user)
|
||||
return render(request, 'my_builds.html', {'builds': builds})
|
||||
|
||||
|
||||
def _remove_run_artifacts(run):
|
||||
"""删除任务对应的本地安装包与图片目录(忽略不存在的情况)。"""
|
||||
if run.uuid:
|
||||
shutil.rmtree(os.path.join('exe', run.uuid), ignore_errors=True)
|
||||
shutil.rmtree(os.path.join('png', run.uuid), ignore_errors=True)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def delete_my_build(request, run_id):
|
||||
"""用户删除自己的已结束任务;进行中的任务不允许删除(避免与 GitHub 回调竞态)。"""
|
||||
run = get_object_or_404(GithubRun, pk=run_id, created_by=request.user)
|
||||
if not run.is_finished():
|
||||
messages.error(request, "该任务仍在进行中,请等待结束(或先在 GitHub Actions 中取消)后再删除。")
|
||||
else:
|
||||
_remove_run_artifacts(run)
|
||||
run.delete()
|
||||
messages.success(request, f"构建任务「{run.filename or run.short_uuid()}」及本地产物已删除。")
|
||||
return redirect('my_builds')
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def retry_build(request, run_id):
|
||||
"""用任务提交时保存的配置快照重新派发一个新构建(原任务保留作历史)。"""
|
||||
qs = GithubRun.objects.all() if request.user.is_staff else GithubRun.objects.filter(created_by=request.user)
|
||||
run = get_object_or_404(qs, pk=run_id)
|
||||
|
||||
if not run.is_finished():
|
||||
messages.error(request, "该任务尚未结束,无需重试。")
|
||||
return redirect('my_builds')
|
||||
if not run.config_data:
|
||||
messages.error(request, "该任务提交时未保存配置快照(较早的历史任务),无法一键重试,请重新填写配置后提交。")
|
||||
return redirect('my_builds')
|
||||
|
||||
# 局部导入避免与 views 模块在导入期形成耦合
|
||||
from .views import generate_custom_client
|
||||
full_url = f"{app_settings.get_value('PROTOCOL')}://{request.get_host()}"
|
||||
# 新任务归属原提交者(管理员代重试时也保持原归属);匿名历史任务归当前操作者
|
||||
owner = run.created_by or request.user
|
||||
result = generate_custom_client(dict(run.config_data), full_url, user=owner)
|
||||
if result.get('success'):
|
||||
messages.success(
|
||||
request,
|
||||
f"已用原配置重新提交构建(新任务:{result.get('filename', '')}),可在下方查看进度。",
|
||||
)
|
||||
else:
|
||||
messages.error(request, f"重试失败:{result.get('error', '未知错误')}")
|
||||
return redirect('my_builds')
|
||||
|
||||
|
||||
@login_required
|
||||
def api_tokens(request):
|
||||
new_plain_key = None
|
||||
if request.method == 'POST':
|
||||
form = ApiTokenForm(request.POST)
|
||||
if form.is_valid():
|
||||
token = ApiToken.objects.create(user=request.user, name=form.cleaned_data['name'])
|
||||
new_plain_key = token.key
|
||||
messages.success(request, "接口令牌创建成功,请立即复制保存(离开本页后将不再完整显示)。")
|
||||
form = ApiTokenForm()
|
||||
else:
|
||||
form = ApiTokenForm()
|
||||
|
||||
tokens = ApiToken.objects.filter(user=request.user)
|
||||
return render(request, 'tokens.html', {
|
||||
'form': form,
|
||||
'tokens': tokens,
|
||||
'new_plain_key': new_plain_key,
|
||||
})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def delete_token(request, key):
|
||||
token = get_object_or_404(ApiToken, key=key, user=request.user)
|
||||
token.delete()
|
||||
messages.success(request, "接口令牌已删除。")
|
||||
return redirect('api_tokens')
|
||||
|
||||
|
||||
@staff_required
|
||||
def dashboard(request):
|
||||
builds = GithubRun.objects.select_related('created_by').all()[:100]
|
||||
stats = {
|
||||
'total': GithubRun.objects.count(),
|
||||
'in_progress': GithubRun.objects.exclude(
|
||||
status__in=['success', 'failure', 'cancelled', 'timed_out', 'skipped']
|
||||
).count(),
|
||||
'success': GithubRun.objects.filter(status='success').count(),
|
||||
'failure': GithubRun.objects.filter(status__in=['failure', 'timed_out', 'cancelled']).count(),
|
||||
'users': User.objects.count(),
|
||||
'tokens': ApiToken.objects.filter(is_active=True).count(),
|
||||
}
|
||||
return render(request, 'dashboard.html', {'builds': builds, 'stats': stats})
|
||||
|
||||
|
||||
@staff_required
|
||||
@require_POST
|
||||
def delete_build(request, run_id):
|
||||
run = get_object_or_404(GithubRun, pk=run_id)
|
||||
_remove_run_artifacts(run)
|
||||
run.delete()
|
||||
messages.success(request, "构建任务及本地产物已删除。")
|
||||
return redirect('dashboard')
|
||||
|
||||
|
||||
# ===== 维护设置(定时清理 + 数据库备份) =====
|
||||
|
||||
@staff_required
|
||||
def maintenance_settings(request):
|
||||
if request.method == 'POST':
|
||||
form = MaintenanceSettingsForm(request.POST)
|
||||
if form.is_valid():
|
||||
app_settings.set_value(
|
||||
'BUILD_RETENTION_DAYS', str(form.cleaned_data['build_retention_days'])
|
||||
)
|
||||
app_settings.set_value(
|
||||
'DB_BACKUP_ENABLED', '1' if form.cleaned_data['db_backup_enabled'] else '0'
|
||||
)
|
||||
app_settings.set_value(
|
||||
'DB_BACKUP_KEEP', str(form.cleaned_data['db_backup_keep'])
|
||||
)
|
||||
messages.success(request, "维护设置已保存,定时任务下一轮检查时自动生效。")
|
||||
return redirect('maintenance_settings')
|
||||
else:
|
||||
form = MaintenanceSettingsForm(initial={
|
||||
'build_retention_days': maintenance.retention_days(),
|
||||
'db_backup_enabled': maintenance.backup_enabled(),
|
||||
'db_backup_keep': maintenance.backup_keep(),
|
||||
})
|
||||
|
||||
return render(request, 'maintenance_settings.html', {
|
||||
'form': form,
|
||||
'status': maintenance.maintenance_status(),
|
||||
})
|
||||
|
||||
|
||||
@staff_required
|
||||
@require_POST
|
||||
def maintenance_run_now(request):
|
||||
"""手动立即执行一轮维护(清理过期任务 + 立即备份数据库)。"""
|
||||
result = maintenance.run_maintenance(force_backup=True)
|
||||
if result['backup_created']:
|
||||
backup_name = os.path.basename(result['backup_created'])
|
||||
messages.success(
|
||||
request,
|
||||
f"维护已执行:清理过期任务 {result['deleted_builds']} 条、"
|
||||
f"临时文件 {result['deleted_temp_files']} 个,并已创建新备份 {backup_name}。",
|
||||
)
|
||||
else:
|
||||
messages.success(
|
||||
request,
|
||||
f"维护已执行:清理过期任务 {result['deleted_builds']} 条、"
|
||||
f"临时文件 {result['deleted_temp_files']} 个;本轮未创建数据库备份。",
|
||||
)
|
||||
return redirect('maintenance_settings')
|
||||
|
||||
|
||||
@staff_required
|
||||
def download_backup(request, filename):
|
||||
"""下载指定的数据库备份文件(文件名严格白名单,防路径穿越)。"""
|
||||
if not re.fullmatch(r'db-\d{8}-\d{6}\.sqlite3', filename):
|
||||
raise Http404("非法的备份文件名")
|
||||
file_path = maintenance.backup_dir() / filename
|
||||
if not file_path.is_file():
|
||||
raise Http404("备份文件不存在")
|
||||
return FileResponse(
|
||||
open(file_path, 'rb'),
|
||||
content_type='application/octet-stream',
|
||||
as_attachment=True,
|
||||
filename=filename,
|
||||
)
|
||||
|
||||
|
||||
@staff_required
|
||||
def user_list(request):
|
||||
if request.method == 'POST':
|
||||
form = AdminUserCreateForm(request.POST)
|
||||
if form.is_valid():
|
||||
User.objects.create_user(
|
||||
username=form.cleaned_data['username'],
|
||||
email=form.cleaned_data.get('email', ''),
|
||||
password=form.cleaned_data['password'],
|
||||
is_staff=form.cleaned_data.get('is_staff', False),
|
||||
)
|
||||
messages.success(request, f"用户「{form.cleaned_data['username']}」创建成功。")
|
||||
return redirect('user_list')
|
||||
else:
|
||||
form = AdminUserCreateForm()
|
||||
|
||||
users = User.objects.annotate(build_count=Count('builds')).order_by('-date_joined')
|
||||
return render(request, 'user_list.html', {'users': users, 'form': form})
|
||||
|
||||
|
||||
@staff_required
|
||||
def user_edit(request, user_id):
|
||||
target = get_object_or_404(User, pk=user_id)
|
||||
if request.method == 'POST':
|
||||
form = AdminUserEditForm(request.POST)
|
||||
if form.is_valid():
|
||||
target.email = form.cleaned_data.get('email', '')
|
||||
target.is_active = form.cleaned_data.get('is_active', False)
|
||||
target.is_staff = form.cleaned_data.get('is_staff', False)
|
||||
new_password = form.cleaned_data.get('new_password')
|
||||
if new_password:
|
||||
target.set_password(new_password)
|
||||
# 管理员修改自己的密码后保持登录状态
|
||||
if request.user.pk == target.pk:
|
||||
update_session_auth_hash(request, target)
|
||||
target.save()
|
||||
messages.success(request, f"用户「{target.username}」已更新。")
|
||||
return redirect('user_list')
|
||||
else:
|
||||
form = AdminUserEditForm(initial={
|
||||
'email': target.email,
|
||||
'is_active': target.is_active,
|
||||
'is_staff': target.is_staff,
|
||||
})
|
||||
return render(request, 'user_form.html', {'form': form, 'target': target})
|
||||
|
||||
|
||||
# 非密钥配置项:设置键 -> 表单字段名
|
||||
_PLAIN_FIELDS = [
|
||||
('BUILD_PLATFORM', 'build_platform'),
|
||||
('GITEA_SERVER_URL', 'gitea_server_url'),
|
||||
('GITEA_OWNER', 'gitea_owner'),
|
||||
('GITEA_REPO', 'gitea_repo'),
|
||||
('GITEA_BRANCH', 'gitea_branch'),
|
||||
('GHUSER', 'ghuser'),
|
||||
('REPONAME', 'reponame'),
|
||||
('GHBRANCH', 'ghbranch'),
|
||||
('GENURL', 'genurl'),
|
||||
('PROTOCOL', 'protocol'),
|
||||
]
|
||||
# 密钥配置项:设置键 -> (输入字段名, 清除复选框字段名)
|
||||
_SECRET_FIELDS = [
|
||||
('GITEA_TOKEN', ('gitea_token', 'clear_gitea_token')),
|
||||
('GITEA_PROXY', ('gitea_proxy', 'clear_gitea_proxy')),
|
||||
('GHBEARER', ('ghbearer', 'clear_ghbearer')),
|
||||
('ZIP_PASSWORD', ('zip_password', 'clear_zip_password')),
|
||||
('SH_SECRET', ('sh_secret', 'clear_sh_secret')),
|
||||
('WEBHOOK_SECRET', ('webhook_secret', 'clear_webhook_secret')),
|
||||
('GH_PROXY', ('gh_proxy', 'clear_gh_proxy')),
|
||||
]
|
||||
|
||||
|
||||
def _settings_rows(form):
|
||||
"""把配置定义与绑定后的表单字段组合成渲染行。"""
|
||||
rows = []
|
||||
for item in app_settings.all_settings():
|
||||
field_name = item['key'].lower()
|
||||
# 仅渲染本表单包含的设置组(maintenance 组在独立维护页处理)
|
||||
if field_name not in form.fields:
|
||||
continue
|
||||
row = dict(item)
|
||||
row['field'] = form[field_name]
|
||||
row['source_label'] = app_settings.SOURCE_LABELS[row['source']]
|
||||
if item['sensitive']:
|
||||
row['clear_field'] = form[f'clear_{field_name}']
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
@staff_required
|
||||
def github_settings(request):
|
||||
"""后台 GitHub 构建配置:DB 覆盖优先,环境变量继续作为回退。"""
|
||||
if request.method == 'POST':
|
||||
form = GitHubSettingsForm(request.POST)
|
||||
if form.is_valid():
|
||||
cleaned = form.cleaned_data
|
||||
|
||||
# 非密钥项:提交值与环境变量(或默认值)一致时不写覆盖,保持来源可追溯
|
||||
for key, field_name in _PLAIN_FIELDS:
|
||||
value = cleaned.get(field_name, '').strip()
|
||||
env_value = os.environ.get(app_settings.DEFS_BY_KEY[key]['env'])
|
||||
if env_value is not None:
|
||||
fallback = env_value.strip()
|
||||
else:
|
||||
fallback = app_settings.DEFS_BY_KEY[key]['default']
|
||||
if value == fallback:
|
||||
app_settings.delete_value(key)
|
||||
else:
|
||||
app_settings.set_value(key, value)
|
||||
|
||||
# 密钥项:勾选清除则回退;填写新值则覆盖;留空保持不变
|
||||
for key, (field_name, clear_name) in _SECRET_FIELDS:
|
||||
if cleaned.get(clear_name):
|
||||
app_settings.delete_value(key)
|
||||
elif cleaned.get(field_name):
|
||||
app_settings.set_value(key, cleaned[field_name])
|
||||
|
||||
messages.success(request, "构建平台配置已保存。")
|
||||
return redirect('github_settings')
|
||||
else:
|
||||
# 仅回填非密钥项;密钥绝不回显明文
|
||||
initial = {
|
||||
item['key'].lower(): item['value']
|
||||
for item in app_settings.all_settings()
|
||||
if not item['sensitive']
|
||||
}
|
||||
form = GitHubSettingsForm(initial=initial)
|
||||
|
||||
rows = _settings_rows(form)
|
||||
groups = [
|
||||
('platform', '构建平台', '选择实际执行构建任务的平台;切换后仅影响新提交的任务,历史任务仍在原平台查询。'),
|
||||
('gitea', 'Gitea 连接', '自建 Gitea(建议 1.27 或更高版本)的服务器、令牌与仓库信息;构建机(Linux/Windows/macOS runner)需自行准备。'),
|
||||
('basic', 'GitHub 基础连接', 'GitHub 仓库与访问令牌,用于触发 Actions 构建。'),
|
||||
('callback', '回调与加密', '构建产物回传地址与配置包加密,两个平台共用,需与仓库 Secret 保持一致。'),
|
||||
('advanced', '高级 / 安全', '回调签名校验两平台共用;自托管 Runner 密钥仅 GitHub 自托管构建需要,均按需配置。'),
|
||||
('network', 'GitHub 网络代理', '服务器访问 GitHub(触发构建、查询状态、拉取版本列表)的网络出口;能直连则无需配置。'),
|
||||
]
|
||||
return render(request, 'site_settings.html', {
|
||||
'rows': rows,
|
||||
'groups': groups,
|
||||
'source_labels': app_settings.SOURCE_LABELS,
|
||||
'build_platform': build_backends.current_platform(),
|
||||
'github_ready': app_settings.is_github_configured(),
|
||||
'warnings': app_settings.config_warnings(),
|
||||
'gh_user': app_settings.get_value('GHUSER'),
|
||||
'gh_repo': app_settings.get_value('REPONAME'),
|
||||
'gitea_server': app_settings.get_value('GITEA_SERVER_URL').strip().rstrip('/'),
|
||||
'gitea_owner': app_settings.get_value('GITEA_OWNER'),
|
||||
'gitea_repo': app_settings.get_value('GITEA_REPO'),
|
||||
})
|
||||
|
||||
|
||||
@staff_required
|
||||
@require_POST
|
||||
def test_build(request):
|
||||
"""测试当前所选(或表单中刚选的)构建平台的连接。"""
|
||||
platform = (request.POST.get('platform') or '').strip().lower()
|
||||
if platform not in build_backends.BACKENDS:
|
||||
platform = build_backends.current_platform()
|
||||
result = build_backends.get_backend(platform).test_connection()
|
||||
return JsonResponse(result)
|
||||
|
||||
|
||||
@staff_required
|
||||
@require_POST
|
||||
def test_github(request):
|
||||
"""兼容旧路由:直接测试 GitHub 连接。"""
|
||||
return JsonResponse(build_backends.get_backend('github').test_connection())
|
||||
|
||||
|
||||
# ===== 构建配置方案(与账号绑定,可载入/导出/导入) =====
|
||||
|
||||
# 允许进入方案 JSON 的字段(即 GenerateForm 全部字段;图片以 data URL 字符串存储)
|
||||
_PROFILE_KEYS = set(GenerateForm.base_fields.keys())
|
||||
_PROFILE_MAX_BYTES = 4 * 1024 * 1024 # 含三张 base64 图片,上限 4MB
|
||||
|
||||
|
||||
def _sanitize_profile_data(raw):
|
||||
"""校验并清洗方案 JSON:仅保留表单已知字段的原始值。"""
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("配置内容必须是 JSON 对象。")
|
||||
cleaned = {}
|
||||
for key, value in raw.items():
|
||||
if key not in _PROFILE_KEYS:
|
||||
continue
|
||||
if isinstance(value, bool) or value is None:
|
||||
if value is not None:
|
||||
cleaned[key] = value
|
||||
elif isinstance(value, (str, int, float)):
|
||||
cleaned[key] = value
|
||||
if len(json.dumps(cleaned, ensure_ascii=False).encode('utf-8')) > _PROFILE_MAX_BYTES:
|
||||
raise ValueError("配置文件过大(含图片上限 4MB)。")
|
||||
return cleaned
|
||||
|
||||
|
||||
def _parse_profile_upload(upload):
|
||||
try:
|
||||
raw_text = upload.read().decode('utf-8')
|
||||
except (UnicodeDecodeError, AttributeError):
|
||||
raise ValueError("配置文件编码无效,请上传本平台导出的 UTF-8 JSON 文件。")
|
||||
if len(raw_text.encode('utf-8')) > _PROFILE_MAX_BYTES:
|
||||
raise ValueError("配置文件过大(上限 4MB)。")
|
||||
try:
|
||||
raw = json.loads(raw_text)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
raise ValueError("配置文件不是有效的 JSON。")
|
||||
return _sanitize_profile_data(raw)
|
||||
|
||||
|
||||
@login_required
|
||||
def saved_configs(request):
|
||||
"""配置方案管理页:列表、删除、导出、导入。"""
|
||||
if request.method == 'POST':
|
||||
upload = request.FILES.get('config_file')
|
||||
name = request.POST.get('name', '').strip()
|
||||
if not upload:
|
||||
messages.error(request, "请选择要导入的 .json 配置文件。")
|
||||
return redirect('saved_configs')
|
||||
if not name:
|
||||
name = os.path.splitext(upload.name)[0].strip()
|
||||
name = name[:100]
|
||||
if not name:
|
||||
messages.error(request, "请填写方案名称。")
|
||||
return redirect('saved_configs')
|
||||
if SavedConfig.objects.filter(owner=request.user, name=name).exists():
|
||||
messages.error(request, f"已存在同名方案「{name}」,请改名后再导入,或先删除原方案。")
|
||||
return redirect('saved_configs')
|
||||
try:
|
||||
data = _parse_profile_upload(upload)
|
||||
except ValueError as e:
|
||||
messages.error(request, str(e))
|
||||
return redirect('saved_configs')
|
||||
SavedConfig.objects.create(owner=request.user, name=name, data=data)
|
||||
messages.success(request, f"配置方案「{name}」导入成功,可在客户端定制页直接载入。")
|
||||
return redirect('saved_configs')
|
||||
|
||||
configs = request.user.saved_configs.all()
|
||||
return render(request, 'saved_configs.html', {'configs': configs})
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def save_config(request):
|
||||
"""定制页「另存为方案」:AJAX 提交 {name, data, overwrite?}。"""
|
||||
try:
|
||||
payload = json.loads(request.body)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
return JsonResponse({'ok': False, 'message': '请求内容不是有效的 JSON。'}, status=400)
|
||||
|
||||
name = (payload.get('name') or '').strip()[:100]
|
||||
if not name:
|
||||
return JsonResponse({'ok': False, 'message': '请填写方案名称。'}, status=400)
|
||||
try:
|
||||
data = _sanitize_profile_data(payload.get('data'))
|
||||
except ValueError as e:
|
||||
return JsonResponse({'ok': False, 'message': str(e)}, status=400)
|
||||
|
||||
existing = SavedConfig.objects.filter(owner=request.user, name=name).first()
|
||||
if existing and not payload.get('overwrite'):
|
||||
return JsonResponse({'ok': False, 'message': f'已存在同名方案「{name}」,是否覆盖?', 'duplicate': True}, status=409)
|
||||
if existing:
|
||||
existing.data = data
|
||||
existing.save(update_fields=['data', 'updated_at'])
|
||||
return JsonResponse({'ok': True, 'id': existing.id, 'overwritten': True})
|
||||
|
||||
cfg = SavedConfig.objects.create(owner=request.user, name=name, data=data)
|
||||
return JsonResponse({'ok': True, 'id': cfg.id, 'overwritten': False})
|
||||
|
||||
|
||||
@login_required
|
||||
def config_data(request, pk):
|
||||
"""供定制页下拉载入:返回方案完整 JSON。"""
|
||||
cfg = get_object_or_404(SavedConfig, pk=pk, owner=request.user)
|
||||
return JsonResponse({'name': cfg.name, 'data': cfg.data})
|
||||
|
||||
|
||||
@login_required
|
||||
def export_config(request, pk):
|
||||
"""把方案导出为 .json 文件(格式与定制页本地导出一致,可互导/供 cli 使用)。"""
|
||||
cfg = get_object_or_404(SavedConfig, pk=pk, owner=request.user)
|
||||
body = json.dumps(cfg.data, ensure_ascii=False, indent=2)
|
||||
response = HttpResponse(body, content_type='application/json; charset=utf-8')
|
||||
response['Content-Disposition'] = f"attachment; filename*=UTF-8''{quote(cfg.name)}.json"
|
||||
return response
|
||||
|
||||
|
||||
@login_required
|
||||
@require_POST
|
||||
def delete_config(request, pk):
|
||||
cfg = get_object_or_404(SavedConfig, pk=pk, owner=request.user)
|
||||
name = cfg.name
|
||||
cfg.delete()
|
||||
messages.success(request, f"配置方案「{name}」已删除。")
|
||||
return redirect('saved_configs')
|
||||
|
||||
|
||||
def healthz(request):
|
||||
"""容器健康检查端点,无需登录。"""
|
||||
return JsonResponse({"status": "ok"})
|
||||
Reference in New Issue
Block a user