2 Commits

Author SHA1 Message Date
8a1287d8ed 1.修复矿建鉴权报错
2.优化登录跳转接口
3.优化登录跳转接口
4.修复CURD生成代码模块表不加前缀访问返回404问题
5.系统级报错***优化报错Fatal error: Type of app\common\library\token\TokenExpirationException::$message
2026-04-13 16:48:37 +08:00
2d14527da8 MySQL数据 2026-04-01 17:12:50 +08:00
15 changed files with 1167 additions and 152 deletions

View File

@@ -802,8 +802,8 @@ class Helper
$indexVueData['defaultItems'] = self::getJsonFromArray($indexVueData['defaultItems'] ?? []); $indexVueData['defaultItems'] = self::getJsonFromArray($indexVueData['defaultItems'] ?? []);
$indexVueData['tableColumn'] = self::buildTableColumn($indexVueData['tableColumn'] ?? []); $indexVueData['tableColumn'] = self::buildTableColumn($indexVueData['tableColumn'] ?? []);
$indexVueData['dblClickNotEditColumn'] = self::buildSimpleArray($indexVueData['dblClickNotEditColumn'] ?? ['undefined']); $indexVueData['dblClickNotEditColumn'] = self::buildSimpleArray($indexVueData['dblClickNotEditColumn'] ?? ['undefined']);
$controllerFile['path'][] = $controllerFile['originalLastName']; $urlSegments = array_merge($controllerFile['path'], [$controllerFile['originalLastName']]);
$indexVueData['controllerUrl'] = '\'/admin/' . ($controllerFile['path'] ? implode('.', $controllerFile['path']) : '') . '/\''; $indexVueData['controllerUrl'] = '\'/admin/' . ($urlSegments ? implode('.', array_map('strtolower', $urlSegments)) : '') . '/\'';
$indexVueData['componentName'] = ($webViewsDir['path'] ? implode('/', $webViewsDir['path']) . '/' : '') . $webViewsDir['originalLastName']; $indexVueData['componentName'] = ($webViewsDir['path'] ? implode('/', $webViewsDir['path']) . '/' : '') . $webViewsDir['originalLastName'];
$indexVueContent = self::assembleStub('html/index', $indexVueData); $indexVueContent = self::assembleStub('html/index', $indexVueData);
self::writeFile(root_path() . $webViewsDir['views'] . '/' . 'index.vue', $indexVueContent); self::writeFile(root_path() . $webViewsDir['views'] . '/' . 'index.vue', $indexVueContent);

View File

