diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 0d08e261..e9b37e82 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -9,3 +9,11 @@ updates: directory: "/" # Location of package manifests schedule: interval: "weekly" + - package-ecosystem: "gomod" + directory: "/" + schedule: + interval: "weekly" + - package-ecosystem: "npm" + directory: "/frontend" + schedule: + interval: "weekly" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..31a6a70b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,91 @@ +name: CI + +on: + pull_request: + paths: + - "**.go" + - "go.mod" + - "go.sum" + - "**.js" + - "**.mjs" + - "**.cjs" + - "**.ts" + - "**.vue" + - "**.html" + - "**.css" + - "frontend/package.json" + - "frontend/package-lock.json" + - ".nvmrc" + push: + branches: + - main + paths: + - "**.go" + - "go.mod" + - "go.sum" + - "**.js" + - "**.mjs" + - "**.cjs" + - "**.ts" + - "**.vue" + - "**.html" + - "**.css" + - "frontend/package.json" + - "frontend/package-lock.json" + - ".nvmrc" + +permissions: + contents: read + +jobs: + go-test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - name: Stub web/dist for go:embed + run: mkdir -p web/dist && touch web/dist/.gitkeep + - name: Test + run: | + go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt + go test $(cat /tmp/go-packages.txt) + + govulncheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - name: Stub web/dist for go:embed + run: mkdir -p web/dist && touch web/dist/.gitkeep + - name: Install govulncheck + run: go install golang.org/x/vuln/cmd/govulncheck@latest + - name: Run govulncheck + run: govulncheck ./... + + frontend: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-node@v5 + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: frontend/package-lock.json + - name: Install + run: npm ci + working-directory: frontend + - name: Lint + run: npm run lint + working-directory: frontend + - name: Build + run: npm run build + working-directory: frontend + - name: Audit + run: npm audit --audit-level=high + working-directory: frontend diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6b397b61..966c581b 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -2,9 +2,31 @@ name: "CodeQL Advanced" on: push: + branches: + - main tags-ignore: - "v*" + paths: + - "**.go" + - "go.mod" + - "go.sum" + - "**.js" + - "**.mjs" + - "**.cjs" + - "**.ts" + - "**.vue" + - "frontend/package-lock.json" pull_request: + paths: + - "**.go" + - "go.mod" + - "go.sum" + - "**.js" + - "**.mjs" + - "**.cjs" + - "**.ts" + - "**.vue" + - "frontend/package-lock.json" schedule: - cron: "18 2 * * 2" @@ -35,9 +57,6 @@ jobs: - name: Checkout repository uses: actions/checkout@v6 - # The Go binary embeds web/dist/ via //go:embed all:dist (web/web.go). - # web/dist/ is .gitignored, so CodeQL's autobuild for Go will fail with - # "pattern all:dist: no matching files found" unless vite emits it first. - name: Setup Node.js if: matrix.language == 'go' uses: actions/setup-node@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0b031967..3b638eab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,6 +19,17 @@ on: - "x-ui.service.arch" - "x-ui.service.rhel" pull_request: + paths: + - "**.js" + - "**.css" + - "**.html" + - "**.sh" + - "**.go" + - "go.mod" + - "go.sum" + - "x-ui.service.debian" + - "x-ui.service.arch" + - "x-ui.service.rhel" jobs: build: diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/DockerEntrypoint.sh b/DockerEntrypoint.sh index 2e5dd750..38786b14 100644 --- a/DockerEntrypoint.sh +++ b/DockerEntrypoint.sh @@ -22,7 +22,7 @@ EOF cat > /etc/fail2ban/filter.d/3x-ipl.conf << 'EOF' [Definition] -datepattern = ^%Y/%m/%d %H:%M:%S +datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S failregex = \[LIMIT_IP\]\s*Email\s*=\s*.+\s*\|\|\s*Disconnecting OLD IP\s*=\s*\s*\|\|\s*Timestamp\s*=\s*\d+ ignoreregex = EOF diff --git a/database/model/model.go b/database/model/model.go index 7c43ae42..1a51f604 100644 --- a/database/model/model.go +++ b/database/model/model.go @@ -21,12 +21,8 @@ const ( Shadowsocks Protocol = "shadowsocks" Mixed Protocol = "mixed" WireGuard Protocol = "wireguard" - // UI stores Hysteria v1 and v2 both as "hysteria" and uses - // settings.version to discriminate. Imports from outside the panel - // can carry the literal "hysteria2" string, so IsHysteria below - // accepts both. - Hysteria Protocol = "hysteria" - Hysteria2 Protocol = "hysteria2" + Hysteria Protocol = "hysteria" + Hysteria2 Protocol = "hysteria2" ) // IsHysteria returns true for both "hysteria" and "hysteria2". @@ -38,9 +34,10 @@ func IsHysteria(p Protocol) bool { // User represents a user account in the 3x-ui panel. type User struct { - Id int `json:"id" gorm:"primaryKey;autoIncrement"` - Username string `json:"username"` - Password string `json:"password"` + Id int `json:"id" gorm:"primaryKey;autoIncrement"` + Username string `json:"username"` + Password string `json:"password"` + LoginEpoch int64 `json:"-" gorm:"default:0"` } // Inbound represents an Xray inbound configuration with traffic statistics and settings. @@ -128,15 +125,16 @@ type Setting struct { // endpoint over HTTP using the per-node ApiToken to populate the runtime // status fields below. type Node struct { - Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` - Name string `json:"name" form:"name" gorm:"uniqueIndex"` - Remark string `json:"remark" form:"remark"` - Scheme string `json:"scheme" form:"scheme"` - Address string `json:"address" form:"address"` - Port int `json:"port" form:"port"` - BasePath string `json:"basePath" form:"basePath"` - ApiToken string `json:"apiToken" form:"apiToken"` - Enable bool `json:"enable" form:"enable" gorm:"default:true"` + Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` + Name string `json:"name" form:"name" gorm:"uniqueIndex"` + Remark string `json:"remark" form:"remark"` + Scheme string `json:"scheme" form:"scheme"` + Address string `json:"address" form:"address"` + Port int `json:"port" form:"port"` + BasePath string `json:"basePath" form:"basePath"` + ApiToken string `json:"apiToken" form:"apiToken"` + Enable bool `json:"enable" form:"enable" gorm:"default:true"` + AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"` // Heartbeat-updated fields. UpdatedAt advances on every probe even when // the row is otherwise unchanged so the UI's "last seen" tooltip is diff --git a/frontend/api-docs.html b/frontend/api-docs.html index 9f35080a..65ee57f7 100644 --- a/frontend/api-docs.html +++ b/frontend/api-docs.html @@ -3,7 +3,7 @@ - 3x-ui · API Docs + API Docs
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index c1749c08..95c46dbb 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -19,9 +19,6 @@ export default [ globals: { ...globals.browser, ...globals.node, - // Legacy script tags inject a couple of helpers on window before - // the SPA boots; declared here so no-undef stops flagging them. - getRandomRealityTarget: 'readonly', }, }, rules: { diff --git a/frontend/inbounds.html b/frontend/inbounds.html index 57485c92..9e8861fc 100644 --- a/frontend/inbounds.html +++ b/frontend/inbounds.html @@ -3,7 +3,7 @@ - 3x-ui · Inbounds + Inbounds
diff --git a/frontend/index.html b/frontend/index.html index b2d45443..d13b400f 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,7 @@ - 3x-ui + Overview
diff --git a/frontend/login.html b/frontend/login.html index ba8e1e05..658f8d10 100644 --- a/frontend/login.html +++ b/frontend/login.html @@ -4,7 +4,7 @@ - 3x-ui — Sign in + Sign in
diff --git a/frontend/nodes.html b/frontend/nodes.html index fb607b19..fec96dbc 100644 --- a/frontend/nodes.html +++ b/frontend/nodes.html @@ -3,7 +3,7 @@ - 3x-ui · Nodes + Nodes
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3bfbce76..365a2324 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,6 +7,10 @@ "": { "name": "3x-ui-frontend", "version": "0.0.2", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.0.0" + }, "dependencies": { "@ant-design/icons-vue": "^7.0.1", "ant-design-vue": "^4.2.6", diff --git a/frontend/package.json b/frontend/package.json index bf0ca9c1..03c381f5 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,6 +4,10 @@ "version": "0.0.2", "type": "module", "description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4 + Vite 8).", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.0.0" + }, "scripts": { "dev": "vite", "build": "vite build", diff --git a/frontend/settings.html b/frontend/settings.html index da144ba7..0ef6413b 100644 --- a/frontend/settings.html +++ b/frontend/settings.html @@ -3,7 +3,7 @@ - 3x-ui · Settings + Settings
diff --git a/frontend/src/api/axios-init.js b/frontend/src/api/axios-init.js index cae11195..2ea235c5 100644 --- a/frontend/src/api/axios-init.js +++ b/frontend/src/api/axios-init.js @@ -2,24 +2,16 @@ import axios from 'axios'; import qs from 'qs'; const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']); -// Public CSRF endpoint — works pre-login (the panel-scoped -// /panel/csrf-token sits behind checkLogin and would 401 a fresh -// login page that hasn't authenticated yet). const CSRF_TOKEN_PATH = '/csrf-token'; -// Cached session CSRF token. The legacy panel injects it via a -// tag rendered by Go; the new SPA pages -// fetch it once from /panel/csrf-token instead. Module-level so -// every axios POST sees the latest value. let csrfToken = null; let csrfFetchPromise = null; +let sessionExpired = false; function readMetaToken() { return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || null; } -// Fetch the token via a bare fetch() (not axios) so the call doesn't -// recurse through this same interceptor. async function fetchCsrfToken() { try { const basePath = window.X_UI_BASE_PATH; @@ -91,19 +83,12 @@ export function setupAxios() { async (error) => { const status = error.response?.status; if (status === 401) { - // 401 → session is gone. In production, the panel routes - // are gated by Go's checkLogin which redirects to base_path - // serving the login page; a reload is enough. In dev, Vite - // serves /index.html directly at "/", so a reload would put - // the user right back on the dashboard and the interceptor - // would loop. Navigate to the dev login entry instead. - if (import.meta.env.DEV) { + if (!sessionExpired) { + sessionExpired = true; const basePath = window.X_UI_BASE_PATH || '/'; - window.location.href = `${basePath}login.html`; - } else { - window.location.reload(); + window.location.replace(basePath); } - return Promise.reject(error); + return new Promise(() => { }); } // 403 with a stale/missing CSRF token: drop the cache, re-fetch, retry once. const cfg = error.config; diff --git a/frontend/src/components/AppSidebar.vue b/frontend/src/components/AppSidebar.vue index 7763b715..bf625f65 100644 --- a/frontend/src/components/AppSidebar.vue +++ b/frontend/src/components/AppSidebar.vue @@ -14,6 +14,7 @@ import { } from '@ant-design/icons-vue'; import { theme, currentTheme, toggleTheme, toggleUltra, pauseAnimationsUntilLeave } from '@/composables/useTheme.js'; +import { HttpUtil } from '@/utils'; const { t } = useI18n(); @@ -45,7 +46,7 @@ const tabs = computed(() => [ { key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') }, { key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') }, { key: `${prefix}panel/api-docs`, icon: 'apidocs', title: t('menu.apiDocs') }, - { key: `${prefix}logout`, icon: 'logout', title: t('logout') }, + { key: 'logout', icon: 'logout', title: t('logout') }, ]); const navTabs = computed(() => tabs.value.filter((tab) => tab.icon !== 'logout')); @@ -55,7 +56,12 @@ const drawerOpen = ref(false); const collapsed = ref(JSON.parse(localStorage.getItem(SIDEBAR_COLLAPSED_KEY) || 'false')); const drawerWidth = 'min(82vw, 320px)'; -function openLink(key) { +async function openLink(key) { + if (key === 'logout') { + await HttpUtil.post('/logout'); + window.location.href = props.basePath || '/'; + return; + } if (key.startsWith('http')) { window.open(key); } else { diff --git a/frontend/src/components/Sparkline.vue b/frontend/src/components/Sparkline.vue index e928c620..679e92f7 100644 --- a/frontend/src/components/Sparkline.vue +++ b/frontend/src/components/Sparkline.vue @@ -17,7 +17,7 @@ const props = defineProps({ showAxes: { type: Boolean, default: false }, yTickStep: { type: Number, default: 25 }, tickCountX: { type: Number, default: 4 }, - paddingLeft: { type: Number, default: 32 }, + paddingLeft: { type: Number, default: 56 }, paddingRight: { type: Number, default: 6 }, paddingTop: { type: Number, default: 6 }, paddingBottom: { type: Number, default: 20 }, diff --git a/frontend/src/entries/api-docs.js b/frontend/src/entries/api-docs.js index e5a22856..852bbc41 100644 --- a/frontend/src/entries/api-docs.js +++ b/frontend/src/entries/api-docs.js @@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css'; import { setupAxios } from '@/api/axios-init.js'; import '@/composables/useTheme.js'; import { i18n } from '@/i18n/index.js'; +import { applyDocumentTitle } from '@/utils'; import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue'; setupAxios(); +applyDocumentTitle(); const messageContainer = document.getElementById('message'); if (messageContainer) { diff --git a/frontend/src/entries/inbounds.js b/frontend/src/entries/inbounds.js index 15b34d2e..8342f476 100644 --- a/frontend/src/entries/inbounds.js +++ b/frontend/src/entries/inbounds.js @@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css'; import { setupAxios } from '@/api/axios-init.js'; import '@/composables/useTheme.js'; import { i18n } from '@/i18n/index.js'; +import { applyDocumentTitle } from '@/utils'; import InboundsPage from '@/pages/inbounds/InboundsPage.vue'; setupAxios(); +applyDocumentTitle(); const messageContainer = document.getElementById('message'); if (messageContainer) { diff --git a/frontend/src/entries/index.js b/frontend/src/entries/index.js index 61d9dc12..8e14d2ae 100644 --- a/frontend/src/entries/index.js +++ b/frontend/src/entries/index.js @@ -7,9 +7,11 @@ import { setupAxios } from '@/api/axios-init.js'; // stored theme to / before Vue mounts. import '@/composables/useTheme.js'; import { i18n } from '@/i18n/index.js'; +import { applyDocumentTitle } from '@/utils'; import IndexPage from '@/pages/index/IndexPage.vue'; setupAxios(); +applyDocumentTitle(); const messageContainer = document.getElementById('message'); if (messageContainer) { diff --git a/frontend/src/entries/login.js b/frontend/src/entries/login.js index ec90a9fb..6b8b0fc2 100644 --- a/frontend/src/entries/login.js +++ b/frontend/src/entries/login.js @@ -7,9 +7,11 @@ import { setupAxios } from '@/api/axios-init.js'; // stored theme to / before Vue renders anything. import '@/composables/useTheme.js'; import { i18n } from '@/i18n/index.js'; +import { applyDocumentTitle } from '@/utils'; import LoginPage from '@/pages/login/LoginPage.vue'; setupAxios(); +applyDocumentTitle(); // Toasts attach to a #message div the page provides — keeps theme // styling in sync with the rest of the panel. diff --git a/frontend/src/entries/nodes.js b/frontend/src/entries/nodes.js index 512643be..819fe9a9 100644 --- a/frontend/src/entries/nodes.js +++ b/frontend/src/entries/nodes.js @@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css'; import { setupAxios } from '@/api/axios-init.js'; import '@/composables/useTheme.js'; import { i18n } from '@/i18n/index.js'; +import { applyDocumentTitle } from '@/utils'; import NodesPage from '@/pages/nodes/NodesPage.vue'; setupAxios(); +applyDocumentTitle(); const messageContainer = document.getElementById('message'); if (messageContainer) { diff --git a/frontend/src/entries/settings.js b/frontend/src/entries/settings.js index 0c9a85a6..4a32bb7e 100644 --- a/frontend/src/entries/settings.js +++ b/frontend/src/entries/settings.js @@ -7,9 +7,11 @@ import { setupAxios } from '@/api/axios-init.js'; // stored theme to / before Vue mounts. import '@/composables/useTheme.js'; import { i18n } from '@/i18n/index.js'; +import { applyDocumentTitle } from '@/utils'; import SettingsPage from '@/pages/settings/SettingsPage.vue'; setupAxios(); +applyDocumentTitle(); const messageContainer = document.getElementById('message'); if (messageContainer) { diff --git a/frontend/src/entries/xray.js b/frontend/src/entries/xray.js index 90af3e55..ba203da0 100644 --- a/frontend/src/entries/xray.js +++ b/frontend/src/entries/xray.js @@ -5,9 +5,11 @@ import 'ant-design-vue/dist/reset.css'; import { setupAxios } from '@/api/axios-init.js'; import '@/composables/useTheme.js'; import { i18n } from '@/i18n/index.js'; +import { applyDocumentTitle } from '@/utils'; import XrayPage from '@/pages/xray/XrayPage.vue'; setupAxios(); +applyDocumentTitle(); const messageContainer = document.getElementById('message'); if (messageContainer) { diff --git a/frontend/src/models/dbinbound.js b/frontend/src/models/dbinbound.js index e2e0adfe..d7a9483e 100644 --- a/frontend/src/models/dbinbound.js +++ b/frontend/src/models/dbinbound.js @@ -70,6 +70,10 @@ export class DBInbound { return this.protocol === Protocols.WIREGUARD; } + get isHysteria() { + return this.protocol === Protocols.HYSTERIA; + } + get address() { let address = location.hostname; if (!ObjectUtil.isEmpty(this.listen) && this.listen !== "0.0.0.0") { diff --git a/frontend/src/models/inbound.js b/frontend/src/models/inbound.js index cccd8d4d..fdfb4560 100644 --- a/frontend/src/models/inbound.js +++ b/frontend/src/models/inbound.js @@ -1,5 +1,6 @@ import dayjs from 'dayjs'; import { ObjectUtil, RandomUtil, Base64, NumberFormatter, SizeFormatter, Wireguard } from '@/utils'; +import { getRandomRealityTarget } from '@/models/reality-targets'; export const Protocols = { VMESS: 'vmess', @@ -687,8 +688,9 @@ export class HysteriaMasquerade extends XrayCommonClass { } static fromJson(json = {}) { + const type = ['proxy', 'file', 'string'].includes(json.type) ? json.type : 'proxy'; return new HysteriaMasquerade( - json.type, + type, json.dir, json.url, json.rewriteHost, @@ -896,9 +898,7 @@ export class RealityStreamSettings extends XrayCommonClass { super(); // If target/serverNames are not provided, use random values if (!target && !serverNames) { - const randomTarget = typeof getRandomRealityTarget !== 'undefined' - ? getRandomRealityTarget() - : { target: 'www.amazon.com:443', sni: 'www.amazon.com,amazon.com' }; + const randomTarget = getRandomRealityTarget(); target = randomTarget.target; serverNames = randomTarget.sni; } diff --git a/frontend/src/models/setting.js b/frontend/src/models/setting.js index 7efc9e7d..1f465036 100644 --- a/frontend/src/models/setting.js +++ b/frontend/src/models/setting.js @@ -15,6 +15,7 @@ export class AllSetting { this.webKeyFile = ""; this.webBasePath = "/"; this.sessionMaxAge = 360; + this.trustedProxyCIDRs = "127.0.0.1/32,::1/128"; this.pageSize = 25; this.expireDiff = 0; this.trafficDiff = 0; @@ -87,6 +88,12 @@ export class AllSetting { this.ldapDefaultTotalGB = 0; this.ldapDefaultExpiryDays = 0; this.ldapDefaultLimitIP = 0; + this.hasTgBotToken = false; + this.hasTwoFactorToken = false; + this.hasLdapPassword = false; + this.hasApiToken = false; + this.hasWarpSecret = false; + this.hasNordSecret = false; if (data == null) { return @@ -97,4 +104,4 @@ export class AllSetting { equals(other) { return ObjectUtil.equals(this, other); } -} \ No newline at end of file +} diff --git a/frontend/src/pages/api-docs/ApiDocsPage.vue b/frontend/src/pages/api-docs/ApiDocsPage.vue index f749cd14..c53ae553 100644 --- a/frontend/src/pages/api-docs/ApiDocsPage.vue +++ b/frontend/src/pages/api-docs/ApiDocsPage.vue @@ -1,5 +1,5 @@ @@ -93,6 +190,7 @@ onMounted(() => { cookie, or with the Authorization: Bearer <token> header below. Every endpoint returns a uniform { success, msg, obj } envelope unless otherwise noted.

+ @@ -101,7 +199,7 @@ onMounted(() => { API Token - +
Regenerate - +
 {
             
 
             
-              
{{ curlExample }}
+
+
+ + + + + {{ visibleEndpoints }} / {{ endpointCount }} endpoints + + + + + Expand all + + + + Collapse all + + +
+ - + @@ -194,20 +333,25 @@ onMounted(() => { } .docs-header { - margin-bottom: 18px; + margin-bottom: 20px; + padding: 24px; + background: var(--bg-card); + border: 1px solid rgba(128, 128, 128, 0.12); + border-radius: 10px; } .docs-title { - font-size: 26px; - font-weight: 700; + font-size: 28px; + font-weight: 800; margin: 0 0 8px; color: rgba(0, 0, 0, 0.88); + letter-spacing: -0.3px; } .docs-lead { margin: 0; color: rgba(0, 0, 0, 0.65); - line-height: 1.6; + line-height: 1.65; font-size: 14px; } @@ -231,7 +375,8 @@ onMounted(() => { justify-content: space-between; gap: 12px; flex-wrap: wrap; - margin-bottom: 8px; + margin-bottom: 10px; + min-height: 32px; } .token-card-title { @@ -242,6 +387,13 @@ onMounted(() => { font-size: 14px; } +.token-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + .token-value { background: rgba(128, 128, 128, 0.08); border: 1px solid rgba(128, 128, 128, 0.15); @@ -275,35 +427,110 @@ onMounted(() => { overflow-x: auto; } +.toolbar { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 16px; +} + +.search-bar { + flex: 1; + min-width: 200px; + max-width: 480px; +} + +.match-count { + font-size: 12px; + color: rgba(0, 0, 0, 0.5); + white-space: nowrap; +} + .toc-nav { display: flex; flex-wrap: wrap; - align-items: center; - gap: 8px 14px; + align-items: flex-start; + gap: 8px 12px; padding: 12px 16px; - background: rgba(128, 128, 128, 0.08); - border-radius: 6px; + background: var(--bg-card); + border: 1px solid rgba(128, 128, 128, 0.12); + border-radius: 8px; margin-bottom: 16px; } .toc-label { - font-size: 12px; + font-size: 11px; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.5px; + letter-spacing: 0.6px; color: rgba(0, 0, 0, 0.5); + padding-top: 3px; + flex-shrink: 0; +} + +.toc-links { + display: flex; + flex-wrap: wrap; + gap: 6px; } .toc-link { - color: #1677ff; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 10px; + border-radius: 20px; + font-size: 12.5px; + color: rgba(0, 0, 0, 0.65); + background: rgba(128, 128, 128, 0.06); + border: 1px solid transparent; text-decoration: none; cursor: pointer; - font-size: 13px; + transition: all 0.2s; + white-space: nowrap; } .toc-link:hover { - color: #4096ff; - text-decoration: underline; + background: rgba(22, 119, 255, 0.08); + color: #1677ff; + border-color: rgba(22, 119, 255, 0.2); +} + +.toc-link.active { + background: rgba(22, 119, 255, 0.12); + color: #1677ff; + border-color: rgba(22, 119, 255, 0.3); + font-weight: 600; +} + +.toc-icon { + font-size: 13px; + opacity: 0.8; +} + +.toc-text { + font-size: 12.5px; +} + +.toc-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + font-size: 10.5px; + font-weight: 700; + background: rgba(22, 119, 255, 0.12); + color: #1677ff; + line-height: 1; +} + +.toc-link.active .toc-badge { + background: #1677ff; + color: #fff; } @@ -312,16 +539,40 @@ body.dark .docs-title { color: rgba(255, 255, 255, 0.92); } +html[data-theme='ultra-dark'] .docs-title { + color: rgba(255, 255, 255, 0.95); +} + +body.dark .docs-header { + background: #252526; + border-color: rgba(255, 255, 255, 0.08); +} + +html[data-theme='ultra-dark'] .docs-header { + background: #0a0a0a; + border-color: rgba(255, 255, 255, 0.06); +} + body.dark .docs-lead, body.dark .token-hint { color: rgba(255, 255, 255, 0.7); } +html[data-theme='ultra-dark'] .docs-lead, +html[data-theme='ultra-dark'] .token-hint { + color: rgba(255, 255, 255, 0.75); +} + body.dark .docs-lead code, body.dark .token-hint code { background: rgba(255, 255, 255, 0.1); } +html[data-theme='ultra-dark'] .docs-lead code, +html[data-theme='ultra-dark'] .token-hint code { + background: rgba(255, 255, 255, 0.12); +} + body.dark .token-value, body.dark .code-block { background: rgba(255, 255, 255, 0.04); @@ -329,11 +580,58 @@ body.dark .code-block { color: rgba(255, 255, 255, 0.88); } +html[data-theme='ultra-dark'] .token-value, +html[data-theme='ultra-dark'] .code-block { + background: rgba(255, 255, 255, 0.02); + border-color: rgba(255, 255, 255, 0.08); +} + body.dark .toc-nav { - background: rgba(255, 255, 255, 0.04); + background: #252526; + border-color: rgba(255, 255, 255, 0.08); +} + +html[data-theme='ultra-dark'] .toc-nav { + background: #0a0a0a; + border-color: rgba(255, 255, 255, 0.06); } body.dark .toc-label { color: rgba(255, 255, 255, 0.55); } + +html[data-theme='ultra-dark'] .toc-label { + color: rgba(255, 255, 255, 0.6); +} + +body.dark .toc-link { + color: rgba(255, 255, 255, 0.65); + background: rgba(255, 255, 255, 0.06); +} + +html[data-theme='ultra-dark'] .toc-link { + background: rgba(255, 255, 255, 0.04); +} + +body.dark .toc-link:hover { + background: rgba(88, 166, 255, 0.12); + color: #58a6ff; + border-color: rgba(88, 166, 255, 0.25); +} + +body.dark .toc-link.active { + background: rgba(88, 166, 255, 0.15); + color: #58a6ff; + border-color: rgba(88, 166, 255, 0.35); +} + +body.dark .toc-badge { + background: rgba(88, 166, 255, 0.15); + color: #58a6ff; +} + +body.dark .toc-link.active .toc-badge { + background: #58a6ff; + color: #0d1117; +} diff --git a/frontend/src/pages/api-docs/CodeBlock.vue b/frontend/src/pages/api-docs/CodeBlock.vue new file mode 100644 index 00000000..446016c7 --- /dev/null +++ b/frontend/src/pages/api-docs/CodeBlock.vue @@ -0,0 +1,174 @@ + + + + + + + diff --git a/frontend/src/pages/api-docs/EndpointRow.vue b/frontend/src/pages/api-docs/EndpointRow.vue index 0b7fb300..5a811427 100644 --- a/frontend/src/pages/api-docs/EndpointRow.vue +++ b/frontend/src/pages/api-docs/EndpointRow.vue @@ -1,6 +1,7 @@ + + + + @@ -1741,6 +1835,11 @@ watch( color: #ff4d4f; } +.vless-auth-state { + display: block; + margin-top: 6px; +} + .json-editor { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; diff --git a/frontend/src/pages/inbounds/InboundList.vue b/frontend/src/pages/inbounds/InboundList.vue index 88b39bc8..cface361 100644 --- a/frontend/src/pages/inbounds/InboundList.vue +++ b/frontend/src/pages/inbounds/InboundList.vue @@ -1,5 +1,5 @@