mirror of
https://github.com/kuaifan/dootask.git
synced 2026-10-29 22:10:42 +00:00
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:
parent
b42b430e4b
commit
be0f05af4d
@ -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 预加载的资源
|
||||
*
|
||||
|
||||
@ -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
241
app/Module/SystemUpdate.php
Normal 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
177
cmd
@ -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 '更新完成')"
|
||||
}
|
||||
|
||||
|
||||
@ -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'),
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`
|
||||
|
||||
|
||||
@ -1082,3 +1082,11 @@ WebDAV 未启用或你没有使用权限
|
||||
指派成功
|
||||
任务负责人最多不能超过10个
|
||||
重复周期设置无效
|
||||
在线更新已关闭
|
||||
系统正在更新中
|
||||
当前环境不支持在线更新
|
||||
服务器存在本地修改,无法在线更新
|
||||
系统已是最新版本
|
||||
最新版本已变化,请重新检查更新
|
||||
发起系统更新失败
|
||||
已开始更新系统
|
||||
|
||||
@ -2791,3 +2791,56 @@ WebDAV 路径冲突
|
||||
发现客户端新版本
|
||||
更新客户端
|
||||
APP 新版本
|
||||
系统更新
|
||||
更新系统
|
||||
系统正在更新,期间可能短暂无法使用,稍后将自动恢复
|
||||
服务重启中,正在等待恢复...
|
||||
系统已更新到 v(*),请刷新页面以使用新版本
|
||||
系统更新失败
|
||||
系统更新完成
|
||||
系统更新中...
|
||||
更新前的数据库备份:(*)
|
||||
请根据日志在服务器上处理,必要时执行 (*) 回滚代码后重新更新
|
||||
等待日志输出...
|
||||
刷新页面
|
||||
当前版本
|
||||
最新版本
|
||||
上次系统更新失败
|
||||
查看日志
|
||||
发现系统新版本
|
||||
服务器存在本地修改,无法在线更新
|
||||
请在服务器上处理本地修改后,执行 (*) 更新系统
|
||||
更新期间系统将暂停服务(通常 1~3 分钟),所有在线成员会短暂断开连接
|
||||
更新前将自动备份数据库
|
||||
更新前将自动备份数据库(本次更新包含数据库变更)
|
||||
系统已是最新版本
|
||||
立即更新系统
|
||||
检查更新
|
||||
上次检查:(*)
|
||||
更新期间系统将暂停服务(通常 1~3 分钟),所有在线成员会短暂断开连接。更新前会自动备份数据库。确定立即更新吗?
|
||||
正在检查更新...
|
||||
检查更新失败
|
||||
当前环境不支持在线更新
|
||||
请在服务器上执行 (*) 更新系统
|
||||
准备更新
|
||||
获取更新
|
||||
备份数据库
|
||||
更新代码
|
||||
安装依赖
|
||||
迁移数据库
|
||||
重启服务
|
||||
检查服务
|
||||
在线更新已关闭
|
||||
如需开启,请联系运维人员调整服务器配置;也可在服务器上执行 (*) 更新系统
|
||||
系统尚未完成安装
|
||||
当前部署方式不支持在线更新
|
||||
当前代码不在任何分支上,无法在线更新
|
||||
无法连接代码仓库,请检查服务器网络或仓库地址
|
||||
也可在服务器上执行 (*) 更新系统
|
||||
无法连接应用商店服务
|
||||
请检查应用商店服务是否正常运行,或在服务器上执行 (*) 更新系统
|
||||
应用商店服务版本过低,暂不支持在线更新
|
||||
请先在服务器上执行一次 (*),之后即可在线更新
|
||||
用时 (*)
|
||||
暂不支持在线更新
|
||||
更新内容
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
49
resources/ai-kb/zh/faq/system-update/unsupported.md
Normal file
49
resources/ai-kb/zh/faq/system-update/unsupported.md
Normal 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]]
|
||||
65
resources/ai-kb/zh/howto/system-update/online-update.md
Normal file
65
resources/ai-kb/zh/howto/system-update/online-update.md
Normal 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]]
|
||||
41
resources/ai-kb/zh/menu-map/menu-navigation/system-update.md
Normal file
41
resources/ai-kb/zh/menu-map/menu-navigation/system-update.md
Normal 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]]
|
||||
@ -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 移动端(自助注销入口)
|
||||
|
||||
@ -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: '导出任务',
|
||||
|
||||
@ -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: '清除缓存'},
|
||||
|
||||
419
resources/assets/js/pages/manage/setting/system-update.vue
Normal file
419
resources/assets/js/pages/manage/setting/system-update.vue
Normal 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>
|
||||
5
resources/assets/js/routes.js
vendored
5
resources/assets/js/routes.js
vendored
@ -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',
|
||||
|
||||
3
resources/assets/js/store/state.js
vendored
3
resources/assets/js/store/state.js
vendored
@ -259,6 +259,9 @@ export default {
|
||||
// 客户端新版本号
|
||||
clientNewVersion: null,
|
||||
|
||||
// 系统(服务端)新版本号,仅管理员检查
|
||||
systemNewVersion: null,
|
||||
|
||||
// 客户端下载地址(网页版)
|
||||
clientDownloadUrl: '',
|
||||
|
||||
|
||||
225
resources/assets/sass/pages/page-setting.scss
vendored
225
resources/assets/sass/pages/page-setting.scss
vendored
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user