feat(system): 管理员支持在线更新服务端(系统更新)

- 新增 api/system/update/check|start|status,发起更新需二次输入登录密码,经 appstore 拉起独立 updater 容器执行 ./cmd update
- cmd update 新增 --check(输出 JSON 检查结果)与 --non-interactive(在线更新模式:强制备份数据库、存在本地修改时中止、写入 docker/logs/update/state.json 进度)
- 设置菜单与头像下拉(仅管理员)新增「系统更新」入口,进度页展示步骤、实时日志与结果
- 在线更新默认开启,运维可通过 .env ONLINE_UPDATE=false 关闭;appstore 注入 HOST_DOCKER_SOCK
- 修复 Ihttp 状态行解析在多词原因短语(如 404 Not Found)时失败的问题
- 同步 ai-kb:系统更新操作指引、不支持在线更新 FAQ、菜单入口
This commit is contained in:
kuaifan 2026-10-01 04:12:55 +00:00
parent b42b430e4b
commit be0f05af4d
21 changed files with 1502 additions and 96 deletions

View File

@ -15,6 +15,7 @@ use App\Module\Doo;
use App\Models\User;
use App\Module\Base;
use App\Module\OnlineLicense;
use App\Module\SystemUpdate;
use App\Module\Timer;
use App\Models\Setting;
use LdapRecord\Container;
@ -1587,6 +1588,70 @@ class SystemController extends AbstractController
return $array;
}
/**
* @api {get} api/system/update/check 检查系统更新(限管理员)
*
* @apiDescription 检查服务端(主程序)是否有新版本,结果缓存 10 分钟
* @apiVersion 1.0.0
* @apiGroup system
* @apiName update__check
*
* @apiParam {Number} [force] 1:跳过缓存重新检查
*
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据(supported、reason、current_version、latest_version、behind、has_update、has_local_changes、has_db_changes、changelog)
*/
public function update__check()
{
User::auth('admin');
$force = intval(Request::input('force')) === 1;
return Base::retSuccess('success', SystemUpdate::check($force));
}
/**
* @api {post} api/system/update/start 发起系统更新(限管理员)
*
* @apiDescription 校验登录密码后,由 appstore 拉起独立容器执行 ./cmd update,接口立即返回;进度通过 api/system/update/status 查询
* @apiVersion 1.0.0
* @apiGroup system
* @apiName update__start
*
* @apiParam {String} password 当前管理员登录密码
* @apiParam {String} version 确认时看到的目标版本号
*
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据(id、from_version、to_version)
*/
public function update__start()
{
$user = User::auth('admin');
$password = (string) Request::input('password');
$version = trim((string) Request::input('version'));
return Base::retSuccess('已开始更新系统', SystemUpdate::start($user, $password, $version));
}
/**
* @api {get} api/system/update/status 系统更新进度(限管理员)
*
* @apiVersion 1.0.0
* @apiGroup system
* @apiName update__status
*
* @apiParam {Number} [offset] 日志读取偏移(字节),用于增量获取日志
*
* @apiSuccess {Number} ret 返回状态码(1正确、0错误)
* @apiSuccess {String} msg 返回信息(错误描述)
* @apiSuccess {Object} data 返回数据(status:idle|running|success|failed、stage、from_version、to_version、error、log、offset 等)
*/
public function update__status()
{
User::auth('admin');
$offset = intval(Request::input('offset'));
return Base::retSuccess('success', SystemUpdate::status($offset));
}
/**
* @api {get} api/system/prefetch 预加载的资源
*

View File

@ -191,9 +191,9 @@ class Ihttp
$split1[1] = substr($data, $pos + 4, strlen($data));
$split2 = explode("\r\n", $split1[0], 2);
preg_match('/^(\S+) (\S+) (\S+)$/', $split2[0], $matches);
preg_match('/^(\S+) (\S+)(?: (.*))?$/', $split2[0], $matches);
$rlt['code'] = $matches[2];
$rlt['status'] = $matches[3];
$rlt['status'] = $matches[3] ?? '';
$rlt['responseline'] = $split2[0];
$header = explode("\r\n", $split2[1]);
$rlt['headers'] = [];

241
app/Module/SystemUpdate.php Normal file
View File

@ -0,0 +1,241 @@
<?php
namespace App\Module;
use App\Exceptions\ApiException;
use App\Models\User;
use App\Models\WebSocket;
use App\Tasks\PushTask;
use Cache;
/**
* 系统更新(主程序在线更新)
*
* 执行链路:php → appstore 内部接口(APP_KEY 鉴权)→ appstore 拉起独立的 updater 容器执行 `./cmd update --non-interactive`。
* updater 不属于 compose 项目,更新过程中 php/nginx/appstore 被重建也不受影响;
* 进度由 cmd 写入 docker/logs/update/state.json,这里直接读取(php 重启期间前端轮询短暂失败属正常)。
*/
class SystemUpdate
{
const CHECK_CACHE_KEY = 'system_update:check';
const CHECK_CACHE_SECONDS = 600;
// 状态文件中 running 超过该时长视为异常中断(updater 被强杀等),允许重新发起
const STALE_SECONDS = 3600;
// 单次读取日志的最大字节数
const LOG_CHUNK_BYTES = 65536;
/**
* 是否允许在线更新(运维可通过 .env ONLINE_UPDATE=false 关闭)
*/
public static function enabled(): bool
{
return (bool) config('dootask.online_update');
}
/**
* 检查更新
* @param bool $force 跳过缓存
* @return array
*/
public static function check(bool $force = false): array
{
$base = [
'supported' => false,
'reason' => '',
'branch' => '',
'current_version' => Base::getVersion(),
'latest_version' => '',
'behind' => 0,
'has_update' => false,
'has_local_changes' => false,
'has_db_changes' => false,
'changelog' => '',
'checked_at' => time(),
];
if (!self::enabled()) {
return array_merge($base, ['reason' => 'disabled']);
}
if (!$force && ($cached = Cache::get(self::CHECK_CACHE_KEY))) {
return $cached;
}
$resp = self::appstore('check', [], 150);
if ($resp['reason']) {
return array_merge($base, ['reason' => $resp['reason']]);
}
$data = is_array($resp['data']) ? $resp['data'] : [];
$result = array_merge($base, [
'supported' => (bool) ($data['supported'] ?? false),
'reason' => (string) ($data['reason'] ?? ''),
'branch' => (string) ($data['branch'] ?? ''),
'latest_version' => (string) ($data['latest_version'] ?? ''),
'behind' => intval($data['behind'] ?? 0),
'has_local_changes' => (bool) ($data['has_local_changes'] ?? false),
'has_db_changes' => (bool) ($data['has_db_changes'] ?? false),
'changelog' => trim((string) base64_decode((string) ($data['changelog_base64'] ?? ''))),
]);
$result['has_update'] = $result['supported'] && $result['behind'] > 0;
Cache::put(self::CHECK_CACHE_KEY, $result, self::CHECK_CACHE_SECONDS);
return $result;
}
/**
* 发起更新
* @param User $user 发起的管理员
* @param string $password 登录密码(二次确认)
* @param string $version 前端确认时看到的目标版本,防止确认期间远端又有变化
* @return array
*/
public static function start(User $user, string $password, string $version): array
{
if (!self::enabled()) {
throw new ApiException('在线更新已关闭');
}
if ($password === '') {
throw new ApiException('请输入登录密码');
}
if ($user->password != Doo::md5s($password, $user->encrypt)) {
throw new ApiException('密码错误');
}
if (self::isRunning(self::readState())) {
throw new ApiException('系统正在更新中');
}
$check = self::check(true);
if (!$check['supported']) {
throw new ApiException('当前环境不支持在线更新');
}
if ($check['has_local_changes']) {
throw new ApiException('服务器存在本地修改,无法在线更新');
}
if (!$check['has_update']) {
throw new ApiException('系统已是最新版本');
}
if ($version !== '' && $version !== $check['latest_version']) {
throw new ApiException('最新版本已变化,请重新检查更新');
}
$id = date('YmdHis');
$resp = self::appstore('start', ['id' => $id, 'operator' => $user->userid], 30);
if ($resp['reason']) {
throw new ApiException($resp['reason'] === 'running' ? '系统正在更新中' : '发起系统更新失败');
}
Cache::forget(self::CHECK_CACHE_KEY);
// 通知所有在线成员:系统即将短暂不可用
PushTask::push([
'fd' => WebSocket::pluck('fd')->toArray(),
'msg' => [
'type' => 'systemUpdate',
'data' => [
'id' => $id,
'version' => $check['latest_version'],
],
],
]);
return [
'id' => $id,
'from_version' => $check['current_version'],
'to_version' => $check['latest_version'],
];
}
/**
* 更新进度
* @param int $offset 日志读取偏移(字节)
* @return array
*/
public static function status(int $offset = 0): array
{
$state = self::readState();
if (empty($state)) {
return ['status' => 'idle', 'log' => '', 'offset' => 0];
}
if (self::isStale($state)) {
$state['status'] = 'failed';
$state['error'] = $state['error'] ?: 'timeout';
}
$log = '';
$logFile = (string) ($state['log'] ?? '');
if (preg_match('/^docker\/logs\/update\/\d{14}\.log$/', $logFile) && file_exists(base_path($logFile))) {
$size = filesize(base_path($logFile));
$offset = max(0, min($offset, $size));
if ($size > $offset) {
$log = (string) file_get_contents(base_path($logFile), false, null, $offset, self::LOG_CHUNK_BYTES);
$offset += strlen($log);
// 去掉终端颜色码
$log = preg_replace('/\e\[[0-9;]*m/', '', $log);
}
}
$state['current_version'] = Base::getVersion();
$state['log'] = $log;
$state['offset'] = $offset;
return $state;
}
/**
* 读取状态文件
* @return array
*/
private static function readState(): array
{
$file = base_path('docker/logs/update/state.json');
if (!file_exists($file)) {
return [];
}
$state = Base::json2array(file_get_contents($file));
return is_array($state) ? $state : [];
}
private static function isRunning(array $state): bool
{
return ($state['status'] ?? '') === 'running' && !self::isStale($state);
}
private static function isStale(array $state): bool
{
return ($state['status'] ?? '') === 'running'
&& time() - intval($state['started_at'] ?? 0) > self::STALE_SECONDS;
}
/**
* 调用 appstore 内部接口
* @param string $action check|start
* @param array $payload
* @param int $timeout
* @return array ['reason' => 失败原因(空为成功), 'data' => 接口 data]
*/
private static function appstore(string $action, array $payload, int $timeout): array
{
$appKey = config('app.key') ?: '';
if (empty($appKey)) {
return ['reason' => 'appstore_unavailable', 'data' => null];
}
$resp = Ihttp::ihttp_request(
"http://appstore/api/v1/internal/system/update/{$action}",
json_encode($payload ?: new \stdClass()),
[
'Content-Type' => 'application/json',
'Authorization' => 'Bearer ' . md5($appKey),
'Version' => Base::getVersion(),
],
$timeout
);
if (Base::isError($resp)) {
info('[system_update] appstore request fail', ['action' => $action, 'error' => $resp]);
return ['reason' => 'appstore_unavailable', 'data' => null];
}
// 旧版 appstore 没有该接口
if (intval($resp['msg']) === 404) {
return ['reason' => 'appstore_outdated', 'data' => null];
}
$body = Base::json2array($resp['data']);
if (intval($body['code'] ?? 0) !== 200) {
info('[system_update] appstore response error', ['action' => $action, 'body' => $resp['data']]);
$reason = (string) ($body['data']['reason'] ?? '');
return ['reason' => in_array($reason, ['running', 'unsupported']) ? $reason : 'appstore_error', 'data' => null];
}
return ['reason' => '', 'data' => $body['data'] ?? null];
}
}

