feat(logs): metrics 指标代理端点(admin) + 快捷查询选中 App/Pod 即插入并执行
- POST /logs/metrics:LogQL 指标查询代理,[$step] 占位符按跨度自适应步长,返回多序列(httptest 假 Loki 单测) - 快捷查询:选中 App/Pod 即写入输入框并立即查询,面板保持打开支持 Pod 级联;命名空间仅级联筛选不插入,切换时清理失效标签 - 删除冗余的「查询该范围日志」「指定副本日志」按钮;Ingress 按钮仅在选择 ingress 命名空间/app 时显示 - /logs 支持 ?q= 预填自动执行(表格「查看日志」跳转入口) - 清理无引用死组件 LogResults/LogHistogram
This commit is contained in:
1 parent
5ce90fe2e5
commit
16306a9532
10 files changed
+264
-295
No files matched your search
@@ -1,70 +0,0 @@
|
||||
import { memo } from 'react'
|
||||
import { AreaChart, Area, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recharts'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type { HistogramBucket } from '@/api/logs'
|
||||
|
||||
interface LogHistogramProps {
|
||||
buckets: HistogramBucket[]
|
||||
loading: boolean
|
||||
/** 点击柱体 → 缩放到相邻 bucket 时间范围 */
|
||||
onZoomRange: (start: number, end: number) => void
|
||||
}
|
||||
|
||||
/** 日志量直方图(服务端 count_over_time 聚合);点击柱体缩放时间范围 */
|
||||
export const LogHistogram = memo(function LogHistogram({ buckets, loading, onZoomRange }: LogHistogramProps) {
|
||||
const { t } = useTranslation('logs')
|
||||
const data = buckets.map((b) => ({
|
||||
ts: b.timestamp.slice(11, 16),
|
||||
count: b.count,
|
||||
raw: new Date(b.timestamp).getTime(),
|
||||
}))
|
||||
|
||||
if (loading && data.length === 0) {
|
||||
return <div className="h-20 flex items-center justify-center text-xs text-muted-foreground">{t('volume.histogramLoading')}</div>
|
||||
}
|
||||
if (data.length === 0) {
|
||||
return <div className="h-20 flex items-center justify-center text-xs text-muted-foreground">{t('volume.histogramEmpty')}</div>
|
||||
}
|
||||
|
||||
const handleClick = (idx: number) => {
|
||||
const cur = data[idx]?.raw
|
||||
if (!cur) return
|
||||
const step = data.length > 1 ? Math.max(data[1].raw - data[0].raw, 60000) : 300000
|
||||
onZoomRange(cur - step / 2, cur + step / 2)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-20">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart
|
||||
data={data}
|
||||
margin={{ top: 4, right: 8, bottom: 0, left: 8 }}
|
||||
onClick={(state: { activeTooltipIndex?: number }) => {
|
||||
if (state?.activeTooltipIndex !== undefined) handleClick(state.activeTooltipIndex)
|
||||
}}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id="logVol" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="hsl(var(--primary))" stopOpacity={0.5} />
|
||||
<stop offset="100%" stopColor="hsl(var(--primary))" stopOpacity={0.05} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<XAxis dataKey="ts" tick={{ fontSize: 10 }} stroke="hsl(var(--muted-foreground))" interval="preserveStartEnd" />
|
||||
<YAxis tick={{ fontSize: 10 }} stroke="hsl(var(--muted-foreground))" width={28} allowDecimals={false} />
|
||||
<Tooltip
|
||||
contentStyle={{ fontSize: 11, borderRadius: 8, background: 'hsl(var(--popover))', border: '1px solid hsl(var(--border))' }}
|
||||
labelStyle={{ color: 'hsl(var(--popover-foreground))' }}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="count"
|
||||
stroke="hsl(var(--primary))"
|
||||
fill="url(#logVol)"
|
||||
strokeWidth={1.5}
|
||||
cursor="pointer"
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -1,215 +0,0 @@
|
||||
import { memo, useMemo, useRef, useState } from 'react'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { LogEntry } from '@/api/logs'
|
||||
import { applyLabelFilter } from '../query-utils'
|
||||
|
||||
// 级别 → 颜色(语义 token)
|
||||
const LEVEL_COLOR: Record<string, string> = {
|
||||
error: 'text-destructive',
|
||||
warn: 'text-warning',
|
||||
info: 'text-success',
|
||||
debug: 'text-muted-foreground',
|
||||
}
|
||||
|
||||
/** 从日志行提取 k=v / JSON 字段(点击过滤用) */
|
||||
function detectFields(message: string): Array<[string, string]> {
|
||||
const out: Array<[string, string]> = []
|
||||
const trimmed = message.trim()
|
||||
if (trimmed.startsWith('{')) {
|
||||
try {
|
||||
const obj = JSON.parse(trimmed)
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
|
||||
out.push([k, String(v)])
|
||||
}
|
||||
}
|
||||
return out.slice(0, 12)
|
||||
} catch {
|
||||
/* 非 JSON 继续 */
|
||||
}
|
||||
}
|
||||
const re = /([a-zA-Z_][\w.-]*)=("[^"]*"|\S+)/g
|
||||
let m: RegExpExecArray | null
|
||||
while ((m = re.exec(message)) !== null && out.length < 12) {
|
||||
out.push([m[1], m[2].replace(/^"|"$/g, '')])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
interface LogResultsProps {
|
||||
entries: LogEntry[]
|
||||
loading: boolean
|
||||
hasMore: boolean
|
||||
onLoadMore: () => void
|
||||
/** 点击标签过滤(改写查询) */
|
||||
onFilter: (nextQuery: string) => void
|
||||
query: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 日志结果区(Grafana 风格裁剪版):级别着色、行展开(原文 + labels + 提取字段,
|
||||
* 点击即过滤/排除)、去重开关
|
||||
*/
|
||||
export const LogResults = memo(function LogResults({
|
||||
entries,
|
||||
loading,
|
||||
hasMore,
|
||||
onLoadMore,
|
||||
onFilter,
|
||||
query,
|
||||
}: LogResultsProps) {
|
||||
const { t } = useTranslation('logs')
|
||||
const [expanded, setExpanded] = useState<Set<string>>(new Set())
|
||||
const [dedup, setDedup] = useState(false)
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const display = useMemo(() => {
|
||||
if (!dedup) return entries
|
||||
const seen = new Set<string>()
|
||||
return entries.filter((e) => {
|
||||
const k = e.message.replace(/\d+/g, '#')
|
||||
if (seen.has(k)) return false
|
||||
seen.add(k)
|
||||
return true
|
||||
})
|
||||
}, [entries, dedup])
|
||||
|
||||
const toggle = (key: string) => {
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(key)) next.delete(key)
|
||||
else next.add(key)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// 公共标签(所有结果共有)
|
||||
const commonLabels = useMemo(() => {
|
||||
if (entries.length === 0) return [] as string[]
|
||||
let keys = Object.keys(entries[0].labels)
|
||||
for (const e of entries) {
|
||||
keys = keys.filter((k) => e.labels[k] !== undefined && e.labels[k] === entries[0].labels[k])
|
||||
}
|
||||
return keys
|
||||
}, [entries])
|
||||
|
||||
if (loading && entries.length === 0) {
|
||||
return <div className="h-40 flex items-center justify-center text-sm text-muted-foreground">{t('raw.searching')}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t('raw.count', { count: display.length })}
|
||||
{dedup && t('raw.dedupSuffix', { count: entries.length })}
|
||||
{commonLabels.length > 0 && (
|
||||
<span className="ml-2 inline-flex flex-wrap gap-1">
|
||||
{commonLabels.slice(0, 6).map((k) => (
|
||||
<span key={k} className="px-1.5 py-0.5 rounded bg-muted">
|
||||
{k}={entries[0].labels[k]}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||
<input type="checkbox" checked={dedup} onChange={(e) => setDedup(e.target.checked)} className="accent-primary" />
|
||||
{t('raw.dedup')}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div ref={listRef} className="rounded-lg border border-border divide-y divide-border max-h-[52vh] overflow-y-auto bg-card">
|
||||
{display.length === 0 ? (
|
||||
<div className="py-12 text-center text-sm text-muted-foreground">{t('raw.empty')}</div>
|
||||
) : (
|
||||
display.map((e, idx) => {
|
||||
const key = `${e.timestamp}-${idx}`
|
||||
const open = expanded.has(key)
|
||||
const fields = open ? detectFields(e.message) : []
|
||||
return (
|
||||
<div key={key} className="text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggle(key)}
|
||||
className="w-full text-left flex items-start gap-2 px-3 py-1.5 hover:bg-muted/50"
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDown className="w-3 h-3 mt-0.5 flex-shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="w-3 h-3 mt-0.5 flex-shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="text-muted-foreground flex-shrink-0 tabular-nums">{e.timestamp.slice(11, 23)}</span>
|
||||
<span className={cn('font-semibold flex-shrink-0 uppercase w-10', LEVEL_COLOR[e.level] || 'text-muted-foreground')}>
|
||||
{e.level}
|
||||
</span>
|
||||
<span className="font-mono break-all min-w-0">{e.message.slice(0, 500)}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-3 pb-3 pl-10 space-y-2">
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground mb-1">{t('raw.original')}</p>
|
||||
<pre className="p-2 rounded bg-muted/60 font-mono whitespace-pre-wrap break-all">{e.message}</pre>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground mb-1">{t('raw.labelsTitle')}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(e.labels).map(([k, v]) => (
|
||||
<span key={k} className="group inline-flex items-center rounded bg-muted px-1.5 py-0.5">
|
||||
<button
|
||||
type="button"
|
||||
className="hover:text-primary"
|
||||
title={t('raw.filterValue')}
|
||||
onClick={() => onFilter(applyLabelFilter(query, k, v))}
|
||||
>
|
||||
{k}={v}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 opacity-0 group-hover:opacity-100 text-destructive"
|
||||
title={t('raw.excludeValue')}
|
||||
onClick={() => onFilter(applyLabelFilter(query, k, v, true))}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{fields.length > 0 && (
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold text-muted-foreground mb-1">{t('raw.fields')}</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{fields.map(([k, v]) => (
|
||||
<span key={k} className="inline-flex items-center rounded bg-info/10 text-info px-1.5 py-0.5">
|
||||
<button type="button" className="hover:underline" onClick={() => onFilter(applyLabelFilter(query, k, v))}>
|
||||
{k}={v}
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onLoadMore}
|
||||
className="w-full py-2 text-xs text-primary hover:bg-primary/5 rounded-lg border border-border"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? t('raw.loadingMore') : t('raw.loadMore')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
@@ -23,7 +23,8 @@ function colorizedLogQL(query: string) {
|
||||
interface QueryInputProps {
|
||||
value: string
|
||||
onChange: (value: string) => void
|
||||
onSubmit: () => void
|
||||
/** 执行查询;传入 query 时直接以其为准(避免 setQuery 异步取到旧值) */
|
||||
onSubmit: (query?: string) => void
|
||||
sourceId: number
|
||||
history: string[]
|
||||
}
|
||||
@@ -152,7 +153,10 @@ export const QueryInput = memo(function QueryInput({ value, onChange, onSubmit,
|
||||
<button type="button" className={`rounded px-3 py-1.5 text-xs ${assistantTab === 'history' ? 'bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:bg-muted'}`} onClick={() => setAssistantTab('history')}>{history.length > 0 ? t('editor.historyTabCount', { count: history.length }) : t('editor.historyTab')}</button>
|
||||
<span className="ml-auto self-center text-xs text-muted-foreground">{t('editor.escClose')}</span>
|
||||
</div>
|
||||
{assistantTab === 'quick' ? <QuickQueryTemplates compact sourceId={sourceId} query={value} onQueryChange={chooseQuery} /> :
|
||||
{assistantTab === 'quick' ? <QuickQueryTemplates compact sourceId={sourceId} query={value}
|
||||
onQueryChange={chooseQuery}
|
||||
onInsert={onChange}
|
||||
onRun={(next) => { onChange(next); onSubmit(next) }} /> :
|
||||
<div className="max-h-64 overflow-y-auto">
|
||||
{history.length === 0 ? <p className="px-2 py-5 text-center text-xs text-muted-foreground">{t('editor.historyEmpty')}</p> : history.map((item, index) => <button key={`${item}-${index}`} type="button" className="block w-full truncate rounded px-2 py-2 text-left font-mono text-xs hover:bg-muted" title={item} onClick={() => chooseQuery(item)}>{item}</button>)}
|
||||
</div>}
|
||||
|
||||
@@ -3,9 +3,9 @@ import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { logsApi } from '@/api/logs'
|
||||
import { SearchableValueSelect } from './SearchableValueSelect'
|
||||
import { applyLabelFilter, extractSelector } from '../query-utils'
|
||||
import { applyLabelFilter, extractSelector, hasLabelFilter, removeLabelFilter, replaceSelector } from '../query-utils'
|
||||
|
||||
type Template = 'app' | 'error' | 'warn' | 'pod' | 'ingress'
|
||||
type Template = 'error' | 'warn' | 'ingress'
|
||||
|
||||
/** Ingress 访问日志所在命名空间候选(按顺序取第一个实际存在的) */
|
||||
const INGRESS_NAMESPACE_CANDIDATES = ['ingress-nginx', 'ingress']
|
||||
@@ -16,10 +16,15 @@ function selectorOf(labels: Record<string, string>) {
|
||||
return `{${entries.map(([key, value]) => `${key}=${JSON.stringify(value.trim())}`).join(',')}}`
|
||||
}
|
||||
|
||||
export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }: {
|
||||
export function QuickQueryTemplates({ sourceId, query, onQueryChange, onInsert, onRun, compact }: {
|
||||
sourceId: number
|
||||
query: string
|
||||
/** 终态动作(模板按钮):写入输入框并关闭助手面板 */
|
||||
onQueryChange: (query: string) => void
|
||||
/** 只写入输入框,不执行、不关面板(清理失效标签用) */
|
||||
onInsert?: (query: string) => void
|
||||
/** 写入并立即执行查询,面板保持打开(选中 App/Pod 后直接出结果) */
|
||||
onRun?: (query: string) => void
|
||||
compact?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation('logs')
|
||||
@@ -114,11 +119,38 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }:
|
||||
// 换命名空间后旧的 app / pod 条件失效
|
||||
setApp('')
|
||||
setPod('')
|
||||
// 命名空间只作级联筛选,不主动插入查询(范围太大);
|
||||
// 但输入框里已插入的 app/pod 属于旧命名空间,需要清理,namespace 标签同步为新值
|
||||
if (!onInsert || !extractSelector(query)) return
|
||||
let next = removeLabelFilter(removeLabelFilter(query, appLabel), 'pod')
|
||||
if (value.trim()) {
|
||||
if (hasLabelFilter(next, 'namespace')) next = applyLabelFilter(next, 'namespace', value.trim())
|
||||
} else {
|
||||
next = removeLabelFilter(next, 'namespace')
|
||||
}
|
||||
if (next !== query) onInsert(next)
|
||||
}
|
||||
|
||||
function chooseApp(value: string) {
|
||||
setApp(value)
|
||||
setPod('')
|
||||
// 选中 App 即把选择器写入输入框并立即执行(命名空间范围太大故不自动执行,App 级别粒度合适)
|
||||
if (!value.trim() || !onRun) return
|
||||
let selector = extractSelector(query) || '{}'
|
||||
if (namespace.trim()) selector = applyLabelFilter(selector, 'namespace', namespace.trim())
|
||||
selector = applyLabelFilter(selector, appLabel, value.trim())
|
||||
onRun(replaceSelector(query, selector))
|
||||
}
|
||||
|
||||
function choosePod(value: string) {
|
||||
setPod(value)
|
||||
// 选 Pod 同样立即执行;级联保证 namespace/app 已选定,合并进选择器
|
||||
if (!value.trim() || !onRun) return
|
||||
let selector = extractSelector(query) || '{}'
|
||||
if (namespace.trim()) selector = applyLabelFilter(selector, 'namespace', namespace.trim())
|
||||
if (app.trim()) selector = applyLabelFilter(selector, appLabel, app.trim())
|
||||
selector = applyLabelFilter(selector, 'pod', value.trim())
|
||||
onRun(replaceSelector(query, selector))
|
||||
}
|
||||
|
||||
function generate(template: Template) {
|
||||
@@ -154,6 +186,12 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }:
|
||||
|
||||
const hasSelector = extractSelector(query).replace(/[{}\s]/g, '').length > 0
|
||||
const hasScope = Boolean(app.trim() || namespace.trim() || pod.trim() || hasSelector)
|
||||
// Ingress 访问日志按钮只在确实指向 ingress controller 时出现:
|
||||
// 选了 ingress 命名空间,或选中的 app 就是 ingress controller(如 ingress-nginx-controller-nginx),
|
||||
// 避免在业务命名空间下显示一个查不到东西的入口
|
||||
const ingressReady =
|
||||
INGRESS_NAMESPACE_CANDIDATES.includes(namespace) ||
|
||||
Boolean(app.trim() && app.toLowerCase().includes('ingress-nginx'))
|
||||
|
||||
return <section className="space-y-2" aria-label={t('templates.aria')}>
|
||||
{!compact && <h2 className="text-sm font-medium">{t('templates.title')}</h2>}
|
||||
@@ -183,18 +221,16 @@ export function QuickQueryTemplates({ sourceId, query, onQueryChange, compact }:
|
||||
label={t('templates.pod')}
|
||||
values={pods}
|
||||
value={pod}
|
||||
onChange={setPod}
|
||||
onChange={choosePod}
|
||||
placeholder={pods.length ? t('templates.selectCount', { count: pods.length }) : app ? t('templates.noPod') : t('templates.optional')}
|
||||
allowEmpty
|
||||
disabled={!sourceId}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('app')}>{t('templates.scopeLogs')}</Button>
|
||||
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('error')}>{t('templates.errorLogs')}</Button>
|
||||
<Button size="sm" variant="outline" disabled={!hasScope} onClick={() => generate('warn')}>{t('templates.warnLogs')}</Button>
|
||||
<Button size="sm" variant="outline" disabled={!pod.trim()} onClick={() => generate('pod')}>{t('templates.podLogs')}</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => generate('ingress')}>{t('templates.ingressLogs')}</Button>
|
||||
{ingressReady && <Button size="sm" variant="outline" onClick={() => generate('ingress')}>{t('templates.ingressLogs')}</Button>}
|
||||
{!hasScope && <span className="self-center text-xs text-muted-foreground">{t('templates.scopeHint')}</span>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -7,6 +7,39 @@ export function extractSelector(query: string): string {
|
||||
return i >= 0 && j > i ? query.slice(i, j + 1) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换 LogQL 的 {...} 选择器,保留其后的管道过滤(|= "x" 等);
|
||||
* 原查询没有选择器时把新选择器前置到最前面
|
||||
*/
|
||||
export function replaceSelector(query: string, nextSelector: string): string {
|
||||
const current = extractSelector(query)
|
||||
if (!current) return nextSelector + query.trim().replace(/^\{[^}]*\}/, '')
|
||||
const i = query.indexOf(current)
|
||||
return query.slice(0, i) + nextSelector + query.slice(i + current.length)
|
||||
}
|
||||
|
||||
/** 从选择器里删除指定标签的所有匹配条件(=、!=、=~、!~) */
|
||||
export function removeLabelFilter(query: string, key: string): string {
|
||||
const current = extractSelector(query)
|
||||
if (!current) return query
|
||||
const inner = current.slice(1, -1).trim()
|
||||
if (!inner) return query
|
||||
const pattern = new RegExp(`^${key}(=|!=|=~|!~)`)
|
||||
const kept = inner.split(',').map((s) => s.trim()).filter(Boolean).filter((p) => !pattern.test(p))
|
||||
const next = kept.length ? `{${kept.join(', ')}}` : '{}'
|
||||
return replaceSelector(query, next)
|
||||
}
|
||||
|
||||
/** 选择器里是否已包含指定标签的匹配条件 */
|
||||
export function hasLabelFilter(query: string, key: string): boolean {
|
||||
const current = extractSelector(query)
|
||||
if (!current) return false
|
||||
return current
|
||||
.slice(1, -1)
|
||||
.split(',')
|
||||
.some((part) => new RegExp(`^\\s*${key}(=|!=|=~|!~)`).test(part))
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 key="value" 合并进 LogQL 的 {...} 选择器(同名标签替换旧值);exclude=true 用 != 排除
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user