diff --git a/app/Http/Controllers/Api/SystemController.php b/app/Http/Controllers/Api/SystemController.php index 31fdccfb6..20e8fa445 100755 --- a/app/Http/Controllers/Api/SystemController.php +++ b/app/Http/Controllers/Api/SystemController.php @@ -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 预加载的资源 * diff --git a/app/Module/Ihttp.php b/app/Module/Ihttp.php index cfe96aff7..3e6fd1583 100755 --- a/app/Module/Ihttp.php +++ b/app/Module/Ihttp.php @@ -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'] = []; diff --git a/app/Module/SystemUpdate.php b/app/Module/SystemUpdate.php new file mode 100644 index 000000000..146f5298d --- /dev/null +++ b/app/Module/SystemUpdate.php @@ -0,0 +1,241 @@ + 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]; + } +} diff --git a/cmd b/cmd index 662614532..3a5e1b617 100755 --- a/cmd +++ b/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 [options] 📦 Core: install Install DooTask (supports --port --relock) - update Update DooTask (supports --branch --force --local) + update Update DooTask (supports --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 '更新完成')" } diff --git a/config/dootask.php b/config/dootask.php index 37f7d4a65..1706ea8ca 100644 --- a/config/dootask.php +++ b/config/dootask.php @@ -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'), diff --git a/docker-compose.yml b/docker-compose.yml index 12cba5874..8f1a8fca4 100755 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/events-map.md b/docs/events-map.md index c29e76d7e..4a4a189b1 100644 --- a/docs/events-map.md +++ b/docs/events-map.md @@ -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` diff --git a/language/original-api.txt b/language/original-api.txt index 6ce4db35e..d0882eff2 100644 --- a/language/original-api.txt +++ b/language/original-api.txt @@ -1082,3 +1082,11 @@ WebDAV 未启用或你没有使用权限 指派成功 任务负责人最多不能超过10个 重复周期设置无效 +在线更新已关闭 +系统正在更新中 +当前环境不支持在线更新 +服务器存在本地修改,无法在线更新 +系统已是最新版本 +最新版本已变化,请重新检查更新 +发起系统更新失败 +已开始更新系统 diff --git a/language/original-web.txt b/language/original-web.txt index 92ed8c3ab..f77870ec1 100644 --- a/language/original-web.txt +++ b/language/original-web.txt @@ -2791,3 +2791,56 @@ WebDAV 路径冲突 发现客户端新版本 更新客户端 APP 新版本 +系统更新 +更新系统 +系统正在更新,期间可能短暂无法使用,稍后将自动恢复 +服务重启中,正在等待恢复... +系统已更新到 v(*),请刷新页面以使用新版本 +系统更新失败 +系统更新完成 +系统更新中... +更新前的数据库备份:(*) +请根据日志在服务器上处理,必要时执行 (*) 回滚代码后重新更新 +等待日志输出... +刷新页面 +当前版本 +最新版本 +上次系统更新失败 +查看日志 +发现系统新版本 +服务器存在本地修改,无法在线更新 +请在服务器上处理本地修改后,执行 (*) 更新系统 +更新期间系统将暂停服务(通常 1~3 分钟),所有在线成员会短暂断开连接 +更新前将自动备份数据库 +更新前将自动备份数据库(本次更新包含数据库变更) +系统已是最新版本 +立即更新系统 +检查更新 +上次检查:(*) +更新期间系统将暂停服务(通常 1~3 分钟),所有在线成员会短暂断开连接。更新前会自动备份数据库。确定立即更新吗? +正在检查更新... +检查更新失败 +当前环境不支持在线更新 +请在服务器上执行 (*) 更新系统 +准备更新 +获取更新 +备份数据库 +更新代码 +安装依赖 +迁移数据库 +重启服务 +检查服务 +在线更新已关闭 +如需开启,请联系运维人员调整服务器配置;也可在服务器上执行 (*) 更新系统 +系统尚未完成安装 +当前部署方式不支持在线更新 +当前代码不在任何分支上,无法在线更新 +无法连接代码仓库,请检查服务器网络或仓库地址 +也可在服务器上执行 (*) 更新系统 +无法连接应用商店服务 +请检查应用商店服务是否正常运行,或在服务器上执行 (*) 更新系统 +应用商店服务版本过低,暂不支持在线更新 +请先在服务器上执行一次 (*),之后即可在线更新 +用时 (*) +暂不支持在线更新 +更新内容 diff --git a/resources/ai-kb/_meta/feature-map.yaml b/resources/ai-kb/_meta/feature-map.yaml index 18238040a..164ab89ef 100644 --- a/resources/ai-kb/_meta/feature-map.yaml +++ b/resources/ai-kb/_meta/feature-map.yaml @@ -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 diff --git a/resources/ai-kb/zh/faq/system-update/unsupported.md b/resources/ai-kb/zh/faq/system-update/unsupported.md new file mode 100644 index 000000000..a718afd95 --- /dev/null +++ b/resources/ai-kb/zh/faq/system-update/unsupported.md @@ -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]] diff --git a/resources/ai-kb/zh/howto/system-update/online-update.md b/resources/ai-kb/zh/howto/system-update/online-update.md new file mode 100644 index 000000000..b2273c1f9 --- /dev/null +++ b/resources/ai-kb/zh/howto/system-update/online-update.md @@ -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]] diff --git a/resources/ai-kb/zh/menu-map/menu-navigation/system-update.md b/resources/ai-kb/zh/menu-map/menu-navigation/system-update.md new file mode 100644 index 000000000..919c9a9d1 --- /dev/null +++ b/resources/ai-kb/zh/menu-map/menu-navigation/system-update.md @@ -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]] diff --git a/resources/ai-kb/zh/menu-map/user-settings/entry.md b/resources/ai-kb/zh/menu-map/user-settings/entry.md index 4fa29702c..df8608488 100644 --- a/resources/ai-kb/zh/menu-map/user-settings/entry.md +++ b/resources/ai-kb/zh/menu-map/user-settings/entry.md @@ -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 移动端(自助注销入口) diff --git a/resources/assets/js/pages/manage.vue b/resources/assets/js/pages/manage.vue index 06e338685..92d048548 100644 --- a/resources/assets/js/pages/manage.vue +++ b/resources/assets/js/pages/manage.vue @@ -15,7 +15,7 @@ {{userInfo.nickname}} - +
@@ -121,6 +121,10 @@ v-if="item.path === 'version'" class="manage-menu-report-badge" :text="clientNewVersion"/> + { + 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: '导出任务', diff --git a/resources/assets/js/pages/manage/setting/index.vue b/resources/assets/js/pages/manage/setting/index.vue index 97c19a2bd..293afa8ab 100644 --- a/resources/assets/js/pages/manage/setting/index.vue +++ b/resources/assets/js/pages/manage/setting/index.vue @@ -25,6 +25,10 @@ {{$L(item.name)}} + @@ -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: '清除缓存'}, diff --git a/resources/assets/js/pages/manage/setting/system-update.vue b/resources/assets/js/pages/manage/setting/system-update.vue new file mode 100644 index 000000000..16ae34734 --- /dev/null +++ b/resources/assets/js/pages/manage/setting/system-update.vue @@ -0,0 +1,419 @@ + + + + diff --git a/resources/assets/js/routes.js b/resources/assets/js/routes.js index b7e79cba3..dd2a7234c 100755 --- a/resources/assets/js/routes.js +++ b/resources/assets/js/routes.js @@ -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', diff --git a/resources/assets/js/store/state.js b/resources/assets/js/store/state.js index 725177a2c..336011ae3 100644 --- a/resources/assets/js/store/state.js +++ b/resources/assets/js/store/state.js @@ -259,6 +259,9 @@ export default { // 客户端新版本号 clientNewVersion: null, + // 系统(服务端)新版本号,仅管理员检查 + systemNewVersion: null, + // 客户端下载地址(网页版) clientDownloadUrl: '', diff --git a/resources/assets/sass/pages/page-setting.scss b/resources/assets/sass/pages/page-setting.scss index 18b310a52..49be0441c 100755 --- a/resources/assets/sass/pages/page-setting.scss +++ b/resources/assets/sass/pages/page-setting.scss @@ -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; + } + } + } + } +} diff --git a/routes/api-map.md b/routes/api-map.md index 14a5b2a94..c35632158 100644 --- a/routes/api-map.md +++ b/routes/api-map.md @@ -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)