Initial commit: rdgen with GitHub/Gitea dual build backend

This commit is contained in:
naeeo
2026-09-29 19:56:02 +08:00
commit a3686a49d4
103 changed files with 20007 additions and 0 deletions
View File
+26
View File
@@ -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 = '管理首页'
+216
View File
@@ -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)
+306
View File
@@ -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()
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class RdgeneratorConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'rdgenerator'
verbose_name = 'RustDesk 客户端生成'
+39
View File
@@ -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()
+87
View File
@@ -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
+230
View File
@@ -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}(请检查服务器地址与网络/代理配置)'}
+110
View File
@@ -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}(如服务器在国内网络,请在下方配置代理后重试)'}
+20
View File
@@ -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,
}
+440
View File
@@ -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)
+74
View File
@@ -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)
+227
View File
@@ -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,
}
View File
@@ -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 '未创建'),
)
)
+22
View File
@@ -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='构建平台'),
),
]
View File
+171
View File
@@ -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})"
+851
View File
@@ -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%; }
}
+19
View File
@@ -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>
+19
View File
@@ -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>
+19
View File
@@ -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
+76
View File
@@ -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 %}
+62
View File
@@ -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 %}
+67
View File
@@ -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 %}
+674
View File
@@ -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 %}
+39
View File
@@ -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 %}
+78
View File
@@ -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>
+125
View File
@@ -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 %}
+351
View File
@@ -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 %}
+111
View File
@@ -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 &lt;你的令牌&gt;</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 %}
+44
View File
@@ -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 %}
+97
View File
@@ -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 %}
+77
View File
@@ -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 %}
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+85
View File
@@ -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)))
+695
View File
@@ -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),
)
+14
View File
@@ -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)
+511
View File
@@ -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"})