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
+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 %}