@@ -668,7 +668,7 @@ class Install extends Api
/** /**
* 获取安装完成后的访问地址(根据请求来源区分 API 与前端开发模式) * 获取安装完成后的访问地址(根据请求来源区分 API 与前端开发模式)
* - 通过 API 访问8787index.html#/admin、index.html#/ * - 通过 API 访问8787index#/admin、index#/(无 index 与 # 之间的斜杠)
* - 通过前端开发服务访问1818/#/admin、/#/ * - 通过前端开发服务访问1818/#/admin、/#/
*/ */
public function accessUrls(Request $request): Response public function accessUrls(Request $request): Response
@@ -680,7 +680,8 @@ class Install extends Api
$port = substr($host, strrpos($host, ':') + 1); $port = substr($host, strrpos($host, ':') + 1);
} }
$scheme = $request->header('x-forwarded-proto', 'http'); $scheme = $request->header('x-forwarded-proto', 'http');
$base = rtrim($scheme . '://' . $host, '/'); $basePath = $request instanceof \support\Request ? $request->publicBasePath() : '';
$base = rtrim($scheme . '://' . $host, '/') . $basePath;
if ($port === '1818') { if ($port === '1818') {
$adminUrl = $base . '/#/admin'; $adminUrl = $base . '/#/admin';

View File

@@ -142,9 +142,14 @@ class Backend extends Api
if ($needLogin) { if ($needLogin) {
if (!$this->auth->isLogin()) { if (!$this->auth->isLogin()) {
if ($request->method() === 'GET' && !$this->expectsApiJsonResponse($request)) {
$location = $this->adminSpaLoginUrl($request);
return redirect($location);
}
// 必须使用 HTTP 200 返回 JSON若用 HTTP 303axios 会跟随重定向,拿不到 JSON前端无法跳转登录
return $this->error(__('Please login first'), [ return $this->error(__('Please login first'), [
'type' => Auth::NEED_LOGIN, 'type' => Auth::NEED_LOGIN,
], 0, ['statusCode' => Auth::LOGIN_RESPONSE_CODE]); ], 0);
} }
if ($needPermission) { if ($needPermission) {
$controllerPath = $this->getControllerPath($request); $controllerPath = $this->getControllerPath($request);
@@ -167,6 +172,37 @@ class Backend extends Api
return null; return null;
} }
/**
* 是否应按 API 返回 JSON前端 axios 会带 server: true纯浏览器地址栏访问多为 HTML Accept
*/
protected function expectsApiJsonResponse(WebmanRequest $request): bool
{
$server = $request->header('server', '');
if ($server === 'true' || $server === '1') {
return true;
}
if (strtolower($request->header('x-requested-with', '')) === 'xmlhttprequest') {
return true;
}
$accept = strtolower($request->header('accept', ''));
if (str_contains($accept, 'application/json')) {
return true;
}
// 浏览器地址栏/点击链接触发的主文档请求,优先 302 到前端登录(避免误判为 API
if (strtolower((string) $request->header('sec-fetch-mode', '')) === 'navigate') {
return false;
}
return false;
}
/**
* 后台 Vue 为 hash 路由时的登录页(相对路径,与 web/src/router 一致)
*/
protected function adminSpaLoginUrl(WebmanRequest $request): string
{
return '/#/admin/login';
}
/** /**
* 子类可覆盖,用于初始化 model 等(替代原 initialize * 子类可覆盖,用于初始化 model 等(替代原 initialize
* @return Response|null 需直接返回时返回 Response否则 null * @return Response|null 需直接返回时返回 Response否则 null

View File

@@ -11,12 +11,15 @@ use Exception;
*/ */
class TokenExpirationException extends Exception class TokenExpirationException extends Exception
{ {
protected array $data = [];
public function __construct( public function __construct(
protected string $message = '', string $message = '',
protected int $code = 409, int $code = 409,
protected array $data = [], array $data = [],
?\Throwable $previous = null ?\Throwable $previous = null
) { ) {
$this->data = $data;
parent::__construct($message, $code, $previous); parent::__construct($message, $code, $previous);
} }

View File

@@ -204,7 +204,24 @@ if (!function_exists('get_controller_path')) {
if (count($parts) < 2) { if (count($parts) < 2) {
return $parts[0] ?? null; return $parts[0] ?? null;
} }
return implode('/', array_slice($parts, 1, -1)) ?: $parts[1]; $segments = array_slice($parts, 1, -1);
if ($segments === []) {
return $parts[1] ?? null;
}
// ThinkPHP 风格段 game.Config -> game/config与 $request->controller 解析结果一致(否则权限节点对不上)
$normalized = [];
foreach ($segments as $seg) {
if (str_contains($seg, '.')) {
$dotPos = strpos($seg, '.');
$mod = substr($seg, 0, $dotPos);
$ctrl = substr($seg, $dotPos + 1);
$normalized[] = strtolower($mod);
$normalized[] = strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $ctrl));
} else {
$normalized[] = strtolower(preg_replace('/([a-z])([A-Z])/', '$1_$2', $seg));
}
}
return implode('/', $normalized);
} }
} }

View File

@@ -245,6 +245,34 @@ Route::get('/admin/security/dataRecycleLog/index', [\app\admin\controller\securi
Route::post('/admin/security/dataRecycleLog/restore', [\app\admin\controller\security\DataRecycleLog::class, 'restore']); Route::post('/admin/security/dataRecycleLog/restore', [\app\admin\controller\security\DataRecycleLog::class, 'restore']);
Route::get('/admin/security/dataRecycleLog/info', [\app\admin\controller\security\DataRecycleLog::class, 'info']); Route::get('/admin/security/dataRecycleLog/info', [\app\admin\controller\security\DataRecycleLog::class, 'info']);
// ==================== CRUD 生成的根级控制器(/admin/item/index 或 /admin/Item/index无子目录、无点号 ====================
// 显式路由在上,此处作为兜底;与 /admin/module.controller/action 互补
Route::add(
['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD'],
'/admin/{controller:[a-zA-Z][a-zA-Z0-9]*}/{action}',
function (\Webman\Http\Request $request, string $controller, string $action) {
$class = '\\app\\admin\\controller\\' . ucfirst(strtolower($controller));
if (!class_exists($class)) {
return new Response(404, ['Content-Type' => 'application/json'], json_encode(['code' => 404, 'msg' => '404 Not Found', 'data' => []], JSON_UNESCAPED_UNICODE));
}
if (!method_exists($class, $action)) {
return new Response(404, ['Content-Type' => 'application/json'], json_encode(['code' => 404, 'msg' => '404 Not Found', 'data' => []], JSON_UNESCAPED_UNICODE));
}
$request->controller = $class;
try {
$instance = new $class();
return $instance->$action($request);
} catch (\Throwable $e) {
return new Response(500, ['Content-Type' => 'application/json'], json_encode([
'code' => 0,
'msg' => $e->getMessage(),
'time' => time(),
'data' => null,
], JSON_UNESCAPED_UNICODE));
}
}
);
// ==================== 兼容 ThinkPHP 风格 URLmodule.Controller/action ==================== // ==================== 兼容 ThinkPHP 风格 URLmodule.Controller/action ====================
// 前端使用 /admin/user.Rule/index 格式,需转换为控制器调用 // 前端使用 /admin/user.Rule/index 格式,需转换为控制器调用
Route::add( Route::add(

View File

@@ -231,8 +231,17 @@ class InstallData extends AbstractMigration
public function menuRule(): void public function menuRule(): void
{ {
if (!$this->hasTable('menu_rule')) return; // Install 迁移在已存在 admin_rule旧版表名时会跳过创建 menu_rule此处需与之一致
$table = $this->table('menu_rule'); $ruleTable = null;
if ($this->hasTable('menu_rule')) {
$ruleTable = 'menu_rule';
} elseif ($this->hasTable('admin_rule')) {
$ruleTable = 'admin_rule';
}
if ($ruleTable === null) {
return;
}
$table = $this->table($ruleTable);
$rows = [ $rows = [
[ [
'id' => '1', 'id' => '1',
@@ -1155,7 +1164,7 @@ class InstallData extends AbstractMigration
'createtime' => $this->nowTime, 'createtime' => $this->nowTime,
], ],
]; ];
$exist = Db::name('menu_rule')->where('id', 1)->value('id'); $exist = Db::name($ruleTable)->where('id', 1)->value('id');
if (!$exist) { if (!$exist) {
$table->insert($rows)->saveData(); $table->insert($rows)->saveData();
} }

File diff suppressed because one or more lines are too long

View File

@@ -13,12 +13,8 @@ if (!defined('BASE_PATH')) {
require $baseDir . '/vendor/autoload.php'; require $baseDir . '/vendor/autoload.php';
if (class_exists('Dotenv\Dotenv') && is_file($baseDir . '/.env')) { if (class_exists('Dotenv\Dotenv') && is_file($baseDir . '/.env')) {
if (method_exists('Dotenv\Dotenv', 'createUnsafeImmutable')) {
Dotenv\Dotenv::createUnsafeImmutable($baseDir)->load();
} else {
Dotenv\Dotenv::createMutable($baseDir)->load(); Dotenv\Dotenv::createMutable($baseDir)->load();
} }
}
if (!function_exists('env')) { if (!function_exists('env')) {
function env(string $key, mixed $default = null): mixed function env(string $key, mixed $default = null): mixed

View File

@@ -10,12 +10,9 @@ $baseDir = __DIR__;
require $baseDir . '/vendor/autoload.php'; require $baseDir . '/vendor/autoload.php';
if (class_exists('Dotenv\Dotenv') && is_file($baseDir . '/.env')) { if (class_exists('Dotenv\Dotenv') && is_file($baseDir . '/.env')) {
if (method_exists('Dotenv\Dotenv', 'createUnsafeImmutable')) { // 必须用 MutableWebman Worker 已加载过时Immutable 不会覆盖 $_ENV会导致 Phinx 与 Db::name() 前缀/库名不一致
Dotenv\Dotenv::createUnsafeImmutable($baseDir)->load();
} else {
Dotenv\Dotenv::createMutable($baseDir)->load(); Dotenv\Dotenv::createMutable($baseDir)->load();
} }
}
if (!function_exists('env')) { if (!function_exists('env')) {
function env(string $key, mixed $default = null): mixed function env(string $key, mixed $default = null): mixed

File diff suppressed because one or more lines are too long

View File

@@ -107,7 +107,7 @@
if (!urls.adminUrl && !urls.frontUrl) return; if (!urls.adminUrl && !urls.frontUrl) return;
document.querySelectorAll('input[type="text"], input:not([type])').forEach(function(inp){ document.querySelectorAll('input[type="text"], input:not([type])').forEach(function(inp){
var v = (inp.value || '').trim(); var v = (inp.value || '').trim();
if (v && (v.indexOf('#/admin') >= 0 || v.indexOf('index.html') >= 0) && v.indexOf('#/') >= 0) { if (v && (v.indexOf('#/admin') >= 0 || v.indexOf('index.html') >= 0 || v.indexOf('/index#') >= 0) && v.indexOf('#/') >= 0) {
inp.value = urls.adminUrl; inp.value = urls.adminUrl;
inp.dispatchEvent(new Event('input', { bubbles: true })); inp.dispatchEvent(new Event('input', { bubbles: true }));
} }
@@ -116,6 +116,22 @@
document.querySelectorAll('a[href*="#/"]').forEach(function(a){ document.querySelectorAll('a[href*="#/"]').forEach(function(a){
if (urls.frontUrl && a.href.indexOf('#/admin') < 0) a.href = urls.frontUrl; if (urls.frontUrl && a.href.indexOf('#/admin') < 0) a.href = urls.frontUrl;
}); });
// index.html/#/ 会被当成路径 /index.html/ 导致 webman 404统一为 index.html#/
document.querySelectorAll('a[href*="index.html/#"]').forEach(function(a){
a.href = a.href.replace(/index\.html\/#\//g, 'index.html#/');
});
// 打包的 index.js 完成页用 protocol+host 拼 adminUrl会漏掉 /index.php 等前缀;用接口结果覆盖展示与点击
if (urls.adminUrl) {
document.querySelectorAll('.admin-url').forEach(function(el){
el.textContent = urls.adminUrl;
el.style.cursor = 'pointer';
el.onclick = function(ev){
ev.preventDefault();
ev.stopPropagation();
window.open(urls.adminUrl, '_blank', 'noreferrer');
};
});
}
ensureQuickPanel(); ensureQuickPanel();
} }
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function(){ setInterval(applyUrls, 800); }); if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function(){ setInterval(applyUrls, 800); });

View File

@@ -35,6 +35,18 @@ class Request extends \Webman\Http\Request
return $path; return $path;
} }
/**
* 入口为 /index.php/... 时返回 /index.php用于拼接后台/前台完整 URL与路由 path() 剥离规则对应)
*/
public function publicBasePath(): string
{
$path = parent::path();
if (str_starts_with($path, '/index.php')) {
return '/index.php';
}
return '';
}
/** /**
* 获取请求参数(兼容 ThinkPHP param合并 get/postpost 优先) * 获取请求参数(兼容 ThinkPHP param合并 get/postpost 优先)
* @param string|null $name 参数名null 返回全部 * @param string|null $name 参数名null 返回全部

View File

@@ -100,6 +100,16 @@ export const useTerminal = defineStore(
} }
function addTask(command: string, blockOnFailure = true, extend = '', callback: Function = () => {}) { function addTask(command: string, blockOnFailure = true, extend = '', callback: Function = () => {}) {
const duplicatePending = state.taskList.some(
(item) =>
item.command === command &&
(item.status === taskStatus.Waiting ||
item.status === taskStatus.Connecting ||
item.status === taskStatus.Executing)
)
if (duplicatePending) {
return
}
if (!state.show) toggleDot(true) if (!state.show) toggleDot(true)
state.taskList = state.taskList.concat({ state.taskList = state.taskList.concat({
uuid: uuid(), uuid: uuid(),

View File

@@ -1,6 +1,7 @@
import type { AxiosRequestConfig, Method } from 'axios' import type { AxiosRequestConfig, Method } from 'axios'
import axios from 'axios' import axios from 'axios'
import { ElLoading, ElNotification, type LoadingOptions } from 'element-plus' import { ElLoading, ElNotification, type LoadingOptions } from 'element-plus'
import { nextTick } from 'vue'
import { refreshToken } from '/@/api/common' import { refreshToken } from '/@/api/common'
import { i18n } from '/@/lang/index' import { i18n } from '/@/lang/index'
import router from '/@/router/index' import router from '/@/router/index'
@@ -20,6 +21,12 @@ const loadingInstance: LoadingInstance = {
count: 0, count: 0,
} }
/** 请求是否后台 /admin/ 接口(不依赖当前路由,避免 loading 等场景误判为前台) */
function isAdminBackendRequest(config: AxiosRequestConfig): boolean {
const u = `${config.baseURL ?? ''}${config.url ?? ''}`
return /\/admin\//i.test(u)
}
/** /**
* 根据运行环境获取基础请求URL * 根据运行环境获取基础请求URL
*/ */
@@ -112,6 +119,22 @@ function createAxios<Data = any, T = ApiPromise<Data>>(axiosConfig: AxiosRequest
if (response.config.responseType == 'json') { if (response.config.responseType == 'json') {
if (response.data && response.data.code !== 1) { if (response.data && response.data.code !== 1) {
const needLogin = response.data.data && typeof response.data.data === 'object' && response.data.data.type === 'need login'
if (needLogin) {
const isAdminAppFlag = isAdminApp() || isAdminBackendRequest(response.config)
if (isAdminAppFlag) {
adminInfo.removeToken()
} else {
userInfo.removeToken()
}
const loginRouteName = isAdminAppFlag ? 'adminLogin' : 'userLogin'
if (router.currentRoute.value.name !== loginRouteName) {
nextTick(() => {
void router.replace({ name: loginRouteName })
})
}
return Promise.reject(response.data)
}
if (response.data.code == 409) { if (response.data.code == 409) {
if (!window.tokenRefreshing) { if (!window.tokenRefreshing) {
window.tokenRefreshing = true window.tokenRefreshing = true