A 新增web终端自定义主题

pull/517/head
vapao 2022-06-30 10:26:02 +08:00
parent dfbe1bf426
commit 02261a7a6f
11 changed files with 364 additions and 22 deletions

View File

@ -2,6 +2,7 @@
# Copyright: (c) <spug.dev@gmail.com>
# Released under the AGPL-3.0 License.
from django.db import models
from apps.account.models import User
from libs import ModelMixin
import json
@ -40,3 +41,13 @@ class Setting(models.Model, ModelMixin):
class Meta:
db_table = 'settings'
class UserSetting(models.Model, ModelMixin):
user = models.ForeignKey(User, on_delete=models.CASCADE)
key = models.CharField(max_length=32)
value = models.TextField()
class Meta:
db_table = 'user_settings'
unique_together = ('user', 'key')

View File

@ -3,10 +3,12 @@
# Released under the AGPL-3.0 License.
# from django.urls import path
from django.conf.urls import url
from .views import *
from apps.setting.views import *
from apps.setting.user import UserSettingView
urlpatterns = [
url(r'^$', SettingView.as_view()),
url(r'^user/$', UserSettingView.as_view()),
url(r'^ldap_test/$', ldap_test),
url(r'^email_test/$', email_test),
url(r'^mfa/$', MFAView.as_view()),

View File

@ -0,0 +1,28 @@
# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug
# Copyright: (c) <spug.dev@gmail.com>
# Released under the AGPL-3.0 License.
from django.views.generic import View
from libs import JsonParser, Argument, json_response
from apps.setting.models import UserSetting
class UserSettingView(View):
def get(self, request):
response = {}
for item in UserSetting.objects.filter(user=request.user):
response[item.key] = item.value
return json_response(response)
def post(self, request):
form, error = JsonParser(
Argument('key', help='参数错误'),
Argument('value', help='参数错误'),
).parse(request.body)
if error is None:
UserSetting.objects.update_or_create(
user=request.user,
key=form.key,
defaults={'value': form.value}
)
return self.get(request)
return json_response(error=error)

45
spug_web/src/gStore.js Normal file
View File

@ -0,0 +1,45 @@
/**
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
* Copyright (c) <spug.dev@gmail.com>
* Released under the AGPL-3.0 License.
*/
import { observable } from 'mobx';
import http from 'libs/http';
import themes from 'pages/ssh/themes';
class Store {
isReady = false;
@observable terminal = {
fontSize: 16,
fontFamily: 'Courier',
theme: 'dark',
styles: themes['dark']
};
_handleSettings = (res) => {
if (res.terminal) {
const terminal = JSON.parse(res.terminal)
terminal.styles = themes[terminal.theme]
this.terminal = terminal
}
}
fetchUserSettings = () => {
if (this.isReady) return
http.get('/api/setting/user/')
.then(res => {
this.isReady = true
this._handleSettings(res)
})
};
updateUserSettings = (key, value) => {
return http.post('/api/setting/user/', {key, value})
.then(res => {
this.isReady = true
this._handleSettings(res)
})
}
}
export default new Store()

View File

@ -229,6 +229,7 @@ class FileManager extends React.Component {
<Input ref={ref => this.input2 = ref} size="small" className={styles.input}
suffix={<div style={{color: '#999', fontSize: 12}}>回车确认</div>}
value={this.state.inputPath} onChange={e => this.setState({inputPath: e.target.value})}
onBlur={this.handleInputEnter}
onPressEnter={this.handleInputEnter}/>
) : (
<Breadcrumb className={styles.bread}>

View File

@ -0,0 +1,100 @@
/**
* Copyright (c) OpenSpug Organization. https://github.com/openspug/spug
* Copyright (c) <spug.dev@gmail.com>
* Released under the AGPL-3.0 License.
*/
import React, { useState, useEffect } from 'react';
import { Drawer, Form, Button, Select, Space, message } from 'antd';
import themes from './themes';
import gStore from 'gStore';
import css from './setting.module.less'
function Setting(props) {
const [theme, setTheme] = useState('dark')
const [styles, setStyles] = useState(themes['dark'])
const [fontSize, setFontSize] = useState(14)
const [fontFamily, setFontFamily] = useState('Courier')
const [loading, setLoading] = useState(false)
useEffect(() => {
const {theme, styles, fontSize, fontFamily} = gStore.terminal
setTheme(theme)
setStyles(styles)
setFontSize(fontSize)
setFontFamily(fontFamily)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gStore.terminal])
useEffect(() => {
setStyles(themes[theme])
}, [theme])
function handleSubmit() {
setLoading(true)
const data = {fontSize, fontFamily, theme}
gStore.updateUserSettings('terminal', JSON.stringify(data))
.then(() => {
message.success('已保存')
props.onClose()
})
.finally(() => setLoading(false))
}
return (<Drawer
title="终端设置"
placement="right"
width={300}
visible={props.visible}
onClose={props.onClose}>
<Form layout="vertical">
<Form.Item label="字体大小">
<Select value={fontSize} placeholder="请选择字体大小" onChange={v => setFontSize(v)}>
<Select.Option value={12}>12</Select.Option>
<Select.Option value={14}>14</Select.Option>
<Select.Option value={16}>16</Select.Option>
<Select.Option value={18}>18</Select.Option>
<Select.Option value={20}>20</Select.Option>
</Select>
</Form.Item>
<Form.Item label="字体名称">
<Select value={fontFamily} placeholder="请选择字体" onChange={v => setFontFamily(v)}>
<Select.Option value="Courier">Courier</Select.Option>
<Select.Option value="Consolas">Consolas</Select.Option>
<Select.Option value="DejaVu Sans Mono">DejaVu Sans Mono</Select.Option>
<Select.Option value="Droid Sans Mono">Droid Sans Mono</Select.Option>
<Select.Option value="Monaco">Monaco</Select.Option>
<Select.Option value="Menlo">Menlo</Select.Option>
<Select.Option value="monospace">monospace</Select.Option>
<Select.Option value="Source Code Pro">Source Code Pro</Select.Option>
</Select>
</Form.Item>
<Form.Item label="主题配色">
<Space wrap className={css.theme} size={12}>
{Object.entries(themes).map(([key, item]) => (
<pre key={key} style={{background: item.background, color: item.foreground}}
onClick={() => setTheme(key)}>spug</pre>))}
</Space>
</Form.Item>
<Form.Item label="预览">
<div className={css.preview}
style={{fontSize, fontFamily, background: styles.background, color: styles.foreground}}>
<div>Welcome to Spug !</div>
<div>* Website: https://spug.cc</div>
<div>[root@iZ8vb48roZ ~]# ls</div>
<div>
<span style={{color: styles.brightBlue}}>apps </span>
<span style={{color: styles.brightRed}}>bak.tar.gz </span>
<span style={{color: styles.brightGreen}}>manage.py </span>
<span>README.md</span>
</div>
<div>[root@iZ8vb48roZ ~]# pwd</div>
<div>/data/api</div>
<div>[root@iZ8vb48roZ ~]#</div>
</div>
</Form.Item>
<Button block type="primary" className={css.btn} loading={loading} onClick={handleSubmit}>保存</Button>
</Form>
</Drawer>)
}
export default Setting

View File

@ -9,7 +9,7 @@ import { FitAddon } from 'xterm-addon-fit';
import { X_TOKEN } from 'libs';
import 'xterm/css/xterm.css';
import styles from './index.module.less';
import gStore from 'gStore';
function WebSSH(props) {
const container = useRef();
@ -18,8 +18,9 @@ function WebSSH(props) {
useEffect(() => {
term.loadAddon(fitPlugin);
term.setOption('fontFamily', 'Source Code Pro, Courier New, Courier, Monaco, monospace, PingFang SC, Microsoft YaHei')
term.setOption('theme', {background: '#2b2b2b', foreground: '#A9B7C6'})
term.setOption('fontSize', gStore.terminal.fontSize)
term.setOption('fontFamily', gStore.terminal.fontFamily)
term.setOption('theme', gStore.terminal.styles)
term.attachCustomKeyEventHandler((arg) => {
if (arg.code === 'PageUp' && arg.type === 'keydown') {
term.scrollPages(-1)
@ -58,6 +59,14 @@ function WebSSH(props) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
term.setOption('fontSize', gStore.terminal.fontSize)
term.setOption('fontFamily', gStore.terminal.fontFamily)
term.setOption('theme', gStore.terminal.styles)
fitTerminal()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gStore.terminal])
useEffect(() => {
if (props.vId === props.activeId) {
setTimeout(() => term.focus())
@ -79,9 +88,7 @@ function WebSSH(props) {
}
return (
<div className={styles.termContainer}>
<div className={styles.terminal} ref={container}/>
</div>
<div className={styles.terminal} ref={container}/>
)
}

View File

@ -18,11 +18,14 @@ import {
VerticalAlignMiddleOutlined,
CloseOutlined,
LeftOutlined,
SkinFilled,
} from '@ant-design/icons';
import { NotFound, AuthButton } from 'components';
import Terminal from './Terminal';
import FileManager from './FileManager';
import Setting from './Setting';
import { http, hasPermission, includes } from 'libs';
import gStore from 'gStore';
import styles from './index.module.less';
import LogoSpugText from 'layout/logo-spug-white.png';
import lds from 'lodash';
@ -31,6 +34,7 @@ let posX = 0
function WebSSH(props) {
const [visible, setVisible] = useState(false);
const [visible2, setVisible2] = useState(false);
const [fetching, setFetching] = useState(true);
const [rawTreeData, setRawTreeData] = useState([]);
const [rawHostList, setRawHostList] = useState([]);
@ -46,6 +50,7 @@ function WebSSH(props) {
window.document.title = 'Spug web terminal'
window.addEventListener('beforeunload', leaveTips)
fetchNodes()
gStore.fetchUserSettings()
return () => window.removeEventListener('beforeunload', leaveTips)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
@ -222,11 +227,11 @@ function WebSSH(props) {
<div className={styles.hosts}>
<Spin spinning={fetching}>
<Input allowClear className={styles.search} prefix={<SearchOutlined style={{color: '#999'}}/>}
placeholder="输入检索" onChange={e => setSearchValue(e.target.value)}/>
placeholder="输入主机名/IP检索" onChange={e => setSearchValue(e.target.value)}/>
<Button icon={<SyncOutlined/>} type="link" loading={fetching} onClick={fetchNodes}/>
{treeData.length > 0 ? (
<Tree.DirectoryTree
defaultExpandAll={treeData.length > 0 && treeData < 5}
defaultExpandAll={treeData.length > 0 && treeData.length < 5}
expandAction="doubleClick"
treeData={treeData}
icon={renderIcon}
@ -247,13 +252,15 @@ function WebSSH(props) {
tabBarExtraContent={hosts.length === 0 ? (
<div className={styles.tips}>小提示双击标签快速复制窗口右击标签展开更多操作</div>
) : sshMode ? (
<AuthButton
auth="host.console.list"
type="link"
disabled={!activeId}
style={{marginRight: 5}}
onClick={handleOpenFileManager}
icon={<LeftOutlined/>}>文件管理器</AuthButton>
<React.Fragment>
<AuthButton
auth="host.console.list"
type="link"
disabled={!activeId}
onClick={handleOpenFileManager}
icon={<LeftOutlined/>}>文件管理器</AuthButton>
<SkinFilled className={styles.setting} onClick={() => setVisible2(true)}/>
</React.Fragment>
) : null}>
{hosts.map(item => (
<Tabs.TabPane key={item.vId} tab={<TabRender host={item}/>}>
@ -280,6 +287,7 @@ function WebSSH(props) {
onClose={() => setVisible(false)}>
<FileManager id={hostId}/>
</Drawer>
<Setting visible={visible2} onClose={() => setVisible2(false)}/>
</div>
) : (
<div style={{height: '100vh'}}>

View File

@ -100,6 +100,13 @@
height: calc(100vh - 66px);
}
.setting {
cursor: pointer;
padding-right: 6px;
margin-right: 6px;
color: #fa8c16;
}
:global(.ant-tabs-nav) {
height: 42px;
margin: 0;
@ -126,14 +133,16 @@
}
}
.termContainer {
.terminal {
margin: 12px;
border-radius: 6px;
background-color: #2b2b2b;
padding: 10px 0 10px 10px;
.terminal {
height: calc(100vh - 84px);
:global(.xterm) {
padding: 10px 0 6px 10px;
height: calc(100vh - 66px);
}
:global(.xterm-viewport) {
border-radius: 6px;
}
}

View File

@ -0,0 +1,20 @@
.theme {
pre {
padding: 2px 6px;
border-radius: 4px;
border: 1px solid #333333;
cursor: pointer;
}
}
.preview {
border: 1px solid rgba(0, 0, 0, 0.1);
padding: 4px;
border-radius: 4px;
font-family: Source Code Pro, Courier New, Courier, Monaco, monospace, PingFang SC, Microsoft YaHei;
}
.btn {
margin-top: 24px;
}

View File

@ -0,0 +1,111 @@
export default {
solarized_dark: {
foreground: '#839496', background: '#2b2b2b', cursor: '#839496',
black: '#1b1b1b', brightBlack: '#626262',
red: '#bb5653', brightRed: '#bb5653',
green: '#909d62', brightGreen: '#909d62',
yellow: '#eac179', brightYellow: '#eac179',
blue: '#7da9c7', brightBlue: '#7da9c7',
magenta: '#b06597', brightMagenta: '#b06597',
cyan: '#8cdcd8', brightCyan: '#8cdcd8',
white: '#d8d8d8', brightWhite: '#f7f7f7'
}, dark: {
foreground: '#c7c7c7', background: '#000000', cursor: '#c7c7c7',
black: '#000000', brightBlack: '#676767',
red: '#c91b00', brightRed: '#ff6d67',
green: '#00c200', brightGreen: '#5ff967',
yellow: '#c7c400', brightYellow: '#fefb67',
blue: '#0225c7', brightBlue: '#6871ff',
magenta: '#c930c7', brightMagenta: '#ff76ff',
cyan: '#00c5c7', brightCyan: '#5ffdff',
white: '#c7c7c7', brightWhite: '#fffefe'
}, ubuntu: {
foreground: '#f1f1ef', background: '#3f0e2f', cursor: '#c7c7c7',
black: '#3c4345', brightBlack: '#676965',
red: '#d71e00', brightRed: '#f44135',
green: '#5da602', brightGreen: '#98e342',
yellow: '#cfad00', brightYellow: '#fcea60',
blue: '#417ab3', brightBlue: '#83afd8',
magenta: '#88658d', brightMagenta: '#bc93b6',
cyan: '#00a7aa', brightCyan: '#37e5e7',
white: '#dbded8', brightWhite: '#f1f1ef'
}, light: {
foreground: '#000000', background: '#fffefe', cursor: '#000000',
black: '#000000', brightBlack: '#676767',
red: '#c91b00', brightRed: '#ff6d67',
green: '#00c200', brightGreen: '#5ff967',
yellow: '#c7c400', brightYellow: '#fefb67',
blue: '#0225c7', brightBlue: '#6871ff',
magenta: '#c930c7', brightMagenta: '#ff76ff',
cyan: '#00c5c7', brightCyan: '#5ffdff',
white: '#c7c7c7', brightWhite: '#fffefe'
}, solarized_light: {
foreground: '#657b83', background: '#fdf6e3', cursor: '#657b83',
black: '#073642', brightBlack: '#002b36',
red: '#dc322f', brightRed: '#cb4b16',
green: '#859900', brightGreen: '#586e75',
yellow: '#b58900', brightYellow: '#657b83',
blue: '#268bd2', brightBlue: '#839496',
magenta: '#d33682', brightMagenta: '#6c71c4',
cyan: '#2aa198', brightCyan: '#93a1a1',
white: '#eee8d5', brightWhite: '#fdf6e3'
}, material: {
foreground: '#2e2d2c', background: '#eeeeee', cursor: '#2e2d2c',
black: '#2c2c2c', brightBlack: '#535353',
red: '#c52728', brightRed: '#ee524f',
green: '#558a2f', brightGreen: '#8bc24a',
yellow: '#f8a725', brightYellow: '#ffea3b',
blue: '#1564bf', brightBlue: '#64b4f5',
magenta: '#691e99', brightMagenta: '#b967c7',
cyan: '#00828e', brightCyan: '#26c5d9',
white: '#f2f1f1', brightWhite: '#e0dfdf'
},
}