mirror of
https://github.com/1Panel-dev/1Panel.git
synced 2025-01-31 14:08:06 +08:00
feat: 优化日志读取 (#5485)
Refs https://github.com/1Panel-dev/1Panel/issues/3690
This commit is contained in:
parent
f4103e285d
commit
345dbda400
@ -126,6 +126,7 @@ type FileReadByLineReq struct {
|
|||||||
Type string `json:"type" validate:"required"`
|
Type string `json:"type" validate:"required"`
|
||||||
ID uint `json:"ID"`
|
ID uint `json:"ID"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
Latest bool `json:"latest"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileExistReq struct {
|
type FileExistReq struct {
|
||||||
|
@ -37,6 +37,7 @@ type FileLineContent struct {
|
|||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
End bool `json:"end"`
|
End bool `json:"end"`
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
|
Total int `json:"total"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileExist struct {
|
type FileExist struct {
|
||||||
|
@ -410,7 +410,7 @@ func (f *FileService) ReadLogByLine(req request.FileReadByLineReq) (*response.Fi
|
|||||||
logFilePath = path.Join(global.CONF.System.TmpDir, fmt.Sprintf("docker_logs/%s", req.Name))
|
logFilePath = path.Join(global.CONF.System.TmpDir, fmt.Sprintf("docker_logs/%s", req.Name))
|
||||||
}
|
}
|
||||||
|
|
||||||
lines, isEndOfFile, err := files.ReadFileByLine(logFilePath, req.Page, req.PageSize)
|
lines, isEndOfFile, total, err := files.ReadFileByLine(logFilePath, req.Page, req.PageSize, req.Latest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -418,6 +418,7 @@ func (f *FileService) ReadLogByLine(req request.FileReadByLineReq) (*response.Fi
|
|||||||
Content: strings.Join(lines, "\n"),
|
Content: strings.Join(lines, "\n"),
|
||||||
End: isEndOfFile,
|
End: isEndOfFile,
|
||||||
Path: logFilePath,
|
Path: logFilePath,
|
||||||
|
Total: total,
|
||||||
}
|
}
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
@ -1008,7 +1008,7 @@ func (w WebsiteService) OpWebsiteLog(req request.WebsiteLogReq) (*response.Websi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
filePath := path.Join(sitePath, "log", req.LogType)
|
filePath := path.Join(sitePath, "log", req.LogType)
|
||||||
lines, end, err := files.ReadFileByLine(filePath, req.Page, req.PageSize)
|
lines, end, _, err := files.ReadFileByLine(filePath, req.Page, req.PageSize, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -66,19 +66,44 @@ func IsHidden(path string) bool {
|
|||||||
return path[0] == dotCharacter
|
return path[0] == dotCharacter
|
||||||
}
|
}
|
||||||
|
|
||||||
func ReadFileByLine(filename string, page, pageSize int) ([]string, bool, error) {
|
func countLines(path string) (int, error) {
|
||||||
if !NewFileOp().Stat(filename) {
|
file, err := os.Open(path)
|
||||||
return nil, true, nil
|
|
||||||
}
|
|
||||||
file, err := os.Open(filename)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return 0, err
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(file)
|
||||||
|
lineCount := 0
|
||||||
|
for scanner.Scan() {
|
||||||
|
lineCount++
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return lineCount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadFileByLine(filename string, page, pageSize int, latest bool) (lines []string, isEndOfFile bool, total int, err error) {
|
||||||
|
if !NewFileOp().Stat(filename) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
file, err := os.Open(filename)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
totalLines, err := countLines(filename)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
total = (totalLines + pageSize - 1) / pageSize
|
||||||
reader := bufio.NewReaderSize(file, 8192)
|
reader := bufio.NewReaderSize(file, 8192)
|
||||||
|
|
||||||
var lines []string
|
if latest {
|
||||||
|
page = total
|
||||||
|
}
|
||||||
currentLine := 0
|
currentLine := 0
|
||||||
startLine := (page - 1) * pageSize
|
startLine := (page - 1) * pageSize
|
||||||
endLine := startLine + pageSize
|
endLine := startLine + pageSize
|
||||||
@ -97,9 +122,8 @@ func ReadFileByLine(filename string, page, pageSize int) ([]string, bool, error)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isEndOfFile := currentLine < endLine
|
isEndOfFile = currentLine < endLine
|
||||||
|
return
|
||||||
return lines, isEndOfFile, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetParentMode(path string) (os.FileMode, error) {
|
func GetParentMode(path string) (os.FileMode, error) {
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "1Panel-Frontend",
|
"name": "1Panel-Frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.7",
|
"version": "1.10",
|
||||||
"description": "1Panel 前端",
|
"description": "1Panel 前端",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@ -25,6 +25,7 @@
|
|||||||
"@codemirror/legacy-modes": "^6.4.0",
|
"@codemirror/legacy-modes": "^6.4.0",
|
||||||
"@codemirror/theme-one-dark": "^6.1.2",
|
"@codemirror/theme-one-dark": "^6.1.2",
|
||||||
"@element-plus/icons-vue": "^1.1.4",
|
"@element-plus/icons-vue": "^1.1.4",
|
||||||
|
"@highlightjs/vue-plugin": "^2.1.0",
|
||||||
"@vueuse/core": "^8.9.4",
|
"@vueuse/core": "^8.9.4",
|
||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"@xterm/xterm": "^5.5.0",
|
"@xterm/xterm": "^5.5.0",
|
||||||
@ -32,6 +33,7 @@
|
|||||||
"echarts": "^5.5.0",
|
"echarts": "^5.5.0",
|
||||||
"element-plus": "^2.7.5",
|
"element-plus": "^2.7.5",
|
||||||
"fit2cloud-ui-plus": "^1.1.4",
|
"fit2cloud-ui-plus": "^1.1.4",
|
||||||
|
"highlight.js": "^11.9.0",
|
||||||
"js-base64": "^3.7.7",
|
"js-base64": "^3.7.7",
|
||||||
"md-editor-v3": "^2.11.3",
|
"md-editor-v3": "^2.11.3",
|
||||||
"monaco-editor": "^0.34.1",
|
"monaco-editor": "^0.34.1",
|
||||||
|
@ -12,35 +12,23 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2.5">
|
<div class="mt-2.5">
|
||||||
<Codemirror
|
<highlightjs
|
||||||
ref="logContainer"
|
ref="editorRef"
|
||||||
:style="styleObject"
|
class="editor-main"
|
||||||
:autofocus="true"
|
language="JavaScript"
|
||||||
:placeholder="$t('website.noLog')"
|
:autodetect="false"
|
||||||
:indent-with-tab="true"
|
:code="content"
|
||||||
:tabSize="4"
|
></highlightjs>
|
||||||
:lineWrapping="true"
|
|
||||||
:matchBrackets="true"
|
|
||||||
theme="cobalt"
|
|
||||||
:styleActiveLine="true"
|
|
||||||
:extensions="extensions"
|
|
||||||
v-model="content"
|
|
||||||
:disabled="true"
|
|
||||||
@ready="handleReady"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { Codemirror } from 'vue-codemirror';
|
import { nextTick, onMounted, onUnmounted, reactive, ref } from 'vue';
|
||||||
import { javascript } from '@codemirror/lang-javascript';
|
|
||||||
import { oneDark } from '@codemirror/theme-one-dark';
|
|
||||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, shallowRef } from 'vue';
|
|
||||||
import { downloadFile } from '@/utils/util';
|
import { downloadFile } from '@/utils/util';
|
||||||
import { ReadByLine } from '@/api/modules/files';
|
import { ReadByLine } from '@/api/modules/files';
|
||||||
import { watch } from 'vue';
|
import { watch } from 'vue';
|
||||||
|
|
||||||
const extensions = [javascript(), oneDark];
|
const editorRef = ref();
|
||||||
|
|
||||||
interface LogProps {
|
interface LogProps {
|
||||||
id?: number;
|
id?: number;
|
||||||
@ -61,7 +49,7 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
style: {
|
style: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'height: calc(100vh - 200px); width: 100%; min-height: 400px',
|
default: 'height: calc(100vh - 200px); width: 100%; min-height: 400px; overflow: auto;',
|
||||||
},
|
},
|
||||||
defaultButton: {
|
defaultButton: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@ -84,29 +72,23 @@ const data = ref({
|
|||||||
|
|
||||||
let timer: NodeJS.Timer | null = null;
|
let timer: NodeJS.Timer | null = null;
|
||||||
const tailLog = ref(false);
|
const tailLog = ref(false);
|
||||||
const view = shallowRef();
|
|
||||||
const content = ref('');
|
const content = ref('');
|
||||||
const end = ref(false);
|
const end = ref(false);
|
||||||
const lastContent = ref('');
|
const lastContent = ref('');
|
||||||
const logContainer = ref();
|
|
||||||
const scrollerElement = ref<HTMLElement | null>(null);
|
const scrollerElement = ref<HTMLElement | null>(null);
|
||||||
|
const minPage = ref(1);
|
||||||
|
const maxPage = ref(1);
|
||||||
|
|
||||||
const readReq = reactive({
|
const readReq = reactive({
|
||||||
id: 0,
|
id: 0,
|
||||||
type: '',
|
type: '',
|
||||||
name: '',
|
name: '',
|
||||||
page: 0,
|
page: 1,
|
||||||
pageSize: 2000,
|
pageSize: 500,
|
||||||
|
latest: false,
|
||||||
});
|
});
|
||||||
const emit = defineEmits(['update:loading', 'update:hasContent', 'update:isReading']);
|
const emit = defineEmits(['update:loading', 'update:hasContent', 'update:isReading']);
|
||||||
|
|
||||||
const handleReady = (payload) => {
|
|
||||||
view.value = payload.view;
|
|
||||||
const editorContainer = payload.container;
|
|
||||||
const editorElement = editorContainer.querySelector('.cm-editor');
|
|
||||||
scrollerElement.value = editorElement.querySelector('.cm-scroller') as HTMLElement;
|
|
||||||
};
|
|
||||||
|
|
||||||
const loading = ref(props.loading);
|
const loading = ref(props.loading);
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@ -121,25 +103,6 @@ const changeLoading = () => {
|
|||||||
emit('update:loading', loading.value);
|
emit('update:loading', loading.value);
|
||||||
};
|
};
|
||||||
|
|
||||||
const styleObject = computed(() => {
|
|
||||||
const styles = {};
|
|
||||||
let style = 'height: calc(100vh - 200px); width: 100%; min-height: 400px';
|
|
||||||
if (props.style != null && props.style != '') {
|
|
||||||
style = props.style;
|
|
||||||
}
|
|
||||||
style.split(';').forEach((styleRule) => {
|
|
||||||
const [property, value] = styleRule.split(':');
|
|
||||||
if (property && value) {
|
|
||||||
const formattedProperty = property
|
|
||||||
.trim()
|
|
||||||
.replace(/([a-z])([A-Z])/g, '$1-$2')
|
|
||||||
.toLowerCase();
|
|
||||||
styles[formattedProperty] = value.trim();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return styles;
|
|
||||||
});
|
|
||||||
|
|
||||||
const stopSignals = [
|
const stopSignals = [
|
||||||
'docker-compose up failed!',
|
'docker-compose up failed!',
|
||||||
'docker-compose up successful!',
|
'docker-compose up successful!',
|
||||||
@ -151,11 +114,8 @@ const stopSignals = [
|
|||||||
'image push successful!',
|
'image push successful!',
|
||||||
];
|
];
|
||||||
|
|
||||||
const getContent = () => {
|
const getContent = (pre: boolean) => {
|
||||||
emit('update:isReading', true);
|
emit('update:isReading', true);
|
||||||
if (!end.value) {
|
|
||||||
readReq.page += 1;
|
|
||||||
}
|
|
||||||
readReq.id = props.config.id;
|
readReq.id = props.config.id;
|
||||||
readReq.type = props.config.type;
|
readReq.type = props.config.type;
|
||||||
readReq.name = props.config.name;
|
readReq.name = props.config.name;
|
||||||
@ -163,6 +123,7 @@ const getContent = () => {
|
|||||||
if (!end.value && res.data.end) {
|
if (!end.value && res.data.end) {
|
||||||
lastContent.value = content.value;
|
lastContent.value = content.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.data.content = res.data.content.replace(/\\u(\w{4})/g, function (match, grp) {
|
res.data.content = res.data.content.replace(/\\u(\w{4})/g, function (match, grp) {
|
||||||
return String.fromCharCode(parseInt(grp, 16));
|
return String.fromCharCode(parseInt(grp, 16));
|
||||||
});
|
});
|
||||||
@ -175,28 +136,38 @@ const getContent = () => {
|
|||||||
if (lastContent.value == '') {
|
if (lastContent.value == '') {
|
||||||
content.value = res.data.content;
|
content.value = res.data.content;
|
||||||
} else {
|
} else {
|
||||||
content.value = lastContent.value + '\n' + res.data.content;
|
content.value = pre
|
||||||
|
? res.data.content + '\n' + lastContent.value
|
||||||
|
: lastContent.value + '\n' + res.data.content;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (content.value == '') {
|
if (content.value == '') {
|
||||||
content.value = res.data.content;
|
content.value = res.data.content;
|
||||||
} else {
|
} else {
|
||||||
content.value = content.value + '\n' + res.data.content;
|
content.value = pre
|
||||||
|
? res.data.content + '\n' + content.value
|
||||||
|
: content.value + '\n' + res.data.content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
end.value = res.data.end;
|
end.value = res.data.end;
|
||||||
emit('update:hasContent', content.value !== '');
|
emit('update:hasContent', content.value !== '');
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
const state = view.value.state;
|
if (pre) {
|
||||||
view.value.dispatch({
|
if (scrollerElement.value.scrollHeight > 2000) {
|
||||||
selection: { anchor: state.doc.length, head: state.doc.length },
|
scrollerElement.value.scrollTop = 2000;
|
||||||
});
|
}
|
||||||
view.value.focus();
|
} else {
|
||||||
const firstLine = view.value.state.doc.line(view.value.state.doc.lines);
|
scrollerElement.value.scrollTop = scrollerElement.value.scrollHeight;
|
||||||
const { top } = view.value.lineBlockAt(firstLine.from);
|
}
|
||||||
scrollerElement.value.scrollTo({ top, behavior: 'instant' });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (readReq.latest) {
|
||||||
|
readReq.page = res.data.total;
|
||||||
|
readReq.latest = false;
|
||||||
|
maxPage.value = res.data.total;
|
||||||
|
minPage.value = res.data.total;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -206,7 +177,7 @@ const changeTail = (fromOutSide: boolean) => {
|
|||||||
}
|
}
|
||||||
if (tailLog.value) {
|
if (tailLog.value) {
|
||||||
timer = setInterval(() => {
|
timer = setInterval(() => {
|
||||||
getContent();
|
getContent(false);
|
||||||
}, 1000 * 2);
|
}, 1000 * 2);
|
||||||
} else {
|
} else {
|
||||||
onCloseLog();
|
onCloseLog();
|
||||||
@ -230,6 +201,10 @@ function isScrolledToBottom(element: HTMLElement): boolean {
|
|||||||
return element.scrollTop + element.clientHeight + 1 >= element.scrollHeight;
|
return element.scrollTop + element.clientHeight + 1 >= element.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isScrolledToTop(element: HTMLElement): boolean {
|
||||||
|
return element.scrollTop === 0;
|
||||||
|
}
|
||||||
|
|
||||||
const init = () => {
|
const init = () => {
|
||||||
if (props.config.tail) {
|
if (props.config.tail) {
|
||||||
tailLog.value = props.config.tail;
|
tailLog.value = props.config.tail;
|
||||||
@ -239,30 +214,56 @@ const init = () => {
|
|||||||
if (tailLog.value) {
|
if (tailLog.value) {
|
||||||
changeTail(false);
|
changeTail(false);
|
||||||
}
|
}
|
||||||
getContent();
|
readReq.latest = true;
|
||||||
|
getContent(false);
|
||||||
|
|
||||||
nextTick(() => {
|
nextTick(() => {});
|
||||||
if (scrollerElement.value) {
|
|
||||||
scrollerElement.value.addEventListener('scroll', function () {
|
|
||||||
if (isScrolledToBottom(scrollerElement.value)) {
|
|
||||||
getContent();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const clearLog = (): void => {
|
const clearLog = (): void => {
|
||||||
content.value = '';
|
content.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const initCodemirror = () => {
|
||||||
|
nextTick(() => {
|
||||||
|
if (editorRef.value) {
|
||||||
|
scrollerElement.value = editorRef.value.$el as HTMLElement;
|
||||||
|
scrollerElement.value.addEventListener('scroll', function () {
|
||||||
|
if (isScrolledToBottom(scrollerElement.value)) {
|
||||||
|
readReq.page = maxPage.value;
|
||||||
|
getContent(false);
|
||||||
|
}
|
||||||
|
if (isScrolledToTop(scrollerElement.value)) {
|
||||||
|
readReq.page = minPage.value - 1;
|
||||||
|
if (readReq.page < 1) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
minPage.value = readReq.page;
|
||||||
|
getContent(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
let hljsDom = scrollerElement.value.querySelector('.hljs') as HTMLElement;
|
||||||
|
hljsDom.style['min-height'] = '300px';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
onCloseLog();
|
onCloseLog();
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
initCodemirror();
|
||||||
init();
|
init();
|
||||||
});
|
});
|
||||||
|
|
||||||
defineExpose({ changeTail, onDownload, clearLog });
|
defineExpose({ changeTail, onDownload, clearLog });
|
||||||
</script>
|
</script>
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.editor-main {
|
||||||
|
height: calc(100vh - 480px);
|
||||||
|
width: 100%;
|
||||||
|
min-height: 400px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
@ -6,6 +6,9 @@ import '@/styles/common.scss';
|
|||||||
import '@/assets/iconfont/iconfont.css';
|
import '@/assets/iconfont/iconfont.css';
|
||||||
import '@/assets/iconfont/iconfont.js';
|
import '@/assets/iconfont/iconfont.js';
|
||||||
import '@/styles/style.css';
|
import '@/styles/style.css';
|
||||||
|
import 'highlight.js/styles/atom-one-dark.css';
|
||||||
|
import 'highlight.js/lib/common';
|
||||||
|
|
||||||
const styleModule = import.meta.glob('xpack/styles/index.scss');
|
const styleModule = import.meta.glob('xpack/styles/index.scss');
|
||||||
for (const path in styleModule) {
|
for (const path in styleModule) {
|
||||||
styleModule[path]?.();
|
styleModule[path]?.();
|
||||||
@ -21,8 +24,10 @@ import Components from '@/components';
|
|||||||
import ElementPlus from 'element-plus';
|
import ElementPlus from 'element-plus';
|
||||||
import Fit2CloudPlus from 'fit2cloud-ui-plus';
|
import Fit2CloudPlus from 'fit2cloud-ui-plus';
|
||||||
import * as Icons from '@element-plus/icons-vue';
|
import * as Icons from '@element-plus/icons-vue';
|
||||||
|
import hljsVuePlugin from '@highlightjs/vue-plugin';
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
app.use(hljsVuePlugin);
|
||||||
app.component('SvgIcon', SvgIcon);
|
app.component('SvgIcon', SvgIcon);
|
||||||
app.use(ElementPlus);
|
app.use(ElementPlus);
|
||||||
app.use(Fit2CloudPlus, { locale: i18n.global.messages.value[localStorage.getItem('lang') || 'zh'] });
|
app.use(Fit2CloudPlus, { locale: i18n.global.messages.value[localStorage.getItem('lang') || 'zh'] });
|
||||||
|
@ -33,7 +33,6 @@
|
|||||||
</template>
|
</template>
|
||||||
<template #main>
|
<template #main>
|
||||||
<Basic :id="id" v-if="index === 'basic'"></Basic>
|
<Basic :id="id" v-if="index === 'basic'"></Basic>
|
||||||
<Safety :id="id" v-if="index === 'safety'"></Safety>
|
|
||||||
<Log :id="id" v-if="index === 'log'"></Log>
|
<Log :id="id" v-if="index === 'log'"></Log>
|
||||||
<Resource :id="id" v-if="index === 'resource'"></Resource>
|
<Resource :id="id" v-if="index === 'resource'"></Resource>
|
||||||
<PHP :id="id" v-if="index === 'php'"></PHP>
|
<PHP :id="id" v-if="index === 'php'"></PHP>
|
||||||
|
Loading…
x
Reference in New Issue
Block a user