177
cmd
View File

@ -85,6 +85,8 @@ msg() {
"更新PHP依赖失败") out="Failed to update PHP dependencies" ;;
"执行数据库备份...") out="Backing up database..." ;;
"重启服务失败") out="Failed to restart services" ;;
"检测到本地修改,已中止在线更新,请在服务器处理后重试") out="Local changes detected, online update aborted. Please resolve them on the server and retry" ;;
"服务未能在规定时间内恢复") out="Services did not recover in time" ;;
"更新完成") out="Update complete" ;;
"警告:此操作将永久删除以下内容:") out="WARNING: This will permanently delete:" ;;
"- 数据库") out="- Database" ;;
@ -130,7 +132,7 @@ if [ -t 0 ] && [ -t 1 ]; then
fi
# 缓存执行
if [ -z "$CACHED_EXECUTION" ] && [ "$1" == "update" ]; then
if [ -z "$CACHED_EXECUTION" ] && [ "$1" == "update" ] && [[ " $* " != *" --check "* ]]; then
if ! cat "$0" > ._cmd 2>/dev/null; then
error "$(msg '无法创建脚本副本')"
exit 1
@ -181,6 +183,7 @@ warning() {
# 输出错误
error() {
UPDATE_LAST_ERROR="$1"
echo -e "${Error} ${RedBG}$1${Font}"
}
@ -362,10 +365,11 @@ wait_php_healthy() {
[ -z "$name" ] && return 0
while [ $wait -lt 90 ]; do
st="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$name" 2>/dev/null)"
{ [ "$st" = "healthy" ] || [ "$st" = "running" ]; } && break
{ [ "$st" = "healthy" ] || [ "$st" = "running" ]; } && return 0
sleep 3
wait=$((wait + 3))
done
return 1
}
# 编译前端
@ -664,7 +668,7 @@ DooTask 管理脚本
📦 核心操作:
install 安装 DooTask (支持 --port <端口> --relock)
update 更新 DooTask (支持 --branch <分支> --force --local)
update 更新 DooTask (支持 --branch <分支> --force --local --check --non-interactive)
uninstall 卸载 DooTask
permission 修复整个项目的权限
@ -715,7 +719,7 @@ Usage: ./cmd <command> [options]
📦 Core:
install Install DooTask (supports --port <port> --relock)
update Update DooTask (supports --branch <branch> --force --local)
update Update DooTask (supports --branch <branch> --force --local --check --non-interactive)
uninstall Uninstall DooTask
permission Repair permissions for the whole project
@ -883,6 +887,111 @@ handle_install() {
container_exec mariadb "sh /etc/mysql/repassword.sh"
}
# 信任项目目录,避免 git 归属检查拦截(sudo / 容器内以 root 执行时)
git_trust_workdir() {
git config --global --get-all safe.directory 2>/dev/null | grep -qxF "${WORK_DIR}" \
|| git config --global --add safe.directory "${WORK_DIR}" 2>/dev/null
}
# 读取 package.json 中的版本号(从标准输入读取)
pkg_version() {
grep -m1 '"version"' | sed -E 's/.*"version"[^"]*"([^"]+)".*/\1/'
}
# JSON 字符串转义(单行)
json_str() {
printf '%s' "$1" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g' | tr -d '\r' | tr '\n\t' ' '
}
# 在线更新状态文件(仅 --non-interactive 模式写入,供 Web 端轮询进度)
UPDATE_STATE_FILE=""
UPDATE_LAST_ERROR=""
US_STATUS=""
US_STAGE=""
US_FROM_VERSION=""
US_TO_VERSION=""
US_FROM_COMMIT=""
US_TO_COMMIT=""
US_BACKUP=""
US_STARTED=""
US_FINISHED=0
US_ERROR=""
update_state_write() {
[[ -z "$UPDATE_STATE_FILE" ]] && return 0
local tmp="${UPDATE_STATE_FILE}.tmp"
printf '{"id":"%s","status":"%s","stage":"%s","from_version":"%s","to_version":"%s","from_commit":"%s","to_commit":"%s","backup_file":"%s","operator":%d,"log":"%s","started_at":%d,"finished_at":%d,"error":"%s"}\n' \
"$(json_str "${UPDATE_ID}")" "$US_STATUS" "$US_STAGE" \
"$(json_str "$US_FROM_VERSION")" "$(json_str "$US_TO_VERSION")" \
"$US_FROM_COMMIT" "$US_TO_COMMIT" "$(json_str "$US_BACKUP")" \
"${UPDATE_OPERATOR:-0}" "$(json_str "${UPDATE_LOG}")" \
"$US_STARTED" "$US_FINISHED" "$(json_str "$US_ERROR")" > "$tmp" && mv -f "$tmp" "$UPDATE_STATE_FILE"
}
update_stage() {
US_STAGE="$1"
update_state_write
}
# 异常退出时(exec_judge 失败 exit 1 等)标记失败
update_state_exit() {
if [[ "$US_STATUS" == "running" ]]; then
US_STATUS="failed"
US_ERROR="${UPDATE_LAST_ERROR:-unknown error}"
US_FINISHED=$(date +%s)
update_state_write
fi
}
update_state_init() {
mkdir -p "${WORK_DIR}/docker/logs/update"
UPDATE_STATE_FILE="${WORK_DIR}/docker/logs/update/state.json"
UPDATE_ID="${UPDATE_ID:-$(date +%Y%m%d%H%M%S)}"
US_STATUS="running"
US_STARTED=$(date +%s)
US_FROM_VERSION="$(pkg_version < "${WORK_DIR}/package.json")"
git_trust_workdir
US_FROM_COMMIT="$(git rev-parse HEAD 2>/dev/null)"
trap update_state_exit EXIT
update_stage "preparing"
}
# 检查更新(只读:仅 git fetch,不改动工作区),结果以 JSON 输出到 stdout
update_check_json() {
local reason="" branch="" current="" latest="" behind=0 local_changes=false db_changes=false changelog=""
current="$(pkg_version < "${WORK_DIR}/package.json")"
if [ ! -f "${WORK_DIR}/vendor/autoload.php" ]; then
reason="not_installed"
elif [ ! -d "${WORK_DIR}/.git" ]; then
reason="no_git"
else
git_trust_workdir
branch="$(git symbolic-ref --short -q HEAD)"
if [ -z "$branch" ]; then
reason="detached_head"
elif ! git fetch origin "$branch" &> /dev/null; then
reason="fetch_failed"
else
latest="$(git show "origin/${branch}:package.json" 2>/dev/null | pkg_version)"
behind="$(git rev-list --count "HEAD..origin/${branch}" 2>/dev/null || echo 0)"
if ! git diff --quiet || ! git diff --cached --quiet; then
local_changes=true
fi
if [ -n "$(git diff --name-only "HEAD...origin/${branch}" -- database/ 2>/dev/null)" ]; then
db_changes=true
fi
# 截取远端 CHANGELOG 中比当前版本新的段落
changelog="$(git show "origin/${branch}:CHANGELOG.md" 2>/dev/null \
| awk -v v="## [${current}]" 'index($0, v) == 1 { exit } /^## \[/ { started = 1 } started { print }' \
| base64 | tr -d '\n')"
fi
fi
printf '{"supported":%s,"reason":"%s","branch":"%s","current_version":"%s","latest_version":"%s","behind":%d,"has_local_changes":%s,"has_db_changes":%s,"changelog_base64":"%s"}\n' \
"$([ -z "$reason" ] && echo true || echo false)" "$reason" "$(json_str "$branch")" \
"$(json_str "$current")" "$(json_str "$latest")" "${behind:-0}" \
"$local_changes" "$db_changes" "$changelog"
}
# 更新函数
handle_update() {
check_sudo
@ -890,6 +999,26 @@ handle_update() {
local target_branch=$(arg_get branch)
local is_local=$(arg_get local)
local force_update=$(arg_get force)
local online_update=$(arg_get non-interactive)
# 在线更新(Web 端发起)不能交互,凭据缺失时让 git 直接失败而不是等待输入
if [[ "$(arg_get check)" == "yes" ]] || [[ "$online_update" == "yes" ]]; then
export GIT_TERMINAL_PROMPT=0
export GIT_SSH_COMMAND="ssh -o BatchMode=yes"
fi
if [[ "$(arg_get check)" == "yes" ]]; then
update_check_json
return 0
fi
if [[ "$online_update" == "yes" ]]; then
# 在线更新只允许更新当前分支:不切分支、不强制覆盖、不走本地模式
target_branch=""
is_local=""
force_update=""
update_state_init
fi
# 检查是否已经安装
if [ ! -f "${WORK_DIR}/vendor/autoload.php" ]; then
@ -903,11 +1032,13 @@ handle_update() {
fi
if [[ -z "$is_local" ]]; then
# 信任项目目录,避免 git 归属检查拦截
git config --global --get-all safe.directory 2>/dev/null | grep -qxF "${WORK_DIR}" \
|| git config --global --add safe.directory "${WORK_DIR}" 2>/dev/null
git_trust_workdir
# 检查本地修改
if ! git diff --quiet || ! git diff --cached --quiet; then
if [[ "$online_update" == "yes" ]]; then
error "$(msg '检测到本地修改,已中止在线更新,请在服务器处理后重试')"
exit 1
fi
if [[ "$force_update" != "yes" ]]; then
warning "$(msg '检测到本地修改,是否强制更新?[Y/n]')"
read -r confirm_force
@ -925,6 +1056,7 @@ handle_update() {
fi
# 远程更新模式
update_stage "fetching"
exec_judge "git fetch --all" "$(msg '获取远程更新失败')"
# 确定目标分支
@ -947,19 +1079,31 @@ handle_update() {
# 检查数据库迁移变动
db_changes=$(git diff --name-only HEAD..origin/${current_branch} 2>/dev/null | grep -E "^database/" || true)
if [[ -n "$db_changes" ]]; then
echo "$(msg '数据库有迁移变动,执行数据库备份...')"
if [[ -n "$db_changes" ]] || [[ "$online_update" == "yes" ]]; then
# 在线更新无人值守,无论是否有迁移都先备份
update_stage "backup"
if [[ -n "$db_changes" ]]; then
echo "$(msg '数据库有迁移变动,执行数据库备份...')"
else
echo "$(msg '执行数据库备份...')"
fi
exec_judge "mysql_snapshot backup" "$(msg '数据库备份失败')" "$(msg '数据库备份完成')"
US_BACKUP="${filename#${WORK_DIR}/}"
fi
# 更新代码
update_stage "pulling"
if [[ "$force_update" == "yes" ]]; then
exec_judge "git reset --hard origin/${current_branch}" "$(msg '强制更新代码失败')"
else
exec_judge "git pull --ff-only origin ${current_branch}" "$(msg '代码拉取失败,可能存在冲突,请使用 --force 参数')"
fi
US_TO_VERSION="$(pkg_version < "${WORK_DIR}/package.json")"
US_TO_COMMIT="$(git rev-parse HEAD 2>/dev/null)"
# 更新依赖
update_stage "composer"
exec_judge "container_run php 'composer install --optimize-autoloader'" "$(msg '更新PHP依赖失败')"
else
# 本地更新模式
@ -968,9 +1112,11 @@ handle_update() {
fi
# 数据库迁移
update_stage "migrating"
exec_judge "container_run php 'php artisan migrate'" "$(msg '数据库迁移失败')"
# 停止服务
update_stage "restarting"
$COMPOSE stop php nginx &> /dev/null
$COMPOSE rm -f php nginx &> /dev/null
@ -982,10 +1128,21 @@ handle_update() {
fi
# 兜底拉起 nginx(避免首启时序竞态)
wait_php_healthy
update_stage "verifying"
local php_healthy="yes"
wait_php_healthy || php_healthy=""
[ -z "$(docker_name nginx)" ] && $COMPOSE up -d --remove-orphans
env_set UPDATE_TIME "$(date +%s)"
if [[ "$online_update" == "yes" ]] && [[ -z "$php_healthy" ]]; then
error "$(msg '服务未能在规定时间内恢复')"
exit 1
fi
if [[ "$online_update" == "yes" ]]; then
US_STATUS="success"
US_FINISHED=$(date +%s)
update_stage "done"
fi
success "$(msg '更新完成')"
}

View File

@ -11,6 +11,9 @@ return [
// 移动 APP AI 界面开关:设为 true 时仅在移动 APP 隐藏 AI 相关界面
'app_ai_hidden' => filter_var(env('APP_AI_HIDDEN', false), FILTER_VALIDATE_BOOLEAN),
// 在线更新开关:设为 false 时禁止管理员在网页端发起系统更新(SystemUpdate)
'online_update' => filter_var(env('ONLINE_UPDATE', true), FILTER_VALIDATE_BOOLEAN),
// 演示账号:登录页展示的演示账号(SystemController::demo)
'demo_account' => env('DEMO_ACCOUNT'),

View File

@ -106,6 +106,7 @@ services:
- ./:/var/www
environment:
HOST_PWD: "${PWD}"
HOST_DOCKER_SOCK: "${HOST_DOCKER_SOCK:-/var/run/docker.sock}"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 5s

View File

@ -3,27 +3,27 @@
> **本文件由脚本自动生成,请勿手改。**
>
> - 生成命令: `node scripts/gen-events-map.mjs`
> - 扫描范围: `resources/assets/js` 下所有 `.js` / `.vue` 文件(共 272 个)
> - 扫描范围: `resources/assets/js` 下所有 `.js` / `.vue` 文件(共 285 个)
> - 事件总线: `resources/assets/js/store/events.js`(mitt 实例)
> - 仅匹配裸 `emitter.emit/on/off(` 调用;`xxx.emitter.emit(`(如 Quill 内部 emitter)不属于本总线,已排除
共 **30** 个静态可解析事件,**124** 处 `emitter.emit/on/off` 调用。
共 **30** 个静态可解析事件,**126** 处 `emitter.emit/on/off` 调用。
## 事件清单
### `addMeeting`
- **emit(10)**
- `resources/assets/js/App.vue:420`
- `resources/assets/js/pages/manage.vue:1206`
- `resources/assets/js/pages/manage.vue:1213`
- `resources/assets/js/pages/manage/application.vue:1195`
- `resources/assets/js/pages/manage/application.vue:1201`
- `resources/assets/js/App.vue:422`
- `resources/assets/js/pages/manage.vue:1278`
- `resources/assets/js/pages/manage.vue:1285`
- `resources/assets/js/pages/manage/application.vue:1198`
- `resources/assets/js/pages/manage/application.vue:1204`
- `resources/assets/js/pages/manage/components/ChatInput/index.vue:1882`
- `resources/assets/js/pages/manage/components/DialogView/index.vue:621`
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:2040`
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:2048`
- `resources/assets/js/pages/manage/messenger.vue:1219`
- `resources/assets/js/pages/manage/messenger.vue:1227`
- **on(1)**
- `resources/assets/js/pages/manage/components/MeetingManager/index.vue:187`
- **off(1)**
@ -34,43 +34,43 @@
- **emit(3)**
- `resources/assets/js/pages/manage/calendar.vue:247`
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:3536`
- `resources/assets/js/pages/manage/components/ProjectPanel.vue:1357`
- `resources/assets/js/pages/manage/components/ProjectPanel.vue:1467`
- **on(1)**
- `resources/assets/js/pages/manage.vue:595`
- `resources/assets/js/pages/manage.vue:600`
- **off(1)**
- `resources/assets/js/pages/manage.vue:613`
- `resources/assets/js/pages/manage.vue:619`
### `aiAssistantClosed`
- **emit(1)**
- `resources/assets/js/components/AIAssistant/index.vue:447`
- **on(1)**
- `resources/assets/js/components/AIAssistant/float-button.vue:159`
- `resources/assets/js/components/AIAssistant/float-button.vue:155`
- **off(1)**
- `resources/assets/js/components/AIAssistant/float-button.vue:168`
- `resources/assets/js/components/AIAssistant/float-button.vue:164`
### `aiAssistantFloatButtonVisibilityChanged`
- **emit(1)**
- `resources/assets/js/pages/manage/setting/assistant.vue:59`
- **on(1)**
- `resources/assets/js/components/AIAssistant/float-button.vue:161`
- `resources/assets/js/components/AIAssistant/float-button.vue:157`
- **off(1)**
- `resources/assets/js/components/AIAssistant/float-button.vue:170`
- `resources/assets/js/components/AIAssistant/float-button.vue:166`
### `aiOperationRequest`
- **emit(1)**
- `resources/assets/js/store/actions.js:4890`
- `resources/assets/js/store/actions.js:4898`
- **on(1)**
- `resources/assets/js/components/AIAssistant/float-button.vue:160`
- `resources/assets/js/components/AIAssistant/float-button.vue:156`
- **off(1)**
- `resources/assets/js/components/AIAssistant/float-button.vue:169`
- `resources/assets/js/components/AIAssistant/float-button.vue:165`
### `clickAgainDialog`
- **emit(1)**
- `resources/assets/js/components/Mobile/Tabbar.vue:189`
- `resources/assets/js/components/Mobile/Tabbar.vue:188`
- **on(1)**
- `resources/assets/js/pages/manage/messenger.vue:344`
- **off(1)**
@ -80,29 +80,29 @@
- **emit(3)**
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:2894`
- `resources/assets/js/pages/manage/components/UserDetail.vue:288`
- `resources/assets/js/pages/manage/messenger.vue:1224`
- `resources/assets/js/pages/manage/components/UserDetail.vue:294`
- `resources/assets/js/pages/manage/messenger.vue:1232`
- **on(1)**
- `resources/assets/js/pages/manage.vue:596`
- `resources/assets/js/pages/manage.vue:601`
- **off(1)**
- `resources/assets/js/pages/manage.vue:614`
- `resources/assets/js/pages/manage.vue:620`
### `dialogMsgPush`
- **emit(1)**
- `resources/assets/js/store/actions.js:4961`
- `resources/assets/js/store/actions.js:4969`
- **on(2)**
- `resources/assets/js/components/Mobile/Tabbar.vue:50`
- `resources/assets/js/pages/manage.vue:597`
- `resources/assets/js/pages/manage.vue:602`
- **off(2)**
- `resources/assets/js/components/Mobile/Tabbar.vue:54`
- `resources/assets/js/pages/manage.vue:615`
- `resources/assets/js/pages/manage.vue:621`
### `handleMoveTop`
- **emit(2)**
- `resources/assets/js/store/actions.js:2810`
- `resources/assets/js/store/actions.js:3803`
- `resources/assets/js/store/actions.js:2818`
- `resources/assets/js/store/actions.js:3811`
- **on(2)**
- `resources/assets/js/pages/manage/components/DialogModal.vue:41`
- `resources/assets/js/pages/manage/components/TaskModal.vue:49`
@ -113,7 +113,7 @@
### `observeMicroApp:close`
- **emit(1)**
- `resources/assets/js/components/AIAssistant/action-executor.js:234`
- `resources/assets/js/components/AIAssistant/action-executor.js:237`
- **on(1)**
- `resources/assets/js/components/MicroApps/index.vue:203`
- **off(1)**
@ -122,7 +122,7 @@
### `observeMicroApp:open`
- **emit(1)**
- `resources/assets/js/store/actions.js:5468`
- `resources/assets/js/store/actions.js:5479`
- **on(1)**
- `resources/assets/js/components/MicroApps/index.vue:202`
- **off(1)**
@ -131,7 +131,7 @@
### `observeMicroApp:updatedOrUninstalled`
- **emit(1)**
- `resources/assets/js/store/mutations.js:452`
- `resources/assets/js/store/mutations.js:462`
- **on(1)**
- `resources/assets/js/components/MicroApps/index.vue:204`
- **off(1)**
@ -140,13 +140,13 @@
### `openAIAssistant`
- **emit(7)**
- `resources/assets/js/components/AIAssistant/float-button.vue:508`
- `resources/assets/js/components/SearchBox.vue:582`
- `resources/assets/js/pages/manage.vue:1237`
- `resources/assets/js/components/AIAssistant/float-button.vue:504`
- `resources/assets/js/components/SearchBox.vue:595`
- `resources/assets/js/pages/manage.vue:1309`
- `resources/assets/js/pages/manage/components/ChatInput/index.vue:1925`
- `resources/assets/js/pages/manage/components/ReportDetail.vue:176`
- `resources/assets/js/pages/manage/components/ReportEdit.vue:267`
- `resources/assets/js/pages/manage/components/TaskAdd.vue:710`
- `resources/assets/js/pages/manage/components/ReportDetail.vue:178`
- `resources/assets/js/pages/manage/components/ReportEdit.vue:270`
- `resources/assets/js/pages/manage/components/TaskAdd.vue:715`
- **on(1)**
- `resources/assets/js/components/AIAssistant/index.vue:387`
- **off(1)**
@ -155,16 +155,16 @@
### `openAIAssistantGlobal`
- **emit(1)**
- `resources/assets/js/pages/manage.vue:1225`
- `resources/assets/js/pages/manage.vue:1297`
- **on(1)**
- `resources/assets/js/components/AIAssistant/float-button.vue:158`
- `resources/assets/js/components/AIAssistant/float-button.vue:154`
- **off(1)**
- `resources/assets/js/components/AIAssistant/float-button.vue:167`
- `resources/assets/js/components/AIAssistant/float-button.vue:163`
### `openDownloadClient`
- **emit(1)**
- `resources/assets/js/pages/manage.vue:1106`
- `resources/assets/js/pages/manage.vue:1156`
- **on(1)**
- `resources/assets/js/components/RightBottom.vue:73`
- **off(1)**
@ -173,25 +173,25 @@
### `openFavorite`
- **emit(1)**
- `resources/assets/js/pages/manage/application.vue:1068`
- `resources/assets/js/pages/manage/application.vue:1071`
- **on(1)**
- `resources/assets/js/pages/manage.vue:599`
- `resources/assets/js/pages/manage.vue:604`
- **off(1)**
- `resources/assets/js/pages/manage.vue:617`
- `resources/assets/js/pages/manage.vue:623`
### `openManageExport`
- **emit(1)**
- `resources/assets/js/pages/manage/application.vue:1115`
- `resources/assets/js/pages/manage/application.vue:1118`
- **on(1)**
- `resources/assets/js/pages/manage.vue:601`
- `resources/assets/js/pages/manage.vue:606`
- **off(1)**
- `resources/assets/js/pages/manage.vue:619`
- `resources/assets/js/pages/manage.vue:625`
### `openMobileNotification`
- **emit(1)**
- `resources/assets/js/pages/manage.vue:1611`
- `resources/assets/js/pages/manage.vue:1683`
- **on(1)**
- `resources/assets/js/components/Mobile/Notification.vue:38`
- **off(1)**
@ -200,7 +200,7 @@
### `openProjectInvite`
- **emit(1)**
- `resources/assets/js/App.vue:432`
- `resources/assets/js/App.vue:434`
- **on(1)**
- `resources/assets/js/pages/manage/components/ProjectInvite.vue:83`
- **off(1)**
@ -209,25 +209,25 @@
### `openRecent`
- **emit(1)**
- `resources/assets/js/pages/manage/application.vue:1071`
- `resources/assets/js/pages/manage/application.vue:1074`
- **on(1)**
- `resources/assets/js/pages/manage.vue:600`
- `resources/assets/js/pages/manage.vue:605`
- **off(1)**
- `resources/assets/js/pages/manage.vue:618`
- `resources/assets/js/pages/manage.vue:624`
### `openReport`
- **emit(1)**
- `resources/assets/js/pages/manage/application.vue:1065`
- `resources/assets/js/pages/manage/application.vue:1068`
- **on(1)**
- `resources/assets/js/pages/manage.vue:598`
- `resources/assets/js/pages/manage.vue:603`
- **off(1)**
- `resources/assets/js/pages/manage.vue:616`
- `resources/assets/js/pages/manage.vue:622`
### `openSearch`
- **emit(1)**
- `resources/assets/js/pages/manage/dashboard.vue:784`
- `resources/assets/js/pages/manage/dashboard.vue:885`
- **on(1)**
- `resources/assets/js/components/SearchBox.vue:128`
- **off(1)**
@ -239,7 +239,7 @@
- `resources/assets/js/components/UserAvatar/index.vue:184`
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:2963`
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:4546`
- `resources/assets/js/pages/manage/messenger.vue:1229`
- `resources/assets/js/pages/manage/messenger.vue:1237`
- **on(1)**
- `resources/assets/js/pages/manage/components/UserDetail.vue:166`
- **off(1)**
@ -248,17 +248,17 @@
### `receiveTask`
- **emit(2)**
- `resources/assets/js/pages/manage/components/ProjectPanel.vue:1775`
- `resources/assets/js/pages/manage/components/ProjectPanel.vue:1884`
- `resources/assets/js/pages/manage/components/TaskRow.vue:280`
- **on(1)**
- `resources/assets/js/pages/manage/components/TaskDetail.vue:744`
- `resources/assets/js/pages/manage/components/TaskDetail.vue:772`
- **off(1)**
- `resources/assets/js/pages/manage/components/TaskDetail.vue:751`
- `resources/assets/js/pages/manage/components/TaskDetail.vue:779`
### `streamMsgData`
- **emit(1)**
- `resources/assets/js/store/actions.js:4578`
- `resources/assets/js/store/actions.js:4586`
- **on(1)**
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:944`
- **off(1)**
@ -267,17 +267,17 @@
### `taskRelationUpdate`
- **emit(1)**
- `resources/assets/js/store/actions.js:5101`
- `resources/assets/js/store/actions.js:5109`
- **on(1)**
- `resources/assets/js/pages/manage/components/TaskDetail.vue:745`
- `resources/assets/js/pages/manage/components/TaskDetail.vue:773`
- **off(1)**
- `resources/assets/js/pages/manage/components/TaskDetail.vue:752`
- `resources/assets/js/pages/manage/components/TaskDetail.vue:780`
### `updateNotification`
- **emit(2)**
- `resources/assets/js/pages/manage.vue:1103`
- `resources/assets/js/pages/manage/setting/index.vue:206`
- `resources/assets/js/pages/manage.vue:1153`
- `resources/assets/js/pages/manage/setting/index.vue:211`
- **on(1)**
- `resources/assets/js/components/RightBottom.vue:65`
- **off(1)**
@ -288,16 +288,16 @@
- **emit(1)**
- `resources/assets/js/components/RightBottom.vue:231`
- **on(1)**
- `resources/assets/js/pages/login.vue:219`
- `resources/assets/js/pages/login.vue:220`
- **off(1)**
- `resources/assets/js/pages/login.vue:224`
- `resources/assets/js/pages/login.vue:225`
### `userActive`
- **emit(3)**
- `resources/assets/js/store/actions.js:857`
- `resources/assets/js/store/actions.js:939`
- `resources/assets/js/store/actions.js:4878`
- `resources/assets/js/store/actions.js:862`
- `resources/assets/js/store/actions.js:944`
- `resources/assets/js/store/actions.js:4886`
- **on(1)**
- `resources/assets/js/components/UserAvatar/index.vue:43`
- **off(1)**
@ -306,11 +306,13 @@
### `websocketMsg`
- **emit(1)**
- `resources/assets/js/store/actions.js:4895`
- **on(2)**
- `resources/assets/js/store/actions.js:4903`
- **on(3)**
- `resources/assets/js/pages/manage.vue:607`
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:943`
- `resources/assets/js/pages/manage/components/FileContent.vue:202`
- **off(2)**
- **off(3)**
- `resources/assets/js/pages/manage.vue:626`
- `resources/assets/js/pages/manage/components/DialogWrapper.vue:955`
- `resources/assets/js/pages/manage/components/FileContent.vue:225`

View File

@ -1082,3 +1082,11 @@ WebDAV 未启用或你没有使用权限
指派成功
任务负责人最多不能超过10个
重复周期设置无效
在线更新已关闭
系统正在更新中
当前环境不支持在线更新
服务器存在本地修改,无法在线更新
系统已是最新版本
最新版本已变化,请重新检查更新
发起系统更新失败
已开始更新系统

View File

@ -2791,3 +2791,56 @@ WebDAV 路径冲突
发现客户端新版本
更新客户端
APP 新版本
系统更新
更新系统
系统正在更新,期间可能短暂无法使用,稍后将自动恢复
服务重启中,正在等待恢复...
系统已更新到 v(*),请刷新页面以使用新版本
系统更新失败
系统更新完成
系统更新中...
更新前的数据库备份:(*)
请根据日志在服务器上处理,必要时执行 (*) 回滚代码后重新更新
等待日志输出...
刷新页面
当前版本
最新版本
上次系统更新失败
查看日志
发现系统新版本
服务器存在本地修改,无法在线更新
请在服务器上处理本地修改后,执行 (*) 更新系统
更新期间系统将暂停服务(通常 1~3 分钟),所有在线成员会短暂断开连接
更新前将自动备份数据库
更新前将自动备份数据库(本次更新包含数据库变更)
系统已是最新版本
立即更新系统
检查更新
上次检查:(*)
更新期间系统将暂停服务(通常 1~3 分钟),所有在线成员会短暂断开连接。更新前会自动备份数据库。确定立即更新吗?
正在检查更新...
检查更新失败
当前环境不支持在线更新
请在服务器上执行 (*) 更新系统
准备更新
获取更新
备份数据库
更新代码
安装依赖
迁移数据库
重启服务
检查服务
在线更新已关闭
如需开启,请联系运维人员调整服务器配置;也可在服务器上执行 (*) 更新系统
系统尚未完成安装
当前部署方式不支持在线更新
当前代码不在任何分支上,无法在线更新
无法连接代码仓库,请检查服务器网络或仓库地址
也可在服务器上执行 (*) 更新系统
无法连接应用商店服务
请检查应用商店服务是否正常运行,或在服务器上执行 (*) 更新系统
应用商店服务版本过低,暂不支持在线更新
请先在服务器上执行一次 (*),之后即可在线更新
用时 (*)
暂不支持在线更新
更新内容

View File

@ -605,6 +605,18 @@ features:
- license.howto
- license.online.howto
- id: system-update
name: 系统更新
scope: admin
batch: B4
priority: P1
chunk_count_est: 2
owner: ~
status: drafted
chunks:
- system-update.online-update.howto
- system-update.unsupported.faq
- id: ldap
name: LDAP 集成
scope: admin
@ -817,6 +829,7 @@ features:
- menu-navigation.recent.menu-map
- menu-navigation.search.menu-map
- menu-navigation.system-setting.menu-map
- menu-navigation.system-update.menu-map
- menu-navigation.team-mgmt.menu-map
- menu-navigation.theme.menu-map

View File

@ -0,0 +1,49 @@
---
id: system-update.unsupported.faq
title: 无法在线更新系统或更新失败怎么办
type: faq
feature: system-update
scope: admin
locale: zh
aliases:
- 不支持在线更新
- 在线更新失败
- 系统更新失败
- 无法连接代码仓库
- 服务器存在本地修改
- 应用商店服务版本过低
- 在线更新已关闭
- 更新卡住
- 更新后打不开
related_tools: []
related_pages: [setting]
prerequisites:
- 系统管理员账号
negative:
- 在线更新失败不会自动回滚代码或数据库
- 网页上无法修复服务器环境问题,需要运维登录服务器处理
last_verified: v1.9.36
---
# 无法在线更新系统或更新失败怎么办
## 问题
管理员在「设置 → 系统更新」页看到不支持在线更新的提示,或者更新过程中显示「系统更新失败」。
## 原因
- 「在线更新已关闭」:运维在服务器 `.env` 中设置了 `ONLINE_UPDATE=false`
- 「当前部署方式不支持在线更新」:代码目录不是 Git 仓库(例如手动上传的代码包)
- 「当前代码不在任何分支上」:服务器代码处于游离状态(detached HEAD)
- 「无法连接代码仓库」:服务器无法访问代码仓库,或仓库地址需要账号密码 / SSH 密钥
- 「应用商店服务版本过低」:旧版本首次使用,内置的应用商店服务还没有在线更新能力
- 「服务器存在本地修改」:服务器上有人改过代码且未提交,在线更新不会覆盖这些修改
- 更新中途失败:常见于网络中断、依赖安装失败、数据库迁移失败
## 解决
- 以上不支持的情况:请运维在服务器项目目录执行 `./cmd update` 更新;「应用商店服务版本过低」只需手动执行一次,之后即可在线更新
- 本地修改:运维确认这些修改是否需要保留,处理后再更新
- 更新失败:页面会显示失败原因、日志和更新前的数据库备份文件;运维可根据日志处理后重新执行 `./cmd update`,必要时用 `git reset --hard <更新前的提交>` 回滚代码、用 `./cmd mysql recovery` 还原数据库
- 「重启服务」阶段暂时连不上服务器属正常,一般 1~3 分钟内恢复;长时间无法恢复请运维检查容器状态
## 相关
- 在线更新步骤:[[system-update.online-update.howto]]

View File

@ -0,0 +1,65 @@
---
id: system-update.online-update.howto
title: 在线更新系统(服务端)
type: howto
feature: system-update
scope: admin
locale: zh
aliases:
- 系统更新
- 更新系统
- 在线更新
- 在线升级
- 升级服务器
- 更新服务器
- 服务端升级
- 怎么升级 DooTask
- 网页上更新系统
- 发现系统新版本
- 服务器版本过低
related_tools: []
related_pages: [setting]
prerequisites:
- 系统管理员账号
- 使用官方 Docker 方式部署,且代码目录是 Git 仓库
- 服务器能访问代码仓库与依赖源
negative:
- 更新期间系统暂停服务(通常 1~3 分钟),所有在线成员会短暂断开连接
- 只能更新到当前分支的最新代码,不能在网页上切换分支或回退版本
- 服务器上有未提交的本地代码修改时不能在线更新,需运维在服务器上处理
- 更新失败不会自动回滚,需要运维根据日志在服务器上处理
- 系统更新与客户端更新是两回事:客户端(桌面端 / APP)由各成员自行更新
last_verified: v1.9.36
---
# 在线更新系统(服务端)
「系统更新」用于管理员在网页上把 DooTask 服务端更新到最新版本,效果等同于运维在服务器上执行 `./cmd update`。它只更新服务端,不更新成员的桌面端 / APP 客户端(客户端更新见 [[electron-client.update.howto]])。
## 入口
- 设置 →「系统更新」(仅管理员可见,位于「更新日志」上方;有新版本时右侧显示新版本号)
- 右上角头像下拉 →「更新系统」(仅管理员可见,且只在有新版本时显示)
- 有系统新版本时,管理员头像右上角会出现红点
## 操作步骤
1. 进入「系统更新」页,页面显示当前版本;有新版本时显示「发现系统新版本」、最新版本号和更新日志
2. 点「检查更新」可立即重新检查(检查结果默认缓存 10 分钟)
3. 点「立即更新系统」,在弹窗中输入当前管理员的登录密码确认
4. 更新开始后,所有在线成员会收到「系统正在更新」提醒
5. 页面依次显示进度:准备更新 → 获取更新 → 备份数据库 → 更新代码 → 安装依赖 → 迁移数据库 → 重启服务 → 检查服务,并实时输出日志
6. 「重启服务」阶段页面会短暂连不上服务器,属正常现象,页面会自动等待恢复
7. 显示「系统更新完成」后,点「刷新页面」使用新版本
## 字段默认值
- 每次在线更新前都会自动备份数据库,备份文件位于服务器 `docker/mysql/backup/` 目录
- 更新日志保存在服务器 `docker/logs/update/` 目录
- 在线更新默认开启,运维可在服务器 `.env` 设置 `ONLINE_UPDATE=false` 关闭
## 不支持
- 不支持定时更新、选择版本、跨分支更新
- 不支持在网页上一键回滚
- 普通成员看不到系统更新入口
## 相关
- 无法在线更新 / 更新失败怎么办:[[system-update.unsupported.faq]]
- 入口位置:[[menu-navigation.system-update.menu-map]]

View File

@ -0,0 +1,41 @@
---
id: menu-navigation.system-update.menu-map
title: 系统更新入口在哪
type: menu-map
feature: menu-navigation
scope: admin
locale: zh
aliases:
- 系统更新在哪
- 更新系统在哪
- 在线升级入口
- 服务器升级入口
- 怎么更新服务端
related_tools: []
related_pages: [setting]
prerequisites:
- 系统管理员账号
negative:
- 普通成员看不到「系统更新」「更新系统」选项
- 「更新系统」头像菜单项只在有系统新版本时出现
last_verified: v1.9.36
---
# 系统更新入口在哪
## 路径
- 设置 → 左侧菜单「系统更新」(位于「更新日志」「版本」上方)
- 右上角头像下拉菜单 →「更新系统」(位于「客户端下载」「更新客户端」同一组,仅在有系统新版本时显示)
- 路由:`/manage/setting/system-update`
## 能做什么
- 查看当前版本、最新版本与更新日志
- 检查更新、在线更新系统并查看进度与日志
## 权限要求
- 仅系统管理员(`admin`)可见
- 发起更新需要再次输入当前管理员的登录密码
## 相关
- 在线更新步骤:[[system-update.online-update.howto]]
- 客户端(桌面端)更新:[[electron-client.update.howto]]

View File

@ -42,10 +42,11 @@ last_verified: v1.9.36
- 键盘设置(仅 Electron / 移动端)→ [[user-settings.shortcut.concept]]
- AI 助手设置(仅安装 ai 插件后显示)→ [[user-settings.ai-assistant-float.howto]]
- 系统设置 / License Key(仅系统管理员可见)
- 系统更新(仅系统管理员可见,有系统新版本时显示新版本号)→ [[system-update.online-update.howto]]
- 更新日志(桌面端有新版本时显示为「更新客户端」,点击打开客户端更新弹窗) / 版本 / 登录设备 / 清除缓存 / 退出登录
## 权限要求
- 所有登录用户可见
- 「系统设置」「License Key」需 `userIsAdmin`
- 「系统设置」「License Key」「系统更新」需 `userIsAdmin`
- 「删除帐号」仅 EEUI 移动端(自助注销入口)

View File

@ -15,7 +15,7 @@
<UserAvatar :userid="userId" :size="36"/>
</div>
<span>{{userInfo.nickname}}</span>
<Badge v-if="!!clientNewVersion" class="manage-box-top-report" dot/>
<Badge v-if="!!clientNewVersion || (userIsAdmin && !!systemNewVersion)" class="manage-box-top-report" dot/>
<div class="manage-box-arrow">
<Icon type="ios-arrow-up" />
<Icon type="ios-arrow-down" />
@ -121,6 +121,10 @@
v-if="item.path === 'version'"
class="manage-menu-report-badge"
:text="clientNewVersion"/>
<Badge
v-else-if="item.path === 'system-update'"
class="manage-menu-report-badge"
:text="systemNewVersion"/>
<Badge
v-else-if="item.path === 'workReport' && reportUnreadNumber > 0"
class="manage-menu-report-badge"
@ -600,6 +604,7 @@ export default {
emitter.on('openFavorite', this.openFavorite);
emitter.on('openRecent', this.openRecent);
emitter.on('openManageExport', this.openManageExport);
emitter.on('websocketMsg', this.onWebsocketMsg);
//
document.addEventListener('keydown', this.shortcutEvent);
},
@ -618,6 +623,7 @@ export default {
emitter.off('openFavorite', this.openFavorite);
emitter.off('openRecent', this.openRecent);
emitter.off('openManageExport', this.openManageExport);
emitter.off('websocketMsg', this.onWebsocketMsg);
//
document.removeEventListener('keydown', this.shortcutEvent);
},
@ -641,6 +647,7 @@ export default {
'clientNewVersion',
'clientDownloadUrl',
'systemNewVersion',
'reportUnreadNumber',
@ -815,8 +822,11 @@ export default {
{path: 'system', name: '系统设置'},
{path: 'license', name: 'License Key'},
],
[{path: 'downloadClient', name: '客户端下载', visible: this.showDownloadClient}],
[{path: 'version', name: '更新客户端', visible: !!this.clientNewVersion}],
[
{path: 'system-update', name: '更新系统', visible: !!this.systemNewVersion},
{path: 'downloadClient', name: '客户端下载', visible: this.showDownloadClient},
{path: 'version', name: '更新客户端', visible: !!this.clientNewVersion},
],
[
{path: 'allProject', name: '所有项目'},
{path: 'archivedProject', name: '已归档的项目'},
@ -829,8 +839,10 @@ export default {
} else {
groups.push(
[{path: 'personal', name: '个人设置'}],
[{path: 'downloadClient', name: '客户端下载', visible: this.showDownloadClient}],
[{path: 'version', name: '更新客户端', visible: !!this.clientNewVersion}],
[
{path: 'downloadClient', name: '客户端下载', visible: this.showDownloadClient},
{path: 'version', name: '更新客户端', visible: !!this.clientNewVersion},
],
[
{path: 'workReport', name: '工作报告', visible: systemFunctionEnabled('report')},
{path: 'archivedProject', name: '已归档的项目'},
@ -934,6 +946,17 @@ export default {
this.chackPass();
},
userIsAdmin: {
handler(isAdmin) {
if (isAdmin) {
this.checkSystemUpdate();
} else {
this.$store.state.systemNewVersion = null;
}
},
immediate: true
},
// 日历被停用(或「系统功能」已卸载)时,不允许停留在日历页
calendarDisabled: {
handler(disabled) {
@ -1172,6 +1195,28 @@ export default {
}
},
/**
* 检查系统(服务端)更新,仅管理员;服务端缓存 10 分钟
*/
checkSystemUpdate() {
this.$store.dispatch("call", {
url: 'system/update/check',
checkNetwork: false,
}).then(({data}) => {
this.$store.state.systemNewVersion = data.has_update ? data.latest_version : null;
}).catch(_ => {});
},
onWebsocketMsg({type}) {
if (type === 'systemUpdate') {
$A.noticeWarning({
title: '系统更新',
desc: '系统正在更新,期间可能短暂无法使用,稍后将自动恢复',
duration: 10,
});
}
},
exportOverdueTask() {
$A.modalConfirm({
title: '导出任务',

View File

@ -25,6 +25,10 @@
<AutoTip disabled>{{$L(item.name)}}</AutoTip>
<Badge v-if="!!clientNewVersion" :text="clientNewVersion"/>
</template>
<template v-else-if="item.path === 'system-update'">
<AutoTip disabled>{{$L(item.name)}}</AutoTip>
<Badge v-if="!!systemNewVersion" :text="systemNewVersion"/>
</template>
<template v-else-if="item.path === 'version-show'">
<AutoTip>{{$L(item.name)}}: {{clientVersion}}</AutoTip>
</template>
@ -74,10 +78,10 @@ export default {
computed: {
...mapGetters(['aiVisible']),
...mapState(['userInfo', 'userIsAdmin', 'clientNewVersion', 'systemConfig']),
...mapState(['userInfo', 'userIsAdmin', 'clientNewVersion', 'systemNewVersion', 'systemConfig']),
showContent() {
return this.$route.path.match(/^\/manage\/setting\/\w+$/)
return this.$route.path.match(/^\/manage\/setting\/[\w-]+$/)
},
menu() {
@ -110,8 +114,11 @@ export default {
{path: 'license', name: 'License Key'},
])
}
if (this.userIsAdmin) {
menu.push({path: 'system-update', name: '系统更新', divided: true})
}
menu.push(...[
{path: 'version', name: this.clientNewVersion ? '更新客户端' : '更新日志', title: '更新日志', divided: true},
{path: 'version', name: this.clientNewVersion ? '更新客户端' : '更新日志', title: '更新日志', divided: !this.userIsAdmin},
{path: 'version-show', name: '版本'},
{path: 'device', name: '登录设备', divided: true},
{path: 'clearCache', name: '清除缓存'},

View File

@ -0,0 +1,419 @@
<template>
<div class="setting-item submit">
<div class="system-update-box">
<!-- 更新进行中 / 本次更新结果 -->
<!-- 更新进行中 / 本次更新结果 -->
<div v-if="taskMode" :class="['update-view', `is-${task.status || 'running'}`]">
<div class="view-head">
<div class="view-title">{{$L(taskTitle)}}</div>
<div class="view-desc">
<span v-if="task.from_version && task.to_version">v{{task.from_version}} → v{{task.to_version}}</span>
<span v-if="taskDuration">{{$L('用时 (*)', taskDuration)}}</span>
</div>
</div>
<ul class="task-steps">
<li v-for="item in stages" :key="item.key" :class="stageClass(item.key)">
<div class="step-dot">
<Loading v-if="stageClass(item.key) === 'process'"/>
<Icon v-else-if="stageClass(item.key) === 'finish'" type="md-checkmark"/>
<Icon v-else-if="stageClass(item.key) === 'error'" type="md-close"/>
</div>
<span class="step-label">{{$L(item.label)}}</span>
</li>
</ul>
<div v-if="task.status === 'running' && pollFailed" class="view-notice warning">{{$L('服务重启中,正在等待恢复...')}}</div>
<div v-else-if="task.status === 'success'" class="view-notice success">
{{$L('系统已更新到 v(*),请刷新页面以使用新版本', task.to_version || task.current_version)}}
</div>
<div v-else-if="task.status === 'failed'" class="view-notice error">
<div v-if="task.error" class="notice-title">{{task.error}}</div>
<div v-if="task.backup_file">{{$L('更新前的数据库备份:(*)', task.backup_file)}}</div>
<div>{{$L('请根据日志在服务器上处理,必要时执行 (*) 回滚代码后重新更新', rollbackCommand)}}</div>
</div>
<div class="view-panel">
<div class="panel-head">{{$L('更新日志')}}</div>
<pre ref="log" class="panel-body is-log">{{taskLog || $L('等待日志输出...')}}</pre>
</div>
</div>
<!-- 检查更新 -->
<div v-else :class="['update-view', checkStatus]">
<div class="view-head">
<div class="view-title">{{$L(checkTitle)}}</div>
<div class="view-desc">
<span v-if="info && info.has_update">v{{currentVersion}} → v{{info.latest_version}}</span>
<span v-else>{{$L('当前版本')}} v{{currentVersion}}</span>
<span v-if="info && info.checked_at">{{$L('上次检查:(*)', checkedTime)}}</span>
</div>
</div>
<div v-if="lastFailed" class="view-notice error">
<div class="notice-title">{{$L('上次系统更新失败')}}</div>
<div>
<span v-if="lastFailed.error">{{lastFailed.error}}</span>
<a href="javascript:void(0)" @click="viewLastTask">{{$L('查看日志')}}</a>
</div>
</div>
<template v-if="info && !info.supported">
<div class="view-notice warning">
<div class="notice-title">{{$L(reasonText)}}</div>
<div>{{$L(reasonHint, './cmd update')}}</div>
</div>
</template>
<template v-else-if="info && info.has_update">
<div v-if="info.has_local_changes" class="view-notice error">
<div class="notice-title">{{$L('服务器存在本地修改,无法在线更新')}}</div>
<div>{{$L('请在服务器上处理本地修改后,执行 (*) 更新系统', './cmd update')}}</div>
</div>
<div v-else class="view-notice warning">
<div>{{$L('更新期间系统将暂停服务(通常 1~3 分钟),所有在线成员会短暂断开连接')}}</div>
<div>{{$L(info.has_db_changes ? '更新前将自动备份数据库(本次更新包含数据库变更)' : '更新前将自动备份数据库')}}</div>
</div>
<div class="view-panel">
<div class="panel-head">{{$L('更新内容')}}</div>
<div class="panel-body is-markdown">
<VMPreview v-if="info.changelog" :value="info.changelog"/>
<div v-else class="panel-empty">{{$L('暂无更新介绍!')}}</div>
</div>
</div>
</template>
</div>
</div>
<!-- 底部按钮:与其他设置页一致固定在底部 -->
<div v-if="taskMode && task.status !== 'running'" class="setting-footer">
<Button v-if="task.status === 'success'" type="primary" @click="reloadPage">{{$L('刷新页面')}}</Button>
<Button @click="backToCheck">{{$L('返回')}}</Button>
</div>
<div v-else-if="!taskMode" class="setting-footer">
<Button
v-if="info && info.has_update && !info.has_local_changes"
type="primary"
:disabled="checkIng"
@click="confirmShow = true">{{$L('立即更新系统')}}</Button>
<Button :loading="checkIng" @click="check(true)">{{$L('检查更新')}}</Button>
</div>
<!-- 确认更新 -->
<Modal
v-model="confirmShow"
:title="$L('更新系统')"
:mask-closable="false"
@on-visible-change="onConfirmVisible">
<div class="system-update-confirm">
<p>{{$L('更新期间系统将暂停服务(通常 1~3 分钟),所有在线成员会短暂断开连接。更新前会自动备份数据库。确定立即更新吗?')}}</p>
<Input
ref="password"
v-model="password"
type="password"
password
:placeholder="$L('请输入登录密码')"
@on-enter="start"/>
</div>
<div slot="footer" class="adaption">
<Button type="default" @click="confirmShow = false">{{$L('取消')}}</Button>
<Button type="primary" :loading="startIng" @click="start">{{$L('立即更新')}}</Button>
</div>
</Modal>
</div>
</template>
<script>
import VMPreview from "../../../components/VMEditor/preview.vue";
const STAGES = [
{key: 'preparing', label: '准备更新'},
{key: 'fetching', label: '获取更新'},
{key: 'backup', label: '备份数据库'},
{key: 'pulling', label: '更新代码'},
{key: 'composer', label: '安装依赖'},
{key: 'migrating', label: '迁移数据库'},
{key: 'restarting', label: '重启服务'},
{key: 'verifying', label: '检查服务'},
];
const REASONS = {
disabled: ['在线更新已关闭', '如需开启,请联系运维人员调整服务器配置;也可在服务器上执行 (*) 更新系统'],
not_installed: ['系统尚未完成安装', '请在服务器上执行 (*) 更新系统'],
no_git: ['当前部署方式不支持在线更新', '请在服务器上执行 (*) 更新系统'],
detached_head: ['当前代码不在任何分支上,无法在线更新', '请在服务器上执行 (*) 更新系统'],
fetch_failed: ['无法连接代码仓库,请检查服务器网络或仓库地址', '也可在服务器上执行 (*) 更新系统'],
appstore_unavailable: ['无法连接应用商店服务', '请检查应用商店服务是否正常运行,或在服务器上执行 (*) 更新系统'],
appstore_outdated: ['应用商店服务版本过低,暂不支持在线更新', '请先在服务器上执行一次 (*),之后即可在线更新'],
};
export default {
components: {VMPreview},
data() {
return {
stages: STAGES,
info: null,
checkIng: false,
currentVersion: window.systemInfo.version,
confirmShow: false,
password: '',
startIng: false,
taskMode: false,
task: {},
taskId: '',
taskLog: '',
taskOffset: 0,
lastFailed: null,
pollTimer: null,
pollFailed: false,
nowTime: Date.now(),
clockTimer: null,
}
},
computed: {
reasonText() {
return (REASONS[this.info?.reason] || ['当前环境不支持在线更新'])[0];
},
reasonHint() {
return (REASONS[this.info?.reason] || [null, '请在服务器上执行 (*) 更新系统'])[1];
},
checkedTime() {
return $A.daytz(this.info.checked_at * 1000).format('YYYY-MM-DD HH:mm:ss');
},
checkTitle() {
if (!this.info) {
return this.checkIng ? '正在检查更新...' : '检查更新失败';
}
if (!this.info.supported) {
return '暂不支持在线更新';
}
return this.info.has_update ? '发现系统新版本' : '系统已是最新版本';
},
checkStatus() {
if (!this.info) {
return 'is-loading';
}
if (!this.info.supported) {
return 'is-unsupported';
}
return this.info.has_update ? 'is-update' : 'is-latest';
},
taskTitle() {
switch (this.task.status) {
case 'success':
return '系统更新完成';
case 'failed':
return '系统更新失败';
default:
return '系统更新中...';
}
},
taskDuration() {
const start = parseInt(this.task.started_at) || 0;
if (!start) {
return '';
}
const end = parseInt(this.task.finished_at) || Math.floor(this.nowTime / 1000);
const seconds = Math.max(0, end - start);
return `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
},
rollbackCommand() {
return this.task.from_commit ? `git reset --hard ${this.task.from_commit.substring(0, 12)}` : 'git reset --hard <commit>';
},
},
mounted() {
this.init();
},
beforeDestroy() {
this.stopPoll();
clearInterval(this.clockTimer);
},
methods: {
async init() {
const state = await this.fetchStatus(0).catch(_ => null);
if (state && state.status === 'running') {
this.enterTask(state.id);
this.applyStatus(state);
this.startPoll();
return;
}
if (state && state.status === 'failed') {
this.lastFailed = state;
}
this.check(false);
},
check(force) {
this.checkIng = true;
this.$store.dispatch("call", {
url: 'system/update/check',
data: {force: force ? 1 : 0},
}).then(({data}) => {
this.info = data;
this.currentVersion = data.current_version || this.currentVersion;
this.$store.state.systemNewVersion = data.has_update ? data.latest_version : null;
}).catch(({msg}) => {
$A.modalError(msg);
}).finally(_ => {
this.checkIng = false;
});
},
onConfirmVisible(visible) {
if (visible) {
this.password = '';
this.$nextTick(_ => this.$refs.password?.focus());
}
},
start() {
if (!this.password) {
$A.messageWarning('请输入登录密码');
return;
}
this.startIng = true;
this.$store.dispatch("call", {
url: 'system/update/start',
method: 'post',
data: {
password: this.password,
version: this.info?.latest_version || '',
},
}).then(({data}) => {
this.confirmShow = false;
this.lastFailed = null;
this.enterTask(data.id);
this.task = {
status: 'running',
stage: 'preparing',
from_version: data.from_version,
to_version: data.to_version,
started_at: Math.floor(Date.now() / 1000),
};
this.startPoll();
}).catch(({msg}) => {
$A.modalError(msg);
}).finally(_ => {
this.startIng = false;
});
},
enterTask(id) {
clearInterval(this.clockTimer);
this.clockTimer = setInterval(_ => {
this.nowTime = Date.now();
}, 1000);
this.taskMode = true;
this.taskId = id || '';
this.taskLog = '';
this.taskOffset = 0;
this.pollFailed = false;
},
fetchStatus(offset) {
return this.$store.dispatch("call", {
url: 'system/update/status',
data: {offset},
checkNetwork: false,
networkFailureRetry: false,
}).then(({data}) => data);
},
applyStatus(state) {
// 新批次的状态文件尚未写入时仍是上一次的结果,忽略
if (this.taskId && state.id && state.id !== this.taskId) {
return;
}
const {log, offset, ...rest} = state;
this.task = Object.assign({}, this.task, rest);
if (log) {
this.taskLog += log;
this.$nextTick(_ => {
const el = this.$refs.log;
el && (el.scrollTop = el.scrollHeight);
});
}
this.taskOffset = offset || this.taskOffset;
if (state.status === 'success') {
this.$store.state.systemNewVersion = null;
}
},
startPoll() {
this.stopPoll();
this.pollTimer = setTimeout(async _ => {
try {
const state = await this.fetchStatus(this.taskOffset);
this.pollFailed = false;
this.applyStatus(state);
} catch (e) {
// php 重启期间请求失败属正常,继续等待恢复
this.pollFailed = true;
}
if (this.task.status === 'running') {
this.startPoll();
} else if (this.taskOffset > 0) {
// 结束后补拉剩余日志
this.fetchStatus(this.taskOffset).then(this.applyStatus).catch(_ => {});
}
}, 2000);
},
stopPoll() {
this.pollTimer && clearTimeout(this.pollTimer);
this.pollTimer = null;
},
viewLastTask() {
const last = this.lastFailed;
this.enterTask(last.id);
this.task = Object.assign({}, last);
this.fetchStatus(0).then(this.applyStatus).catch(_ => {});
},
backToCheck() {
this.stopPoll();
clearInterval(this.clockTimer);
this.taskMode = false;
this.task = {};
this.check(false);
},
stageClass(key) {
const index = STAGES.findIndex(item => item.key === key);
const current = this.task.stage === 'done' ? STAGES.length : STAGES.findIndex(item => item.key === this.task.stage);
if (index < current) {
return 'finish';
}
if (index === current) {
if (this.task.status === 'failed') {
return 'error';
}
return this.task.status === 'running' ? 'process' : 'finish';
}
return 'wait';
},
reloadPage() {
$A.reloadUrl();
},
}
}
</script>

View File

@ -94,6 +94,11 @@ export default [
path: 'device',
component: () => import('./pages/manage/setting/device.vue'),
},
{
name: 'manage-setting-system-update',
path: 'system-update',
component: () => import('./pages/manage/setting/system-update.vue'),
},
{
name: 'manage-setting-version',
path: 'version',

View File

@ -259,6 +259,9 @@ export default {
// 客户端新版本号
clientNewVersion: null,
// 系统(服务端)新版本号,仅管理员检查
systemNewVersion: null,
// 客户端下载地址(网页版)
clientDownloadUrl: '',

View File

@ -670,3 +670,228 @@ body.window-portrait {
}
}
}
.system-update-box {
flex: 1;
display: flex;
flex-direction: column;
overflow: auto;
padding: 24px 40px;
.update-view {
flex: 1;
display: flex;
flex-direction: column;
min-height: 420px;
.view-head {
margin-bottom: 20px;
.view-title {
font-size: 18px;
font-weight: 500;
line-height: 26px;
color: $primary-title-color;
}
.view-desc {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
margin-top: 4px;
font-size: 13px;
color: $primary-desc-color;
}
}
&.is-success,
&.is-update {
.view-head .view-title {
color: $primary-color;
}
}
&.is-failed .view-head .view-title {
color: #ed4014;
}
.view-notice {
margin-bottom: 16px;
padding: 10px 14px;
border-radius: 6px;
font-size: 13px;
line-height: 1.7;
color: $primary-text-color;
word-break: break-all;
.notice-title {
font-weight: 500;
color: $primary-title-color;
}
a {
margin-left: 8px;
}
&.success {
background-color: rgba($primary-color, 0.1);
}
&.warning {
background-color: rgba(#ff9900, 0.1);
}
&.error {
background-color: rgba(#ed4014, 0.06);
.notice-title {
color: #ed4014;
}
}
}
.view-panel {
flex: 1;
min-height: 240px;
display: flex;
flex-direction: column;
border-radius: 8px;
overflow: hidden;
background-color: #f7f8fa;
.panel-head {
flex-shrink: 0;
padding: 12px 18px;
font-size: 13px;
font-weight: 500;
color: $primary-desc-color;
}
.panel-body {
flex: 1;
height: 0;
margin: 0;
padding: 0 18px 14px;
overflow: auto;
color: $primary-text-color;
&.is-log {
font-size: 12px;
line-height: 1.7;
white-space: pre-wrap;
word-break: break-all;
}
&.is-markdown {
padding: 4px 18px 12px;
}
.panel-empty {
padding: 8px 0;
color: $primary-desc-color;
}
.vuepress-markdown-body {
padding: 0 !important;
color: inherit;
font-size: 14px;
background-color: transparent;
h2 {
font-size: 1.25em;
margin: 1em 0 .6em;
padding-bottom: 0;
border-bottom: 0;
}
h3 {
font-size: 1.05em;
margin: 1em 0 .5em;
}
ul, ol {
padding-left: 2em;
line-height: 1.6;
}
}
}
}
}
.task-steps {
display: flex;
margin-bottom: 24px;
> li {
position: relative;
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
font-size: 13px;
color: $primary-desc-color;
// 连接线:从上一个节点中心连到当前节点中心
&:not(:first-child):before {
content: "";
position: absolute;
top: 10px;
right: calc(50% + 16px);
left: calc(-50% + 16px);
height: 2px;
border-radius: 1px;
background-color: #ebeef5;
}
.step-dot {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid #dcdfe6;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
color: #ffffff;
.common-loading {
width: 22px;
height: 22px;
}
}
.step-label {
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&.finish {
color: $primary-text-color;
&:before {
background-color: $primary-color;
}
.step-dot {
border-color: $primary-color;
background-color: $primary-color;
}
}
&.process {
color: $primary-title-color;
font-weight: 500;
&:before {
background-color: $primary-color;
}
.step-dot {
border: 0;
}
}
&.error {
color: #ed4014;
font-weight: 500;
&:before {
background-color: $primary-color;
}
.step-dot {
border-color: #ed4014;
background-color: #ed4014;
}
}
}
}
}
.system-update-confirm {
> p {
margin-bottom: 16px;
line-height: 1.6;
}
}
body.window-portrait {
.system-update-box {
padding: 20px;
.task-steps {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px 8px;
> li {
flex-direction: row;
&:before {
display: none;
}
}
}
}
}

View File

@ -2,7 +2,7 @@
> 此文件由 `php artisan doc:api-map` 生成,勿手改。
接口总数:330
接口总数:333
## 路由规则
@ -209,6 +209,9 @@ API 使用动态路由(见 `routes/web.php`),URL 段映射为控制器方
| api/system/checkin/export | checkin__export() | get | 导出签到数据(限管理员) |
| api/system/checkin/down | checkin__down() | get | 下载导出的签到数据 |
| api/system/version | version() | get | 获取版本号 |
| api/system/update/check | update__check() | get | 检查系统更新(限管理员) |
| api/system/update/start | update__start() | post | 发起系统更新(限管理员) |
| api/system/update/status | update__status() | get | 系统更新进度(限管理员) |
| api/system/prefetch | prefetch() | get | 预加载的资源 |
## license(LicenseController)