feat(workbench): implement workbench window management and navigation state
Deployment Config CI / Deployment Config (push) Successful in 24s
Desktop Client Build / Resolve Build Metadata (push) Successful in 39s
Frontend CI / Frontend (push) Successful in 3m3s
Backend CI / Backend (push) Successful in 16m18s
Desktop Client Build / Build Desktop Client (push) Successful in 24m1s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 35s

This commit is contained in:
2026-05-20 15:44:26 +08:00
parent dd082e2ed1
commit 6359a2ddd3
6 changed files with 868 additions and 10 deletions
@@ -72,6 +72,7 @@ import {
} from './session-registry'
import { upsertDesktopAccount } from './transport/api-client'
import { STANDARD_USER_AGENT } from './user-agent'
import { openWorkbenchWindow } from './workbench-window'
interface DetectedAccount {
platformUid: string
@@ -4559,7 +4560,11 @@ export async function openPublishAccountConsole(account: PublishAccountIdentity)
throw new Error(`desktop_account_session_expired:${account.platform}`)
}
}
const window = createBoundWindow(`${definition.label} 创作台`, consoleURL, handle.session)
const window = await openWorkbenchWindow({
title: `${definition.label} 创作台`,
targetURL: consoleURL,
session: handle.session,
})
window.show()
window.focus()
}
+2 -2
View File
@@ -91,9 +91,9 @@ export function initTray(onOpen: () => void): ElectronTray {
updateTrayIssueIndicator(0)
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: 'Open', click: () => onOpen() },
{ label: '打开省心推', click: () => onOpen() },
{ type: 'separator' },
{ label: 'Quit', click: () => app.quit() },
{ label: '退出省心推', click: () => app.quit() },
]),
)
tray.on('click', () => onOpen())
@@ -0,0 +1,807 @@
import { join } from 'node:path'
import type { Session, WebContents } from 'electron/main'
import { BrowserWindow, WebContentsView, ipcMain, nativeTheme } from 'electron/main'
import { STANDARD_USER_AGENT } from './user-agent'
interface WorkbenchWindowOptions {
title: string
targetURL: string
session: Session
}
interface WorkbenchNavigationState {
canGoBack: boolean
canGoForward: boolean
loading: boolean
url: string
title: string
host: string
secure: boolean
}
interface WorkbenchWindowRecord {
shellContentsId: number
window: BrowserWindow
view: WebContentsView
title: string
initialURL: string
activeTargetURL: string
pageTitle: string
loading: boolean
}
const TOOLBAR_HEIGHT = 46
const workbenchWindows = new Map<number, WorkbenchWindowRecord>()
let handlersRegistered = false
function preloadPath(): string {
return join(__dirname, '../preload/bridge.cjs')
}
function titleBarStyle(): 'hidden' | 'hiddenInset' {
return process.platform === 'darwin' ? 'hiddenInset' : 'hidden'
}
function titleBarOverlay(): { color: string; symbolColor: string; height: number } | undefined {
if (process.platform === 'darwin') {
return undefined
}
return {
color: nativeTheme.shouldUseDarkColors ? '#15191a' : '#fbfcfd',
symbolColor: nativeTheme.shouldUseDarkColors ? '#f8fafc' : '#111827',
height: TOOLBAR_HEIGHT,
}
}
function escapeHtml(value: string): string {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;')
}
function hostFromURL(rawURL: string): string {
try {
return new URL(rawURL).host
} catch {
return ''
}
}
function isRemoteHTTPURL(rawURL: string): boolean {
return /^https?:\/\//i.test(rawURL)
}
function isLocalWorkbenchPage(rawURL: string): boolean {
return rawURL.startsWith('data:text/html')
}
function workbenchShellHTML(title: string, targetURL: string): string {
const initialHost = hostFromURL(targetURL)
const platformClass = `platform-${process.platform}`
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${escapeHtml(title)}</title>
<style>
:root {
color-scheme: light dark;
--toolbar-height: ${TOOLBAR_HEIGHT}px;
--surface: #fbfcfd;
--surface-elevated: rgba(255, 255, 255, 0.82);
--surface-control: #eef2f6;
--surface-control-hover: #e3e9f1;
--border: #dfe5ed;
--border-soft: rgba(148, 163, 184, 0.28);
--text: #1f2933;
--muted: #64717f;
--muted-soft: #94a3b8;
--accent: #2f6fed;
font-family:
"SF Pro Text",
"SF Pro Display",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
background: var(--surface);
color: var(--text);
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: var(--surface);
}
.workbench-toolbar {
position: fixed;
inset: 0 0 auto 0;
height: var(--toolbar-height);
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
padding: 8px 14px;
border-bottom: 1px solid var(--border);
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.94), rgba(248, 250, 252, 0.9)),
var(--surface);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.9) inset;
-webkit-app-region: drag;
user-select: none;
}
.platform-darwin .workbench-toolbar {
padding-left: 82px;
}
.platform-win32 .workbench-toolbar {
padding-right: 148px;
}
.nav-cluster {
display: inline-flex;
align-items: center;
gap: 3px;
height: 30px;
padding: 2px;
border: 1px solid var(--border-soft);
border-radius: 8px;
background: rgba(241, 245, 249, 0.76);
box-shadow:
0 1px 2px rgba(15, 23, 42, 0.05),
0 1px 0 rgba(255, 255, 255, 0.78) inset;
-webkit-app-region: no-drag;
}
.nav-button {
width: 25px;
height: 24px;
display: inline-grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 6px;
background: transparent;
color: #334155;
cursor: pointer;
transition:
background 150ms ease,
color 150ms ease,
transform 120ms ease,
opacity 150ms ease;
}
.nav-button:hover:not(:disabled) {
background: var(--surface-control-hover);
color: #111827;
}
.nav-button:active:not(:disabled) {
transform: translateY(1px) scale(0.98);
}
.nav-button:disabled {
cursor: default;
color: var(--muted-soft);
opacity: 0.58;
}
.nav-button svg {
width: 15px;
height: 15px;
display: block;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
}
.title-strip {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 30px;
color: var(--muted);
font-size: 12px;
line-height: 1;
}
.page-title {
min-width: 0;
max-width: min(46vw, 620px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: var(--text);
font-size: 13px;
font-weight: 650;
letter-spacing: 0;
}
.host-pill {
min-width: 0;
max-width: 260px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 10px;
height: 24px;
border: 1px solid var(--border-soft);
border-radius: 999px;
background: var(--surface-elevated);
color: var(--muted);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.72) inset;
}
.host-dot {
width: 6px;
height: 6px;
flex: 0 0 auto;
border-radius: 999px;
background: #22a06b;
box-shadow: 0 0 0 3px rgba(34, 160, 107, 0.12);
}
.host {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.toolbar-spacer {
width: 30px;
height: 30px;
}
.loading-line {
position: absolute;
left: 0;
right: 0;
bottom: -1px;
height: 2px;
overflow: hidden;
opacity: 0;
transition: opacity 140ms ease;
}
.loading-line.is-loading {
opacity: 1;
}
.loading-line::before {
content: "";
position: absolute;
inset: 0 auto 0 0;
width: 38%;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
transform: translateX(-100%);
animation: loading-sweep 1.05s ease-in-out infinite;
}
@keyframes loading-sweep {
to {
transform: translateX(270%);
}
}
@media (max-width: 760px) {
.workbench-toolbar {
gap: 8px;
}
.platform-darwin .workbench-toolbar {
padding-left: 78px;
}
.host-pill {
display: none;
}
.page-title {
max-width: 48vw;
}
}
@media (prefers-color-scheme: dark) {
:root {
--surface: #15191a;
--surface-elevated: rgba(31, 37, 41, 0.82);
--surface-control: #20272c;
--surface-control-hover: #293139;
--border: #293138;
--border-soft: rgba(148, 163, 184, 0.18);
--text: #edf2f7;
--muted: #a6b0bb;
--muted-soft: #66717e;
--accent: #6aa2ff;
}
.workbench-toolbar {
background:
linear-gradient(180deg, rgba(24, 29, 32, 0.94), rgba(20, 24, 27, 0.92)),
var(--surface);
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.05) inset;
}
.nav-cluster {
background: rgba(30, 37, 42, 0.76);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.2),
0 1px 0 rgba(255, 255, 255, 0.04) inset;
}
.nav-button {
color: #cbd5e1;
}
.nav-button:hover:not(:disabled) {
background: var(--surface-control-hover);
color: #f8fafc;
}
.host-pill {
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04) inset;
}
}
</style>
</head>
<body class="${platformClass}">
<header class="workbench-toolbar">
<nav class="nav-cluster" aria-label="工作台导航">
<button class="nav-button" id="backButton" type="button" title="后退" aria-label="后退" disabled>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 18 9 12l6-6" /></svg>
</button>
<button class="nav-button" id="forwardButton" type="button" title="前进" aria-label="前进" disabled>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
</button>
<button class="nav-button" id="reloadButton" type="button" title="刷新" aria-label="刷新">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 6v5h-5" /><path d="M4 18v-5h5" /><path d="M18.4 9A7 7 0 0 0 6.8 6.4L4 9" /><path d="M5.6 15a7 7 0 0 0 11.6 2.6L20 15" /></svg>
</button>
</nav>
<div class="title-strip">
<strong class="page-title" id="pageTitle">${escapeHtml(title)}</strong>
<span class="host-pill" title="${escapeHtml(initialHost)}">
<span class="host-dot" aria-hidden="true"></span>
<span class="host" id="hostLabel">${escapeHtml(initialHost)}</span>
</span>
</div>
<div class="toolbar-spacer" aria-hidden="true"></div>
<div class="loading-line" id="loadingLine" aria-hidden="true"></div>
</header>
<script>
(() => {
const bridge = window.desktopBridge && window.desktopBridge.workbenchNavigation;
const backButton = document.getElementById('backButton');
const forwardButton = document.getElementById('forwardButton');
const reloadButton = document.getElementById('reloadButton');
const pageTitle = document.getElementById('pageTitle');
const hostLabel = document.getElementById('hostLabel');
const loadingLine = document.getElementById('loadingLine');
function applyState(state) {
if (!state) {
return;
}
backButton.disabled = !state.canGoBack;
forwardButton.disabled = !state.canGoForward;
pageTitle.textContent = state.title || ${JSON.stringify(title)};
hostLabel.textContent = state.host || '';
hostLabel.parentElement.title = state.url || state.host || '';
loadingLine.classList.toggle('is-loading', Boolean(state.loading));
}
if (!bridge) {
reloadButton.disabled = true;
return;
}
backButton.addEventListener('click', () => {
void bridge.goBack();
});
forwardButton.addEventListener('click', () => {
void bridge.goForward();
});
reloadButton.addEventListener('click', () => {
void bridge.reload();
});
bridge.onStateChanged(applyState);
void bridge.getState().then(applyState).catch(() => undefined);
})();
</script>
</body>
</html>`
}
function workbenchShellDataURL(title: string, targetURL: string): string {
return `data:text/html;charset=UTF-8,${encodeURIComponent(workbenchShellHTML(title, targetURL))}`
}
function workbenchErrorDataURL(title: string, targetURL: string, message: string): string {
const host = hostFromURL(targetURL)
const html = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${escapeHtml(title)} - 加载失败</title>
<style>
:root {
color-scheme: light dark;
font-family:
"SF Pro Text",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
sans-serif;
background: #f6f8fb;
color: #17202a;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 28px;
background: #f6f8fb;
}
main {
width: min(620px, 100%);
padding: 26px;
border: 1px solid #dbe3ec;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.06);
}
h1 {
margin: 0;
font-size: 22px;
line-height: 1.35;
letter-spacing: 0;
}
p {
margin: 12px 0 0;
color: #536170;
line-height: 1.7;
}
code {
display: block;
margin-top: 16px;
padding: 11px 12px;
border-radius: 6px;
background: #f1f5f9;
color: #334155;
font: 13px/1.55 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
word-break: break-all;
}
@media (prefers-color-scheme: dark) {
:root,
body {
background: #111827;
color: #f8fafc;
}
main {
border-color: #2f3948;
background: #182130;
box-shadow: none;
}
p {
color: #b6bfcb;
}
code {
background: #111827;
color: #d8e0ea;
}
}
</style>
</head>
<body>
<main>
<h1>${escapeHtml(title)}加载失败</h1>
<p>远程工作台暂时无法打开。可以使用窗口左上方的刷新按钮重试。</p>
<code>${escapeHtml(host || targetURL)}
${escapeHtml(message)}</code>
</main>
</body>
</html>`
return `data:text/html;charset=UTF-8,${encodeURIComponent(html)}`
}
function stateForRecord(record: WorkbenchWindowRecord): WorkbenchNavigationState {
const contents = record.view.webContents
const currentURL = contents.isDestroyed() ? '' : contents.getURL()
const displayURL = isLocalWorkbenchPage(currentURL) ? record.activeTargetURL : currentURL
return {
canGoBack: !contents.isDestroyed() && contents.canGoBack(),
canGoForward: !contents.isDestroyed() && contents.canGoForward(),
loading: record.loading || (!contents.isDestroyed() && contents.isLoading()),
url: displayURL,
title: record.pageTitle || record.title,
host: hostFromURL(displayURL),
secure: displayURL.startsWith('https://'),
}
}
function sendNavigationState(record: WorkbenchWindowRecord): void {
if (record.window.isDestroyed()) {
return
}
record.window.webContents.send('desktop:workbench-navigation-state', stateForRecord(record))
}
function getRecordForSender(sender: WebContents): WorkbenchWindowRecord | null {
const record = workbenchWindows.get(sender.id)
if (!record || record.window.isDestroyed() || record.view.webContents.isDestroyed()) {
return null
}
return record
}
function defaultNavigationState(): WorkbenchNavigationState {
return {
canGoBack: false,
canGoForward: false,
loading: false,
url: '',
title: '',
host: '',
secure: false,
}
}
function registerWorkbenchNavigationHandlers(): void {
if (handlersRegistered) {
return
}
handlersRegistered = true
ipcMain.handle('desktop:workbench-navigation:get-state', (event) => {
const record = getRecordForSender(event.sender)
return record ? stateForRecord(record) : defaultNavigationState()
})
ipcMain.handle('desktop:workbench-navigation:go-back', (event) => {
const record = getRecordForSender(event.sender)
if (record?.view.webContents.canGoBack()) {
record.view.webContents.goBack()
}
return null
})
ipcMain.handle('desktop:workbench-navigation:go-forward', (event) => {
const record = getRecordForSender(event.sender)
if (record?.view.webContents.canGoForward()) {
record.view.webContents.goForward()
}
return null
})
ipcMain.handle('desktop:workbench-navigation:reload', (event) => {
const record = getRecordForSender(event.sender)
if (!record) {
return null
}
const currentURL = record.view.webContents.getURL()
if (isLocalWorkbenchPage(currentURL)) {
void navigateWorkbenchRemote(record, record.activeTargetURL || record.initialURL)
} else {
record.view.webContents.reload()
}
return null
})
}
function layoutWorkbenchView(record: WorkbenchWindowRecord): void {
if (record.window.isDestroyed() || record.view.webContents.isDestroyed()) {
return
}
const [width, height] = record.window.getContentSize()
record.view.setBounds({
x: 0,
y: TOOLBAR_HEIGHT,
width: Math.max(0, width),
height: Math.max(0, height - TOOLBAR_HEIGHT),
})
}
function attachWorkbenchViewEvents(record: WorkbenchWindowRecord): void {
const { webContents } = record.view
webContents.on('did-start-loading', () => {
record.loading = true
sendNavigationState(record)
})
webContents.on('did-stop-loading', () => {
record.loading = false
sendNavigationState(record)
})
webContents.on('did-start-navigation', (_event, url, isInPlace, isMainFrame) => {
if (isMainFrame && isRemoteHTTPURL(url)) {
record.activeTargetURL = url
record.loading = !isInPlace
sendNavigationState(record)
}
})
webContents.on('did-navigate', (_event, url) => {
if (isRemoteHTTPURL(url)) {
record.activeTargetURL = url
}
sendNavigationState(record)
})
webContents.on('did-navigate-in-page', (_event, url) => {
if (isRemoteHTTPURL(url)) {
record.activeTargetURL = url
}
sendNavigationState(record)
})
webContents.on('page-title-updated', (_event, pageTitle) => {
record.pageTitle = pageTitle || record.title
sendNavigationState(record)
})
webContents.on(
'did-fail-load',
(_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
if (!isMainFrame || errorCode === -3 || errorCode === -20) {
return
}
const targetURL = validatedURL || record.activeTargetURL || record.initialURL
console.warn('[desktop-workbench] workbench page load failed', {
title: record.title,
targetURL,
errorCode,
errorDescription,
})
void showWorkbenchError(record, targetURL, `${errorCode}: ${errorDescription}`)
},
)
webContents.setWindowOpenHandler(({ url }) => {
if (isRemoteHTTPURL(url)) {
setImmediate(() => {
void navigateWorkbenchRemote(record, url)
})
}
return { action: 'deny' }
})
}
async function showWorkbenchError(
record: WorkbenchWindowRecord,
targetURL: string,
message: string,
): Promise<void> {
if (record.window.isDestroyed() || record.view.webContents.isDestroyed()) {
return
}
record.loading = false
record.activeTargetURL = targetURL
record.pageTitle = `${record.title}加载失败`
await record.view.webContents.loadURL(workbenchErrorDataURL(record.title, targetURL, message))
sendNavigationState(record)
}
async function navigateWorkbenchRemote(
record: WorkbenchWindowRecord,
targetURL: string,
): Promise<void> {
if (record.window.isDestroyed() || record.view.webContents.isDestroyed()) {
return
}
record.activeTargetURL = targetURL
record.loading = true
sendNavigationState(record)
try {
await record.view.webContents.loadURL(targetURL, { userAgent: STANDARD_USER_AGENT })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (/ERR_ABORTED|ERR_BLOCKED_BY_CLIENT|\((-3|-20)\) loading/i.test(message)) {
return
}
await showWorkbenchError(record, targetURL, message)
}
}
export async function openWorkbenchWindow(options: WorkbenchWindowOptions): Promise<BrowserWindow> {
registerWorkbenchNavigationHandlers()
const window = new BrowserWindow({
show: false,
width: 1320,
height: 900,
minWidth: 1100,
minHeight: 760,
title: options.title,
autoHideMenuBar: true,
titleBarStyle: titleBarStyle(),
titleBarOverlay: titleBarOverlay(),
trafficLightPosition: process.platform === 'darwin' ? { x: 14, y: 16 } : undefined,
backgroundColor: nativeTheme.shouldUseDarkColors ? '#15191a' : '#fbfcfd',
webPreferences: {
preload: preloadPath(),
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
},
})
const view = new WebContentsView({
webPreferences: {
session: options.session,
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
},
})
view.webContents.setUserAgent(STANDARD_USER_AGENT)
window.contentView.addChildView(view)
const record: WorkbenchWindowRecord = {
shellContentsId: window.webContents.id,
window,
view,
title: options.title,
initialURL: options.targetURL,
activeTargetURL: options.targetURL,
pageTitle: options.title,
loading: false,
}
workbenchWindows.set(record.shellContentsId, record)
attachWorkbenchViewEvents(record)
window.on('resize', () => {
layoutWorkbenchView(record)
})
window.on('closed', () => {
workbenchWindows.delete(record.shellContentsId)
if (!record.view.webContents.isDestroyed()) {
record.view.webContents.close()
}
})
await window.webContents.loadURL(workbenchShellDataURL(options.title, options.targetURL))
layoutWorkbenchView(record)
sendNavigationState(record)
void navigateWorkbenchRemote(record, options.targetURL)
return window
}
+29
View File
@@ -38,6 +38,16 @@ interface DesktopLoginCredentials {
password: string
}
interface WorkbenchNavigationState {
canGoBack: boolean
canGoForward: boolean
loading: boolean
url: string
title: string
host: string
secure: boolean
}
const rendererProxyRequestChannel = 'desktop:renderer-devtools-proxy:request'
const rendererProxyResponseChannel = 'desktop:renderer-devtools-proxy:response'
@@ -196,6 +206,25 @@ const desktopBridge = {
}
},
},
workbenchNavigation: {
getState: () =>
ipcRenderer.invoke(
'desktop:workbench-navigation:get-state',
) as Promise<WorkbenchNavigationState>,
goBack: () => ipcRenderer.invoke('desktop:workbench-navigation:go-back') as Promise<null>,
goForward: () =>
ipcRenderer.invoke('desktop:workbench-navigation:go-forward') as Promise<null>,
reload: () => ipcRenderer.invoke('desktop:workbench-navigation:reload') as Promise<null>,
onStateChanged: (listener: (state: WorkbenchNavigationState) => void) => {
const wrapped = (_event: IpcRendererEvent, payload: WorkbenchNavigationState) => {
listener(payload)
}
ipcRenderer.on('desktop:workbench-navigation-state', wrapped)
return () => {
ipcRenderer.off('desktop:workbench-navigation-state', wrapped)
}
},
},
}
contextBridge.exposeInMainWorld('desktopBridge', desktopBridge)
+17
View File
@@ -22,6 +22,16 @@ declare global {
password: string
}
interface WorkbenchNavigationState {
canGoBack: boolean
canGoForward: boolean
loading: boolean
url: string
title: string
host: string
secure: boolean
}
interface Window {
desktopBridge: {
app: {
@@ -85,6 +95,13 @@ declare global {
): () => void
onNetworkObserved(listener: (event: DesktopObservedRequest) => void): () => void
}
workbenchNavigation: {
getState(): Promise<WorkbenchNavigationState>
goBack(): Promise<null>
goForward(): Promise<null>
reload(): Promise<null>
onStateChanged(listener: (state: WorkbenchNavigationState) => void): () => void
}
}
}
}
+7 -7
View File
@@ -151,14 +151,14 @@ qdrant:
timeout: 15s
object_storage:
provider: minio
endpoint: minio:9000
access_key: minioadmin
secret_key: minioadmin
bucket: geo-private
use_ssl: false
provider: aliyun
endpoint: https://oss-cn-shanghai.aliyuncs.com
access_key: LTAI5tAEj8euR8B1gXzMoG84
secret_key: lJPrKo9ViyHJE8UWIMmMCY0B6Je8v2
bucket: shengxintui
use_ssl: true
public_base_url: ""
region: ""
region: cn-shanghai
cache:
driver: redis