mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-27 01:32:08 +03:00
Compare commits
77 Commits
v3.8.0
...
dev-latest
| Author | SHA1 | Date | |
|---|---|---|---|
| bd9ccde1f4 | |||
| 9672249edb | |||
| 5e15120cec | |||
| 94fa317e76 | |||
| 788b76c544 | |||
| 6ac0c88084 | |||
| 6ab718f813 | |||
| 8979072bd9 | |||
| f3b100282a | |||
| bd01f923fb | |||
| b3a5be9da4 | |||
| 8f1201553e | |||
| d42e2133c7 | |||
| a2ca023336 | |||
| 3fe92df7ad | |||
| 169cd86e00 | |||
| 66df77665f | |||
| c54c28d92d | |||
| 5d41e65a3c | |||
| 0dec3d65ba | |||
| 07ee638a50 | |||
| ee2ff48c81 | |||
| 3b9ca47a4e | |||
| c0c0136037 | |||
| d86a3def85 | |||
| dcaadd4857 | |||
| fd7b3559bc | |||
| 89e200ead4 | |||
| a03228c455 | |||
| 86302d2f2d | |||
| 95f19b192f | |||
| 1c0ce80e8e | |||
| f8db7f6c29 | |||
| 536f9a6338 | |||
| d59b77bcdb | |||
| 17e89db979 | |||
| 040d01c5dc | |||
| b78dd82869 | |||
| 7ef22f94c9 | |||
| ec9fbae645 | |||
| e26cf1d3ed | |||
| 01ce2bcecb | |||
| c9e62451e6 | |||
| 5008906c4c | |||
| 5fe4f241c1 | |||
| e8bab17c2f | |||
| 14b92fbcff | |||
| 1d85ef138e | |||
| 3fa44915c1 | |||
| 7fc86f87de | |||
| 3c1498d806 | |||
| d1c4e0261b | |||
| bc49c1a68f | |||
| 3c8cf35734 | |||
| dea7cd9cc1 | |||
| 56bb876d8d | |||
| eb11e8c85a | |||
| a84bbeab2e | |||
| ea66aa4971 | |||
| cfa8350d10 | |||
| af466b6a24 | |||
| 789a03065a | |||
| bc424f0968 | |||
| ac3fc12077 | |||
| d9c7c76fb0 | |||
| d440c2b932 | |||
| e790f46757 | |||
| d089adeeea | |||
| 4a8fdceed6 | |||
| 574caa63e9 | |||
| baef3cdd07 | |||
| 43e64993fc | |||
| d52b598abf | |||
| 2d8d304850 | |||
| a036ddd66f | |||
| 78ab7a9246 | |||
| a810f497e6 |
@@ -83,12 +83,12 @@ jobs:
|
||||
- name: PostgreSQL schema and migration tests
|
||||
run: |
|
||||
set -o pipefail
|
||||
go test ./internal/database -run '^(TestHostAutoMigrateCreatesColumns_Postgres|TestMigrate_Postgres)$' -count=1 -v | tee /tmp/postgres-schema.log
|
||||
# Both must pass. Counting, not SKIP-matching: renaming either test would
|
||||
go test ./internal/database -run '^(TestHostAutoMigrateCreatesColumns_Postgres|TestMigrate_Postgres|TestClientWeeklyRenewMigration_Postgres)$' -count=1 -v | tee /tmp/postgres-schema.log
|
||||
# All must pass. Counting, not SKIP-matching: renaming a test would
|
||||
# otherwise leave this step green while testing nothing.
|
||||
passed=$(grep -c -- '--- PASS' /tmp/postgres-schema.log || true)
|
||||
if [ "$passed" -lt 2 ]; then
|
||||
echo "expected 2 passing PostgreSQL schema tests, got $passed" >&2
|
||||
passed=$(grep -c -- '^--- PASS' /tmp/postgres-schema.log || true)
|
||||
if [ "$passed" -lt 3 ]; then
|
||||
echo "expected at least 3 passing PostgreSQL schema tests, got $passed" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -44,8 +44,8 @@ jobs:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
allowed_non_write_users: "*"
|
||||
claude_args: |
|
||||
--model claude-opus-5
|
||||
--effort xhigh
|
||||
--model claude-opus-5-5
|
||||
--effort medium
|
||||
--max-turns 300
|
||||
--allowedTools "Bash(gh label list:*),Bash(gh issue view:*),Bash(gh issue list:*),Bash(gh issue comment ${{ github.event.issue.number }}:*),Bash(gh issue edit ${{ github.event.issue.number }} --add-label:*),Bash(gh issue edit ${{ github.event.issue.number }} --remove-label:*),Bash(gh issue edit ${{ github.event.issue.number }} --title:*),Bash(gh issue close ${{ github.event.issue.number }}:*),Bash(gh search issues:*),Bash(gh search commits:*),Bash(gh search prs:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr list:*),Bash(gh release list:*),Bash(gh release view:*),Bash(git log:*),Bash(git show:*),Bash(git blame:*),Bash(git ls-tree:*),Bash(git tag:*),Read,Glob,Grep,Write(//tmp/**),Edit(//tmp/**)"
|
||||
--disallowedTools "Read(//**/.git/**),Edit(//**/.git/**)"
|
||||
@@ -437,8 +437,24 @@ jobs:
|
||||
path: ${{ runner.temp }}/claude-execution-output.json
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
- name: Fail if the analysis posted no reply
|
||||
# A refused credential ends the action with exit 0, so the step below cannot
|
||||
# tell it from a reply that landed: the transcript is the only place it appears.
|
||||
- name: Report an analysis the credential refused
|
||||
id: refused
|
||||
if: ${{ !cancelled() }}
|
||||
env:
|
||||
TRANSCRIPT: ${{ runner.temp }}/claude-execution-output.json
|
||||
ISSUE: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[ -f "$TRANSCRIPT" ] || exit 0
|
||||
jq -e 'any(.[]; .type == "result" and ((.api_error_status // 0) == 401 or (.api_error_status // 0) == 403))' "$TRANSCRIPT" >/dev/null 2>&1 \
|
||||
|| jq -e 'any(.[]; ((.error // "") | test("^(oauth_|authentication_|invalid_api_key)")))' "$TRANSCRIPT" >/dev/null 2>&1 \
|
||||
|| exit 0
|
||||
echo "skipped=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::No analysis of #${ISSUE}: the Claude credential was refused, so this issue was not examined."
|
||||
- name: Fail if the analysis posted no reply
|
||||
if: ${{ !cancelled() && steps.refused.outputs.skipped != 'true' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
REPO: ${{ github.repository }}
|
||||
|
||||
@@ -118,8 +118,8 @@ jobs:
|
||||
# allowedTools only pre-approves; it denies nothing. Only the deny list
|
||||
# stops the review executing what it just checked out, or delegating.
|
||||
claude_args: |
|
||||
--model claude-opus-5
|
||||
--effort xhigh
|
||||
--model claude-opus-5-5
|
||||
--effort medium
|
||||
--max-turns 300
|
||||
--allowedTools "mcp__github_inline_comment__create_inline_comment,Bash(gh api:*),Bash(gh pr view:*),Bash(gh pr diff:*),Bash(gh pr comment ${{ env.PR }}:*),Bash(grep:*),Bash(rg:*),Bash(ls:*),Bash(find:*),Bash(sed:*),Bash(git log:*),Bash(git show:*),Bash(git diff:*),Bash(git blame:*),Bash(go doc:*),Bash(go env:*),Read,Glob,Grep,WebFetch,WebSearch"
|
||||
--disallowedTools "Agent,Bash(go build:*),Bash(go run:*),Bash(go test:*),Bash(go generate:*),Bash(go install:*),Bash(make:*),Bash(npm:*),Bash(npx:*),Bash(pnpm:*),Bash(yarn:*),Bash(node:*),Bash(bash:*),Bash(sh:*),Bash(docker:*),Bash(chmod:*),Edit,Write,NotebookEdit"
|
||||
@@ -228,10 +228,25 @@ jobs:
|
||||
echo "skipped=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::notice::No review of #${PR}: ${reason}."
|
||||
gh pr comment "$PR" --repo "$REPO" --body "No review ran on this head: ${reason}. Nothing in this pull request was examined. A maintainer can ask for one with \`@claude review\`."
|
||||
# A refused credential ends the action with exit 0, so the step above never
|
||||
# sees it: the transcript is the only place that refusal appears.
|
||||
- name: Report a review the credential refused
|
||||
id: refused
|
||||
if: ${{ !cancelled() }}
|
||||
env:
|
||||
TRANSCRIPT: ${{ runner.temp }}/claude-execution-output.json
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[ -f "$TRANSCRIPT" ] || exit 0
|
||||
jq -e 'any(.[]; .type == "result" and ((.api_error_status // 0) == 401 or (.api_error_status // 0) == 403))' "$TRANSCRIPT" >/dev/null 2>&1 \
|
||||
|| jq -e 'any(.[]; ((.error // "") | test("^(oauth_|authentication_|invalid_api_key)")))' "$TRANSCRIPT" >/dev/null 2>&1 \
|
||||
|| exit 0
|
||||
echo "skipped=true" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::No review of #${PR}: the Claude credential was refused, so nothing in this pull request was examined."
|
||||
# updated_at, not created_at: a re-review may edit its earlier comment.
|
||||
# --paginate prints one jq count per page, so the pages are summed.
|
||||
- name: Fail if the review posted nothing
|
||||
if: ${{ !cancelled() && steps.pinned-sha.outcome == 'success' && steps.reviewed.outputs.done != 'true' && steps.throttled.outputs.skipped != 'true' }}
|
||||
if: ${{ !cancelled() && steps.pinned-sha.outcome == 'success' && steps.reviewed.outputs.done != 'true' && steps.throttled.outputs.skipped != 'true' && steps.refused.outputs.skipped != 'true' }}
|
||||
env:
|
||||
HEAD_SHA: ${{ steps.pinned-sha.outputs.sha }}
|
||||
STARTED_AT: ${{ steps.started.outputs.at }}
|
||||
|
||||
@@ -75,11 +75,18 @@ file locations when it can answer in one hop.
|
||||
share-link or install-command output changes.
|
||||
|
||||
## Hard rules (non-negotiable)
|
||||
- Fix size must match bug size. Find the root cause, then make the SMALLEST
|
||||
change that removes it — a one-line guard beats a new subsystem. A small bug
|
||||
does not earn new columns, jobs, abstractions, config knobs or helper layers.
|
||||
If a fix genuinely needs new architecture, say so and get agreement first;
|
||||
never ship it unasked next to the fix.
|
||||
- Correct fix over small fix. Find the root cause and fix it the right way, however
|
||||
much code that takes. Size the change by what the correct fix needs, never by
|
||||
line count: when the right fix spans many files, or needs a migration, a shared
|
||||
helper or a new abstraction, write it. A guard that hides the symptom while the
|
||||
cause survives is the wrong fix, however small. Two limits remain:
|
||||
- Everything added must be something the correct fix needs. No speculative
|
||||
knobs, unused extension points or "while I was here" rewrites. Unrelated
|
||||
refactors and cleanups go in their own commit.
|
||||
- Stop and ask only when the right fix needs a decision the code cannot answer:
|
||||
a deliberate user-visible behaviour change, or two sound designs with a real
|
||||
trade-off. Ask with a recommendation. Size alone is never a reason to stop,
|
||||
defer or ship a smaller patch.
|
||||
- Comments in committed Go/TS: 2 lines MAX per comment block. Make the name
|
||||
carry the meaning first and rename rather than annotate; spend the 2 lines on
|
||||
the *why* a name cannot hold — an invariant, an issue number, a non-obvious
|
||||
@@ -113,6 +120,36 @@ file locations when it can answer in one hop.
|
||||
explaining the why. Types in use: `fix`, `feat`, `chore`, `refactor`, `perf`,
|
||||
`docs`, `style`.
|
||||
|
||||
## Tests: TDD, and only tests that can fail (Go and frontend)
|
||||
- Work red → green → refactor.
|
||||
- Bug: turn the reproduction into a test first, and watch it fail for the
|
||||
reported reason.
|
||||
- Feature: write the test for the first behaviour before writing its code.
|
||||
- Then write the code that makes it pass, and refactor with the suite green.
|
||||
|
||||
If a test was written after the code, prove it anyway: revert the code, watch
|
||||
the test go red, then restore. A test that passes either way is worse than no
|
||||
test. It certifies nothing, and then gets cited as proof the fix works.
|
||||
- Every test must name the failure it catches. When no test can reach a change
|
||||
(workflow YAML, pure wiring, layout), say so and name the command that
|
||||
demonstrates it. Never write a stand-in test.
|
||||
- Fake tests are forbidden. Delete any you write or meet in the code you touch:
|
||||
- tests of a getter, a constant, a rename, a pure map lookup, or an input the
|
||||
function can never receive;
|
||||
- tests that restate the implementation, such as recomputing the expected
|
||||
value with the same formula or asserting that a mock was called exactly the
|
||||
way the code calls it;
|
||||
- mocking the unit under test, or mocking so much around it that the real
|
||||
code path never runs;
|
||||
- assertions too weak to fail: `err != nil`, `len > 0`, `toBeDefined()`, or
|
||||
`not.toThrow()` alone;
|
||||
- golden files or snapshots regenerated to match whatever the code now outputs;
|
||||
- extra cases that exercise no distinct branch, and tests written to raise
|
||||
coverage.
|
||||
|
||||
One real test that drives the bug through the actual code path beats five
|
||||
that restate the code.
|
||||
|
||||
## Go conventions
|
||||
- Stdlib `testing` only (no testify). Table-driven, `t.Run` subtests,
|
||||
`t.Helper()` on helpers. Assert the exact value / typed error / emitted
|
||||
@@ -120,12 +157,6 @@ file locations when it can answer in one hop.
|
||||
`database.InitDB(filepath.Join(t.TempDir(), "x-ui.db"))` +
|
||||
`t.Cleanup(func() { _ = database.CloseDB() })`; `httptest` for HTTP.
|
||||
`internal/sub`'s `initSubDB(t)` is the template.
|
||||
- A test must fail without its fix. Write it, revert the fix, watch it go red,
|
||||
restore. A test that passes either way is worse than no test: it certifies
|
||||
nothing and then gets cited as proof the fix works.
|
||||
- Test what can actually break. No test for a getter, a constant, a rename, a
|
||||
pure map lookup, or inputs the function can never receive. One real test that
|
||||
drives the bug through the actual code path beats five that restate the code.
|
||||
- Code must pass `golangci-lint run` (gofumpt + goimports formatting): `make lint`.
|
||||
- Postgres, xray-gRPC-e2e and scale tests `t.Skip` unless `XUI_TEST_PG_DSN`,
|
||||
`XUI_DB_TYPE`+`XUI_DB_DSN`, `XRAY_E2E_BINARY` or `XUI_SCALE_TEST` is set — a
|
||||
@@ -136,7 +167,7 @@ file locations when it can answer in one hop.
|
||||
- TS strict; `@typescript-eslint/no-explicit-any` is an error. Zod schemas in
|
||||
`src/schemas/` are the source of truth; infer types with `z.infer`, never
|
||||
hand-write. Do not edit `src/generated/`.
|
||||
- Node 24 (`.nvmrc`) — `make gen` imports `.ts` directly and needs its type
|
||||
- Node 26 (`.nvmrc`) — `make gen` imports `.ts` directly and needs its type
|
||||
stripping; Node 22 dies with `ERR_UNKNOWN_FILE_EXTENSION`. `npm test` includes
|
||||
a headless-Chromium Storybook project, so run
|
||||
`npx playwright install --with-deps chromium` once or `make verify` fails.
|
||||
|
||||
+7
-1
@@ -5,7 +5,7 @@ Thanks for taking the time to contribute to 3x-ui. This guide gets a development
|
||||
## Prerequisites
|
||||
|
||||
- **Go 1.27+** (the version pinned in `go.mod`)
|
||||
- **Node.js 24 LTS** (the version pinned in `.nvmrc`) and npm 10+ (for the React frontend)
|
||||
- **Node.js 26** (the version pinned in `.nvmrc`) and npm 11+ (for the React frontend)
|
||||
- **Git**
|
||||
- **A C compiler** — required by the CGo SQLite driver (`github.com/mattn/go-sqlite3`). Linux and macOS already ship one; for Windows see below.
|
||||
|
||||
@@ -243,6 +243,12 @@ For deeper notes on the frontend toolchain see [`frontend/README.md`](frontend/R
|
||||
|
||||
Tests live next to the code (`foo.go` ↔ `foo_test.go`); frontend specs and golden fixtures live in `frontend/src/test/`.
|
||||
|
||||
### Test first, and only tests that can fail
|
||||
|
||||
- **Red → green → refactor.** Write the test before the code. For a bug, the test reproduces the report; for a feature, it covers the first behaviour. Watch it fail, write the code that makes it pass, then refactor with the suite green.
|
||||
- **Every test catches a named failure.** Don't test getters, constants or renames. Don't restate the implementation, mock the unit under test, write assertions too weak to fail, or regenerate snapshots to match whatever the code now outputs.
|
||||
- **Fix the root cause the right way**, even when that takes more code. A small patch that hides the symptom is not a fix.
|
||||
|
||||
### Go conventions
|
||||
|
||||
- **Stdlib `testing` only** — no testify. Table-driven with `t.Run` subtests and `t.Helper()` on helpers.
|
||||
|
||||
@@ -3,6 +3,6 @@ import { llms } from 'fumadocs-core/source';
|
||||
|
||||
export const revalidate = false;
|
||||
|
||||
export function GET() {
|
||||
return new Response(llms(source).index());
|
||||
export async function GET() {
|
||||
return new Response(await llms(source).index());
|
||||
}
|
||||
|
||||
@@ -565,7 +565,7 @@ golangci-lint run # full lint (gofumpt + goimports formatting)
|
||||
go run main.go # run the panel locally (serves embedded dist if built)
|
||||
```
|
||||
|
||||
**Frontend (`cd frontend`, Node 24 — see `.nvmrc`):**
|
||||
**Frontend (`cd frontend`, Node 26 — see `.nvmrc`):**
|
||||
|
||||
```bash
|
||||
npm install
|
||||
|
||||
@@ -43,10 +43,10 @@ value defeats the point, since DPI can fingerprint it over time.
|
||||
| ------------ | ---------------------------------------------------------------------------- |
|
||||
| **Jc** | Number of junk packets sent before the handshake. |
|
||||
| **Jmin/Jmax** | Size range (bytes) for those junk packets. `Jmin` must not exceed `Jmax`. |
|
||||
| **S1/S2** | Padding added to the handshake init/response packets. `S1 + 56` must not equal `S2` — amneziawg-go rejects a value that would make both packets the same size. |
|
||||
| **S3** | Cookie-reply padding, `0`-`64`. |
|
||||
| **S1/S2** | Padding added to the handshake init/response packets, `0`-`1552` / `0`-`1608`: the packets are `148 + S1` and `92 + S2` bytes and must fit the 1700-byte receive buffer amneziawg-go uses on iOS. The panel also rejects `S1 + 56 = S2`, which would give both packets the same size on the wire (amneziawg-go itself accepts it). An AmneziaWG outbound takes the remote server's values as they are, up to `65535`. |
|
||||
| **S3** | Cookie-reply padding, `0`-`1636`: the reply is `64 + S3` bytes and must fit the 1700-byte receive buffer amneziawg-go uses on iOS. An outbound, as with S1/S2, takes the remote server's value up to `65535`. |
|
||||
| **S4** | Transport (data) packet padding, `0`-`32`. |
|
||||
| **H1-H4** | Magic header values that replace WireGuard's standard message-type bytes. Each is a single integer or a `low-high` range; `1`-`4` are reserved (real WireGuard message types) and must not be used. |
|
||||
| **H1-H4** | Header values that replace WireGuard's message-type field. Each is a single integer or a `low-high` range, and the four must not overlap — amneziawg-go and the kernel module refuse the whole device otherwise. `1`-`4` are WireGuard's own types and the engine default for a blank field: valid, but without a HeaderProtectionKey the type field then reads like plain WireGuard. |
|
||||
| **I1-I5** | Optional signature packets — random bytes prepended before the handshake, e.g. `<r 148>`. Generated sets fill `I1` only, matching Amnezia's own generator. |
|
||||
| **HeaderProtectionKey** | A base64 32-byte key for the 3.0 header-protection mechanism. Must match on every client config; blank disables it. |
|
||||
| **ContentPaddingAddition** | A single integer or `low-high` byte range of extra padding on content packets. Kept `<= 64` by the generator so a 1420-MTU tunnel doesn't fragment. |
|
||||
|
||||
@@ -20,15 +20,15 @@ inbounds** at once, with per-client traffic accounting.
|
||||
| **Limit IP** | all (except TUIC) | Max simultaneous source IPs (enforced via Fail2ban). |
|
||||
| **Total (GB)** | all (except TUIC) | Traffic quota; the client is disabled when exhausted (for TUIC, limits are set at the inbound level). |
|
||||
| **Expiry** | all | Date after which the client stops working. |
|
||||
| **Reset** | all | Auto-renew period in **days** (rolls the quota over). |
|
||||
| **Auto renewal** | all | Disabled, fixed interval in days, calendar weekly, or calendar monthly. |
|
||||
| **Telegram ID**| all | Links the client to a Telegram user for self-service/notifications.|
|
||||
| **Sub ID** | all | Subscription identifier grouping this client's links. |
|
||||
| **Group** | all | Optional client group for organization and bulk filtering. |
|
||||
| **Comment** | all | Free-text note. |
|
||||
|
||||
<Callout type="info">
|
||||
Reaching the **traffic** or **expiry** limit disables the client; the panel can
|
||||
restart Xray automatically when clients are auto-disabled
|
||||
Reaching the **traffic** or **expiry** limit disables the client, and a client
|
||||
disabled or deleted by hand counts too; the panel restarts Xray then
|
||||
(`restartXrayOnClientDisable`, on by default).
|
||||
</Callout>
|
||||
|
||||
@@ -42,6 +42,68 @@ inbounds** at once, with per-client traffic accounting.
|
||||
- **Online status** and **last-online** times are tracked per client (and per
|
||||
node in multi-node setups).
|
||||
|
||||
## Automatic renewal
|
||||
|
||||
The individual and bulk-create forms offer one renewal mode at a time:
|
||||
|
||||
| Mode | API fields | Schedule |
|
||||
| --- | --- | --- |
|
||||
| Disabled | `reset=0`, `resetDay=0`, `resetWeekday=0` | The expiry is not renewed. |
|
||||
| Fixed interval | `reset=N`, other two fields `0` | Add exactly N × 24 hours to the previous cutoff. |
|
||||
| Calendar weekly | `resetWeekday=1..7`, other two fields `0` | Renew at panel-local midnight on Monday (1) through Sunday (7). |
|
||||
| Calendar monthly | `resetDay=1..31`, `resetWeekday=0` | Renew at panel-local midnight on that day; missing dates clamp to the month's last day without losing the configured day. |
|
||||
|
||||
Calendar weeks stay on the selected weekday across daylight-saving changes;
|
||||
they are not equivalent to a fixed seven-day interval. A skipped midnight uses
|
||||
the first valid instant of that date; a repeated midnight uses the first one.
|
||||
If a timezone skips the entire selected date, the next matching week is used.
|
||||
Existing monthly clients
|
||||
that also have `reset` set retain monthly precedence. The API rejects weekly
|
||||
renewal combined with a positive `reset` or `resetDay`.
|
||||
|
||||
For a full calendar month, select **monthly, day 1** and set the initial cutoff
|
||||
to the next month's first midnight. For example, `2030-09-01 00:00:00` is valid
|
||||
through `2030-08-31 23:59:59`. Day 31 renews at the **start** of the 31st and is
|
||||
not the same schedule. The existing optional month-end subscription-header
|
||||
display remains a separate setting and is not enabled by this form.
|
||||
|
||||
The preview uses the panel's timezone and the same calendar/catch-up calculation
|
||||
as automatic renewal. It shows the cutoff, last valid second, next expiry, and
|
||||
allowances needed. It is informational: it does not save, activate, reserve, or
|
||||
guarantee a future renewal. When no expiry is set, auto-renewal cannot run; an
|
||||
explicit button can set the first calendar cutoff. Selecting a mode alone never
|
||||
rewrites an existing expiry. First-use clients keep their initial duration, and
|
||||
their calendar dates are available after activation.
|
||||
|
||||
For legacy last-second calendar cutoffs, the renewal boundary includes the
|
||||
existing free alignment to the following midnight. The last-valid-second
|
||||
preview still uses the **stored expiry**, not that alignment: an exclusive
|
||||
`23:59:59` cutoff is valid through `23:59:58`. Use a next-midnight cutoff for
|
||||
full-day validity; the preview itself does not repair the initial expiry.
|
||||
|
||||
`resetMax=0` means unlimited renewals. A positive limit counts **each elapsed
|
||||
period**, including offline catch-up, not each scheduler tick or attached inbound.
|
||||
If the remaining allowances cannot reach a future cutoff, the client stays
|
||||
expired and its traffic is not reset. Operator-disabled clients stay disabled.
|
||||
|
||||
Renewal already resets client traffic. The separate **periodic traffic reset**
|
||||
does not move the expiry and is unchanged; keep it disabled unless you intend an
|
||||
additional reset. Quarterly, yearly, and every-N-week/month schedules are not
|
||||
part of these modes.
|
||||
|
||||
<Callout type="warn">
|
||||
Upgrade the main panel and every participating node before enabling weekly
|
||||
renewal. Older versions ignore `resetWeekday`; a weekly-only client would not
|
||||
auto-renew and, after its expiry or quota is exhausted, can be deleted by
|
||||
**delete depleted clients** because older versions lack the weekly protection.
|
||||
Back up the database and convert weekly schedules to a renewal mode supported
|
||||
by every participating version before downgrading. Merely disabling weekly
|
||||
renewal does not protect a depleted client from deletion. Avoid depleted-client
|
||||
cleanup while a mixed-version fleet or unconverted weekly clients remain.
|
||||
Database upgrades default this new field to `0` and preserve existing limits
|
||||
and dates.
|
||||
</Callout>
|
||||
|
||||
## Share links and external links
|
||||
|
||||
Every client has share links and a QR code for its inbounds, plus a combined
|
||||
|
||||
@@ -94,6 +94,25 @@ Subscriptions return standard headers that compatible apps read:
|
||||
- **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**,
|
||||
**`Announce`** — optional branding shown by some clients.
|
||||
|
||||
### Profile page links and upgrades
|
||||
|
||||
In **Subscription → Profile → Profile page**, choose `subProfileMode` for all
|
||||
subscription clients:
|
||||
|
||||
- **No link** (`none`, default): omit `Profile-Web-Page-Url`.
|
||||
- **Built-in subscription page** (`builtin`): link to the client's built-in page.
|
||||
- **Custom website** (`custom`): use `subProfileUrl`; a blank URL omits the header.
|
||||
|
||||
**Upgrade note:** previously, an empty `subProfileUrl` automatically linked to
|
||||
the built-in page. After upgrading, an unset mode with an empty or whitespace-only
|
||||
URL becomes **No link**; an existing nonempty URL remains a **Custom website**.
|
||||
To restore the built-in link, select **Built-in subscription page** above and
|
||||
save the settings.
|
||||
|
||||
The built-in page exposes subscription URLs and node configurations, including
|
||||
for Happ encrypted subscriptions. Enable it only if you intend to provide that
|
||||
access.
|
||||
|
||||
### Optional month-end expiry display
|
||||
|
||||
Under **Subscription → Information**, **Month-end subscription expiry display**
|
||||
|
||||
@@ -22,6 +22,12 @@ _openapi:
|
||||
this — the middleware short-circuits CSRF for authenticated API
|
||||
requests.
|
||||
url: '#mint-a-csrf-token-for-the-current-session-the-spa-replays-it-in-the-x-csrf-token-header-on-unsafe-requests-bearer-token-callers-can-skip-this--the-middleware-short-circuits-csrf-for-authenticated-api-requests'
|
||||
- depth: 2
|
||||
title: Public. Active paid sponsor placements read from the project
|
||||
sponsors.json (cached for 1h); expired entries are dropped. Logos are
|
||||
proxied by the panel at /sponsors/logo/{name}. Used by the login page
|
||||
and panel sponsor slots.
|
||||
url: '#public-active-paid-sponsor-placements-read-from-the-project-sponsorsjson-cached-for-1h-expired-entries-are-dropped-logos-are-proxied-by-the-panel-at-sponsorslogoname-used-by-the-login-page-and-panel-sponsor-slots'
|
||||
- depth: 2
|
||||
title: Returns whether 2FA is enabled on the panel — used by the login page to
|
||||
decide whether to show the OTP field.
|
||||
@@ -39,6 +45,11 @@ _openapi:
|
||||
this — the middleware short-circuits CSRF for authenticated API
|
||||
requests.
|
||||
id: mint-a-csrf-token-for-the-current-session-the-spa-replays-it-in-the-x-csrf-token-header-on-unsafe-requests-bearer-token-callers-can-skip-this--the-middleware-short-circuits-csrf-for-authenticated-api-requests
|
||||
- content: Public. Active paid sponsor placements read from the project
|
||||
sponsors.json (cached for 1h); expired entries are dropped. Logos are
|
||||
proxied by the panel at /sponsors/logo/{name}. Used by the login page
|
||||
and panel sponsor slots.
|
||||
id: public-active-paid-sponsor-placements-read-from-the-project-sponsorsjson-cached-for-1h-expired-entries-are-dropped-logos-are-proxied-by-the-panel-at-sponsorslogoname-used-by-the-login-page-and-panel-sponsor-slots
|
||||
- content: Returns whether 2FA is enabled on the panel — used by the login page to
|
||||
decide whether to show the OTP field.
|
||||
id: returns-whether-2fa-is-enabled-on-the-panel--used-by-the-login-page-to-decide-whether-to-show-the-otp-field
|
||||
@@ -54,7 +65,7 @@ export default function Layout(props) {
|
||||
return (
|
||||
<>
|
||||
{props.children}
|
||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
|
||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/login","method":"post"},{"path":"/logout","method":"post"},{"path":"/csrf-token","method":"get"},{"path":"/sponsors","method":"get"},{"path":"/getTwoFactorEnable","method":"post"}]} showTitle />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -37,6 +37,9 @@ _openapi:
|
||||
call. Body is JSON. Per-protocol secrets are generated server-side when
|
||||
omitted, so callers can send only the universal fields.
|
||||
url: '#create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields'
|
||||
- depth: 2
|
||||
title: Preview client auto-renewal dates without saving or resetting anything.
|
||||
url: '#preview-client-auto-renewal-dates-without-saving-or-resetting-anything'
|
||||
- depth: 2
|
||||
title: Update an existing client by email. Changes propagate to every attached
|
||||
inbound. Body is the JSON client payload — supply the full set of fields
|
||||
@@ -317,6 +320,8 @@ _openapi:
|
||||
call. Body is JSON. Per-protocol secrets are generated server-side
|
||||
when omitted, so callers can send only the universal fields.
|
||||
id: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
|
||||
- content: Preview client auto-renewal dates without saving or resetting anything.
|
||||
id: preview-client-auto-renewal-dates-without-saving-or-resetting-anything
|
||||
- content: Update an existing client by email. Changes propagate to every attached
|
||||
inbound. Body is the JSON client payload — supply the full set of
|
||||
fields you want to keep (the server replaces the row, it does not
|
||||
@@ -592,6 +597,16 @@ _openapi:
|
||||
one per line. `limitHwid` is applied only when every inbound
|
||||
succeeded, so re-run the call after fixing the failure.
|
||||
heading: create-a-new-client-and-attach-it-to-one-or-more-inbounds-in-a-single-call-body-is-json-per-protocol-secrets-are-generated-server-side-when-omitted-so-callers-can-send-only-the-universal-fields
|
||||
- content: Uses the same calendar and catch-up calculation as auto-renew in the
|
||||
panel timezone. resetWeekday is 1 (Monday) to 7 (Sunday), 0 disables
|
||||
weekly mode; it cannot be combined with positive reset or resetDay.
|
||||
Existing resetDay takes precedence over reset. With expiryTime=0,
|
||||
calendar modes suggest a first cutoff but do not activate renewal.
|
||||
Negative expiryTime waits for first-use activation. resetMax and
|
||||
resetCount simulate the existing per-period allowance limit; the
|
||||
preview is informational and does not reserve an allowance or
|
||||
guarantee node availability.
|
||||
heading: preview-client-auto-renewal-dates-without-saving-or-resetting-anything
|
||||
- content: 'The inbounds are applied concurrently and independently: one that
|
||||
fails no longer stops the others. Every inbound error names the
|
||||
inbound it came from (`inbound 7: <message>`), and several failures
|
||||
@@ -638,7 +653,7 @@ export default function Layout(props) {
|
||||
return (
|
||||
<>
|
||||
{props.children}
|
||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/clients/list","method":"get"},{"path":"/panel/api/clients/list/paged","method":"get"},{"path":"/panel/api/clients/get/{email}","method":"get"},{"path":"/panel/api/clients/get/tgId/{tgId}","method":"get"},{"path":"/panel/api/clients/add","method":"post"},{"path":"/panel/api/clients/update/{email}","method":"post"},{"path":"/panel/api/clients/del/{email}","method":"post"},{"path":"/panel/api/clients/{email}/attach","method":"post"},{"path":"/panel/api/clients/{email}/detach","method":"post"},{"path":"/panel/api/clients/{email}/externalLinks","method":"post"},{"path":"/panel/api/clients/resetAllTraffics","method":"post"},{"path":"/panel/api/clients/delDepleted","method":"post"},{"path":"/panel/api/clients/delOrphans","method":"post"},{"path":"/panel/api/clients/export","method":"get"},{"path":"/panel/api/clients/import","method":"post"},{"path":"/panel/api/clients/bulkAdjust","method":"post"},{"path":"/panel/api/clients/bulkEnable","method":"post"},{"path":"/panel/api/clients/bulkDisable","method":"post"},{"path":"/panel/api/clients/bulkDel","method":"post"},{"path":"/panel/api/clients/bulkCreate","method":"post"},{"path":"/panel/api/clients/groups/bulkAdd","method":"post"},{"path":"/panel/api/clients/groups/bulkRemove","method":"post"},{"path":"/panel/api/clients/bulkAttach","method":"post"},{"path":"/panel/api/clients/bulkDetach","method":"post"},{"path":"/panel/api/clients/bulkResetTraffic","method":"post"},{"path":"/panel/api/clients/groups","method":"get"},{"path":"/panel/api/clients/groups/{name}/emails","method":"get"},{"path":"/panel/api/clients/groups/create","method":"post"},{"path":"/panel/api/clients/groups/rename","method":"post"},{"path":"/panel/api/clients/groups/delete","method":"post"},{"path":"/panel/api/clients/groups/resetTraffic","method":"post"},{"path":"/panel/api/clients/resetTraffic/{email}","method":"post"},{"path":"/panel/api/clients/updateTraffic/{email}","method":"post"},{"path":"/panel/api/clients/ips/{email}","method":"post"},{"path":"/panel/api/clients/clearIps/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"delete"},{"path":"/panel/api/clients/hwids/{email}/{id}","method":"delete"},{"path":"/panel/api/clients/onlines","method":"post"},{"path":"/panel/api/clients/onlinesByGuid","method":"post"},{"path":"/panel/api/clients/clientIpsByGuid","method":"post"},{"path":"/panel/api/clients/activeInbounds","method":"post"},{"path":"/panel/api/clients/lastOnline","method":"post"},{"path":"/panel/api/clients/traffic/{email}","method":"get"},{"path":"/panel/api/clients/subLinks/{subId}","method":"get"},{"path":"/panel/api/clients/happLink/{id}","method":"post"},{"path":"/panel/api/clients/links/{email}","method":"get"}]} showTitle />
|
||||
<Comp document="./public/openapi.json" webhooks={[]} operations={[{"path":"/panel/api/clients/list","method":"get"},{"path":"/panel/api/clients/list/paged","method":"get"},{"path":"/panel/api/clients/get/{email}","method":"get"},{"path":"/panel/api/clients/get/tgId/{tgId}","method":"get"},{"path":"/panel/api/clients/add","method":"post"},{"path":"/panel/api/clients/renewalPreview","method":"post"},{"path":"/panel/api/clients/update/{email}","method":"post"},{"path":"/panel/api/clients/del/{email}","method":"post"},{"path":"/panel/api/clients/{email}/attach","method":"post"},{"path":"/panel/api/clients/{email}/detach","method":"post"},{"path":"/panel/api/clients/{email}/externalLinks","method":"post"},{"path":"/panel/api/clients/resetAllTraffics","method":"post"},{"path":"/panel/api/clients/delDepleted","method":"post"},{"path":"/panel/api/clients/delOrphans","method":"post"},{"path":"/panel/api/clients/export","method":"get"},{"path":"/panel/api/clients/import","method":"post"},{"path":"/panel/api/clients/bulkAdjust","method":"post"},{"path":"/panel/api/clients/bulkEnable","method":"post"},{"path":"/panel/api/clients/bulkDisable","method":"post"},{"path":"/panel/api/clients/bulkDel","method":"post"},{"path":"/panel/api/clients/bulkCreate","method":"post"},{"path":"/panel/api/clients/groups/bulkAdd","method":"post"},{"path":"/panel/api/clients/groups/bulkRemove","method":"post"},{"path":"/panel/api/clients/bulkAttach","method":"post"},{"path":"/panel/api/clients/bulkDetach","method":"post"},{"path":"/panel/api/clients/bulkResetTraffic","method":"post"},{"path":"/panel/api/clients/groups","method":"get"},{"path":"/panel/api/clients/groups/{name}/emails","method":"get"},{"path":"/panel/api/clients/groups/create","method":"post"},{"path":"/panel/api/clients/groups/rename","method":"post"},{"path":"/panel/api/clients/groups/delete","method":"post"},{"path":"/panel/api/clients/groups/resetTraffic","method":"post"},{"path":"/panel/api/clients/resetTraffic/{email}","method":"post"},{"path":"/panel/api/clients/updateTraffic/{email}","method":"post"},{"path":"/panel/api/clients/ips/{email}","method":"post"},{"path":"/panel/api/clients/clearIps/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"post"},{"path":"/panel/api/clients/hwids/{email}","method":"delete"},{"path":"/panel/api/clients/hwids/{email}/{id}","method":"delete"},{"path":"/panel/api/clients/onlines","method":"post"},{"path":"/panel/api/clients/onlinesByGuid","method":"post"},{"path":"/panel/api/clients/clientIpsByGuid","method":"post"},{"path":"/panel/api/clients/activeInbounds","method":"post"},{"path":"/panel/api/clients/lastOnline","method":"post"},{"path":"/panel/api/clients/traffic/{email}","method":"get"},{"path":"/panel/api/clients/subLinks/{subId}","method":"get"},{"path":"/panel/api/clients/happLink/{id}","method":"post"},{"path":"/panel/api/clients/links/{email}","method":"get"}]} showTitle />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "Reference",
|
||||
"icon": "BookMarked",
|
||||
"icon": "BookBookmark",
|
||||
"pages": ["env-vars", "database", "ports-firewall", "api"]
|
||||
}
|
||||
|
||||
@@ -27,8 +27,8 @@ icon: Users
|
||||
| **Comment** | همه | یادداشت متنی آزاد. |
|
||||
|
||||
<Callout type="info">
|
||||
رسیدن به محدودیت **ترافیک** یا **انقضا** کلاینت را غیرفعال میکند؛ پنل میتواند
|
||||
هنگام غیرفعالشدن خودکار کلاینتها، Xray را بهصورت خودکار راهاندازی مجدد کند
|
||||
رسیدن به محدودیت **ترافیک** یا **انقضا** کلاینت را غیرفعال میکند؛ غیرفعالسازی یا
|
||||
حذف دستی کلاینت هم همین اثر را دارد؛ در این حالت پنل Xray را راهاندازی مجدد میکند
|
||||
(`restartXrayOnClientDisable`، بهصورت پیشفرض فعال).
|
||||
</Callout>
|
||||
|
||||
|
||||
@@ -72,6 +72,23 @@ SOCKS/HTTP روی 127.0.0.1، DNS، مسیریابی، policy) بهعلاوه
|
||||
- **`Profile-Title`**، **`Support-Url`**، **`Profile-Web-Page-Url`**،
|
||||
**`Announce`** — برندینگ اختیاری که برخی کلاینتها نمایش میدهند.
|
||||
|
||||
### لینک صفحه پروفایل
|
||||
|
||||
در تنظیمات **سابسکریپشن ← پروفایل**، گزینه **صفحه پروفایل** (`subProfileMode`)
|
||||
لینک را برای همه کلاینتهای اشتراک کنترل میکند:
|
||||
|
||||
- **بدون لینک** (`none`، پیشفرض) — هدر `Profile-Web-Page-Url` ارسال نمیشود.
|
||||
- **صفحه اشتراک داخلی** (`builtin`) — لینک صفحه اشتراک داخلی ارائه میشود.
|
||||
- **وبسایت سفارشی** (`custom`) — آدرس `subProfileUrl` استفاده میشود؛ اگر خالی باشد، هدر ارسال نمیشود.
|
||||
|
||||
**پس از ارتقا:** اگر `subProfileMode` هنوز تنظیم نشده و مقدار قبلی `subProfileUrl`
|
||||
خالی یا فقط شامل فاصله باشد، بهجای لینک خودکار صفحه داخلی، حالت **بدون لینک**
|
||||
انتخاب میشود. آدرس سفارشی غیرخالی قبلی در حالت **وبسایت سفارشی** حفظ میشود.
|
||||
|
||||
برای بازگرداندن لینک قبلی، در همین بخش **صفحه اشتراک داخلی** را انتخاب و تنظیمات
|
||||
را ذخیره کنید. این صفحه آدرسهای اشتراک و پیکربندی گرهها را آشکار میکند، حتی
|
||||
برای اشتراکهای رمزگذاریشده Happ.
|
||||
|
||||
## قالبهای سفارشی صفحه
|
||||
|
||||
برای برندینگ صفحهی HTML اشتراک، `subThemeDir` را به یک پوشهی حاوی قالب سفارشیِ
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "مرجع",
|
||||
"icon": "BookMarked",
|
||||
"icon": "BookBookmark",
|
||||
"pages": ["env-vars", "database", "ports-firewall", "api"]
|
||||
}
|
||||
|
||||
@@ -28,9 +28,9 @@ icon: Users
|
||||
| **Comment** | все | Произвольная текстовая заметка. |
|
||||
|
||||
<Callout type="info">
|
||||
Достижение лимита **трафика** или **срока действия** отключает клиента; при
|
||||
автоматическом отключении клиентов панель может автоматически перезапускать
|
||||
Xray (`restartXrayOnClientDisable`, включено по умолчанию).
|
||||
Достижение лимита **трафика** или **срока действия** отключает клиента, как и
|
||||
ручное отключение или удаление; тогда панель перезапускает Xray
|
||||
(`restartXrayOnClientDisable`, включено по умолчанию).
|
||||
</Callout>
|
||||
|
||||
## Лимиты и контроль IP
|
||||
|
||||
@@ -76,6 +76,24 @@ policy) плюс исходящее соединение `proxy`, указыва
|
||||
- **`Profile-Title`**, **`Support-Url`**, **`Profile-Web-Page-Url`**,
|
||||
**`Announce`** — необязательный брендинг, отображаемый некоторыми клиентами.
|
||||
|
||||
### Ссылка на страницу профиля
|
||||
|
||||
В настройках **Подписка → Профиль** поле **Страница профиля** (`subProfileMode`)
|
||||
управляет ссылкой для всех клиентов подписки:
|
||||
|
||||
- **Без ссылки** (`none`, по умолчанию) — заголовок `Profile-Web-Page-Url` не отправляется.
|
||||
- **Встроенная страница подписки** (`builtin`) — ссылка на встроенную страницу подписки.
|
||||
- **Свой сайт** (`custom`) — адрес из `subProfileUrl`; если он пуст, заголовок не отправляется.
|
||||
|
||||
**После обновления:** если `subProfileMode` ещё не задан, а прежний `subProfileUrl`
|
||||
пуст или содержит только пробелы, вместо автоматической ссылки на встроенную
|
||||
страницу теперь используется **Без ссылки**. Существующий непустой пользовательский
|
||||
адрес сохраняется в режиме **Свой сайт**.
|
||||
|
||||
Чтобы вернуть прежнюю ссылку, выберите **Встроенная страница подписки** в этом поле
|
||||
и сохраните настройки. Эта страница раскрывает URL-адреса подписок и конфигурации
|
||||
узлов, в том числе для зашифрованных подписок Happ.
|
||||
|
||||
## Пользовательские шаблоны страниц
|
||||
|
||||
Укажите в `subThemeDir` папку с пользовательским шаблоном информационной
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "Справочник",
|
||||
"icon": "BookMarked",
|
||||
"icon": "BookBookmark",
|
||||
"pages": ["env-vars", "database", "ports-firewall", "api"]
|
||||
}
|
||||
|
||||
@@ -19,15 +19,15 @@ icon: Users
|
||||
| **Limit IP** | 全部(TUIC 除外) | 最大同时连接的源 IP 数量(通过 Fail2ban 强制执行)。 |
|
||||
| **Total (GB)** | 全部(TUIC 除外) | 流量配额;用尽后客户端将被禁用(对于 TUIC,限制在入站级别设置)。 |
|
||||
| **Expiry** | 全部 | 该日期之后客户端停止工作。 |
|
||||
| **Reset** | 全部 | 以**天**为单位的自动续期周期(滚动重置配额)。 |
|
||||
| **自动续期** | 全部 | 关闭、固定天数、日历每周或日历每月。 |
|
||||
| **Telegram ID**| 全部 | 将客户端关联到 Telegram 用户,用于自助服务/通知。 |
|
||||
| **Sub ID** | 全部 | 用于对该客户端链接分组的订阅标识符。 |
|
||||
| **Group** | 全部 | 可选的客户端分组,便于组织管理和批量筛选。 |
|
||||
| **Comment** | 全部 | 自由文本备注。 |
|
||||
|
||||
<Callout type="info">
|
||||
达到**流量**或**到期**限制会禁用客户端;当客户端被自动禁用时,面板可以
|
||||
自动重启 Xray(`restartXrayOnClientDisable`,默认开启)。
|
||||
达到**流量**或**到期**限制会禁用客户端,手动禁用或删除客户端同样如此;
|
||||
此时面板会重启 Xray(`restartXrayOnClientDisable`,默认开启)。
|
||||
</Callout>
|
||||
|
||||
## 限制与 IP 控制
|
||||
@@ -39,6 +39,58 @@ icon: Users
|
||||
并从该客户端的操作中清除它们。
|
||||
- 系统会按客户端(在多节点部署中还会按节点)跟踪**在线状态**和**最后在线**时间。
|
||||
|
||||
## 自动续期
|
||||
|
||||
单个客户端和批量创建表单使用统一的续期模式选择:
|
||||
|
||||
| 模式 | API 字段 | 续期规则 |
|
||||
| --- | --- | --- |
|
||||
| 关闭 | `reset=0`、`resetDay=0`、`resetWeekday=0` | 不自动延长到期时间。 |
|
||||
| 固定天数 | `reset=N`,另两个字段为 `0` | 从上次截止时间增加 N × 24 小时。 |
|
||||
| 日历每周 | `resetWeekday=1..7`,另两个字段为 `0` | 在面板时区每周一(1)至周日(7)的零点续期。 |
|
||||
| 日历每月 | `resetDay=1..31`、`resetWeekday=0` | 在面板时区指定日的零点续期;短月取月末,之后仍按原配置日续期。 |
|
||||
|
||||
日历每周跨夏令时仍保持指定星期,不等于固定 7 天。若零点不存在,使用
|
||||
该日期第一个有效时刻;零点重复时取第一次。若时区跳过整天,则使用
|
||||
下一周的同一星期。旧配置同时填写
|
||||
`reset` 和 `resetDay` 时继续以每月续期为准。API 不允许每周续期与正数
|
||||
`reset` 或 `resetDay` 同时启用。
|
||||
|
||||
整自然月应选**每月、1 日**,首次截止时间设置为下月 1 日零点。例如
|
||||
`2030-09-01 00:00:00` 表示有效至 `2030-08-31 23:59:59`。
|
||||
31 日表示在 31 日**开始时**续期,并不是同一边界。订阅头原有的可选
|
||||
月末显示设置仍独立存在,本表单不会自动开启它。
|
||||
|
||||
日期预览使用面板时区和后端实际续期的同一套计算,显示截止时间、最后
|
||||
有效秒、下次到期时间及需要消耗的续期次数。预览不会保存、激活或预留
|
||||
续期,也不保证未来一定续期。未设到期时间时自动续期无法运行,可以
|
||||
明确点击按钮设置首次日历截止时间;仅选择模式不会修改已有到期时间。
|
||||
“首次使用后开始”保留原来的初始天数,激活后才能确定日历日期。
|
||||
|
||||
旧配置以最后一秒为日历截止时间时,续期边界包含原有的不计次数向下个
|
||||
零点对齐规则。但最后有效秒仍按**已存储的到期时间**计算,不会假装
|
||||
初始时间已被修改:排他截止时间 `23:59:59` 实际有效至 `23:59:58`。
|
||||
整天有效应使用下一个零点,预览本身不会修复首次截止时间。
|
||||
|
||||
最大续期次数 `resetMax=0` 表示不限次数。正数上限按**每个经过的周期**
|
||||
计数,包括离线补续,不按定时任务执行次数或关联入站数量计数。剩余
|
||||
次数不足以续到未来时,客户端继续过期且不会重置流量;手动禁用的
|
||||
客户端保持禁用。
|
||||
|
||||
自动续期本身会重置客户端流量。独立的**定期流量重置**不延长到期时间,
|
||||
这次未改变其规则;除非需要额外重置,否则保持关闭。本次不包含季度、
|
||||
年度和每 N 周/月的续期。
|
||||
|
||||
<Callout type="warn">
|
||||
启用每周续期前,需要升级主面板及所有参与节点。旧版本会忽略
|
||||
`resetWeekday`,仅配置每周的客户端将无法自动续期,且在到期或流量
|
||||
耗尽后,可能被**删除已耗尽客户端**操作删除,因为旧版没有每周续期
|
||||
的清理保护。降级前应备份数据库,并将每周配置转换为所有参与版本
|
||||
都支持的续期模式;仅关闭每周续期并不能防止耗尽后的删除。存在
|
||||
混合版本或尚未转换的每周客户端时,应避免执行耗尽客户端清理。
|
||||
数据库升级默认将新字段设为 `0`,保留已有日期和限制。
|
||||
</Callout>
|
||||
|
||||
## 分享链接与外部链接
|
||||
|
||||
每个客户端都有针对其各入站的分享链接和二维码,外加一个合并的
|
||||
|
||||
@@ -73,6 +73,18 @@ Clash 格式自动识别保留原有的 `(?i)(clash|mihomo)` 默认匹配器,
|
||||
- **`Profile-Update-Interval`** —— 刷新间隔,以小时为单位(`subUpdates`)。
|
||||
- **`Profile-Title`**、**`Support-Url`**、**`Profile-Web-Page-Url`**、**`Announce`** —— 部分客户端会显示的可选品牌信息。
|
||||
|
||||
### 资料页链接与升级说明
|
||||
|
||||
在 **订阅 → 资料 → 资料页方式** 中选择 `subProfileMode`,对所有订阅客户端生效:
|
||||
|
||||
- **不提供**(`none`,默认):不发送 `Profile-Web-Page-Url`。
|
||||
- **内置订阅页**(`builtin`):提供该客户端的内置订阅页链接。
|
||||
- **自定义网站**(`custom`):使用 `subProfileUrl`;地址留空时不发送该响应头。
|
||||
|
||||
**升级提示:** 旧版在 `subProfileUrl` 留空时会自动提供内置订阅页链接。升级后,尚未设置模式且地址为空或仅含空白字符的配置会使用 **不提供**;已有非空地址继续使用 **自定义网站**。需要恢复内置入口时,在上述位置选择 **内置订阅页** 并保存设置。
|
||||
|
||||
内置订阅页会公开订阅地址和节点配置,Happ 加密订阅也不例外;请在确定需要提供这些内容时开启。
|
||||
|
||||
## 自定义页面模板
|
||||
|
||||
将 `subThemeDir` 指向一个包含自定义信息页模板的文件夹,即可为 HTML 订阅页面定制品牌。每条链接上的客户端备注完全支持模板化 —— 参见[分享链接 → 备注变量](/docs/config/share-links#remark-template-variables)。
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"title": "参考",
|
||||
"icon": "BookMarked",
|
||||
"icon": "BookBookmark",
|
||||
"pages": ["env-vars", "database", "ports-firewall", "api"]
|
||||
}
|
||||
|
||||
+20
-20
@@ -18,34 +18,34 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"fumadocs-core": "^16.15.5",
|
||||
"fumadocs-docgen": "^3.1.0",
|
||||
"fumadocs-mdx": "^15.4.0",
|
||||
"fumadocs-openapi": "^11.4.0",
|
||||
"fumadocs-ui": "^16.15.5",
|
||||
"lucide-react": "^1.39.0",
|
||||
"mermaid": "^11.17.2",
|
||||
"next": "16.3.4",
|
||||
"fumadocs-core": "^16.15.14",
|
||||
"fumadocs-docgen": "^3.1.1",
|
||||
"fumadocs-mdx": "^15.4.5",
|
||||
"fumadocs-openapi": "^12.0.3",
|
||||
"fumadocs-ui": "^16.15.14",
|
||||
"lucide-react": "^1.48.0",
|
||||
"mermaid": "^12.0.0",
|
||||
"next": "16.3.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-qr-code": "^2.2.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tailwind-merge": "^3.7.0",
|
||||
"zbsearch": "4.0.0",
|
||||
"zod": "^4.5.4"
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.3",
|
||||
"@types/mdx": "^2.0.14",
|
||||
"@types/node": "^26.4.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.5",
|
||||
"oxfmt": "0.66.0",
|
||||
"oxlint": "1.81.0",
|
||||
"postcss": "^8.5.26",
|
||||
"@types/node": "^26.6.2",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"oxfmt": "0.70.0",
|
||||
"oxlint": "1.85.0",
|
||||
"postcss": "^8.5.28",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "7.0.2",
|
||||
"vitest": "^4.1.11"
|
||||
"vitest": "^5.0.2"
|
||||
},
|
||||
"packageManager": "pnpm@11.25.0"
|
||||
"packageManager": "pnpm@12.6.0"
|
||||
}
|
||||
|
||||
Generated
+1345
-1147
File diff suppressed because it is too large
Load Diff
@@ -12,9 +12,16 @@ minimumReleaseAgeExclude:
|
||||
- mermaid@11.17.0
|
||||
- lucide-react@1.33.0
|
||||
- postcss@8.5.26
|
||||
- fumadocs-mdx@15.3.0
|
||||
- '@fumadocs/api-docs@0.2.7'
|
||||
- fumadocs-mdx@15.3.0 || 15.4.1 || 15.4.5
|
||||
- '@fumadocs/api-docs@0.2.7 || 0.2.9'
|
||||
- '@types/node@26.4.1'
|
||||
- fumadocs-core@16.15.5
|
||||
- fumadocs-openapi@11.4.0
|
||||
- fumadocs-ui@16.15.5
|
||||
- fumadocs-core@16.15.5 || 16.15.11
|
||||
- fumadocs-openapi@11.4.0 || 11.4.3 || 12.0.3
|
||||
- fumadocs-ui@16.15.5 || 16.15.11
|
||||
- '@fumadocs/tailwind@0.1.2'
|
||||
- '@fumari/image-size@0.1.1'
|
||||
- '@fumari/stf@1.1.1'
|
||||
- '@vitest/mocker@5.0.1 || 5.0.2'
|
||||
- '@vitest/spy@5.0.1 || 5.0.2'
|
||||
- fumadocs-docgen@3.1.1
|
||||
- vitest@5.0.1 || 5.0.2
|
||||
|
||||
+379
-2
@@ -69,6 +69,9 @@
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"externalSubUserAgent": {
|
||||
"type": "string"
|
||||
},
|
||||
"externalTrafficInformEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -288,6 +291,9 @@
|
||||
"subHappFallbackUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappLocalProxyAuth": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappNewUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -395,6 +401,9 @@
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"subProfileMode": {
|
||||
"type": "string"
|
||||
},
|
||||
"subProfileUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -516,6 +525,7 @@
|
||||
"discordMemory",
|
||||
"discordRunTime",
|
||||
"expireDiff",
|
||||
"externalSubUserAgent",
|
||||
"externalTrafficInformEnable",
|
||||
"externalTrafficInformURI",
|
||||
"happLinkEnable",
|
||||
@@ -583,6 +593,7 @@
|
||||
"subHappExcludeApns",
|
||||
"subHappExcludeRoutes",
|
||||
"subHappFallbackUrl",
|
||||
"subHappLocalProxyAuth",
|
||||
"subHappNewUrl",
|
||||
"subHappNoLimit",
|
||||
"subHappNotificationExpire",
|
||||
@@ -618,6 +629,7 @@
|
||||
"subListen",
|
||||
"subPath",
|
||||
"subPort",
|
||||
"subProfileMode",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
@@ -696,6 +708,9 @@
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"externalSubUserAgent": {
|
||||
"type": "string"
|
||||
},
|
||||
"externalTrafficInformEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -939,6 +954,9 @@
|
||||
"subHappFallbackUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappLocalProxyAuth": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappNewUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1046,6 +1064,9 @@
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"subProfileMode": {
|
||||
"type": "string"
|
||||
},
|
||||
"subProfileUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1167,6 +1188,7 @@
|
||||
"discordMemory",
|
||||
"discordRunTime",
|
||||
"expireDiff",
|
||||
"externalSubUserAgent",
|
||||
"externalTrafficInformEnable",
|
||||
"externalTrafficInformURI",
|
||||
"happLinkEnable",
|
||||
@@ -1242,6 +1264,7 @@
|
||||
"subHappExcludeApns",
|
||||
"subHappExcludeRoutes",
|
||||
"subHappFallbackUrl",
|
||||
"subHappLocalProxyAuth",
|
||||
"subHappNewUrl",
|
||||
"subHappNoLimit",
|
||||
"subHappNotificationExpire",
|
||||
@@ -1277,6 +1300,7 @@
|
||||
"subListen",
|
||||
"subPath",
|
||||
"subPort",
|
||||
"subProfileMode",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
@@ -1515,13 +1539,17 @@
|
||||
"type": "integer"
|
||||
},
|
||||
"resetDay": {
|
||||
"description": "Calendar renewal day 1-31, 0 = interval mode",
|
||||
"description": "Calendar renewal day 1-31, 0 disables monthly renewal",
|
||||
"type": "integer"
|
||||
},
|
||||
"resetMax": {
|
||||
"description": "Max auto-renew count, 0 = unlimited",
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"description": "Calendar weekday 1-7 (Mon-Sun), 0 disables weekly renewal",
|
||||
"type": "integer"
|
||||
},
|
||||
"reverse": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -1584,6 +1612,7 @@
|
||||
"reset",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday",
|
||||
"security",
|
||||
"subId",
|
||||
"tgId",
|
||||
@@ -1735,6 +1764,9 @@
|
||||
"resetMax": {
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"type": "integer"
|
||||
},
|
||||
"reverse": {},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
@@ -1790,6 +1822,7 @@
|
||||
"reset",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday",
|
||||
"reverse",
|
||||
"secret",
|
||||
"security",
|
||||
@@ -1803,6 +1836,97 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ClientRenewalPreview": {
|
||||
"properties": {
|
||||
"canRenew": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"delayedStart": {
|
||||
"example": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"nextExpiry": {
|
||||
"example": "2030-02-01T00:00:00Z",
|
||||
"type": "string"
|
||||
},
|
||||
"renewAt": {
|
||||
"example": "2030-01-01T00:00:00Z",
|
||||
"type": "string"
|
||||
},
|
||||
"renewals": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"suggestedExpiry": {
|
||||
"example": "2030-01-01T00:00:00Z",
|
||||
"type": "string"
|
||||
},
|
||||
"suggestedExpiryTime": {
|
||||
"example": 1893456000000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"timeZone": {
|
||||
"example": "UTC",
|
||||
"type": "string"
|
||||
},
|
||||
"validThrough": {
|
||||
"example": "2029-12-31T23:59:59Z",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"canRenew",
|
||||
"delayedStart",
|
||||
"nextExpiry",
|
||||
"renewAt",
|
||||
"renewals",
|
||||
"suggestedExpiry",
|
||||
"suggestedExpiryTime",
|
||||
"timeZone",
|
||||
"validThrough"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ClientRenewalPreviewRequest": {
|
||||
"properties": {
|
||||
"expiryTime": {
|
||||
"example": 1893456000000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"reset": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetCount": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetDay": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetMax": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"expiryTime",
|
||||
"reset",
|
||||
"resetCount",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ClientReverse": {
|
||||
"properties": {
|
||||
"tag": {
|
||||
@@ -1873,6 +1997,10 @@
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"subId": {
|
||||
"example": "abcd1234",
|
||||
"type": "string"
|
||||
@@ -1907,6 +2035,7 @@
|
||||
"reset",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday",
|
||||
"subId",
|
||||
"totalGB",
|
||||
"updatedAt"
|
||||
@@ -1962,7 +2091,7 @@
|
||||
"type": "integer"
|
||||
},
|
||||
"resetDay": {
|
||||
"description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 keeps the interval behaviour.",
|
||||
"description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 disables monthly renewal.",
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -1971,6 +2100,11 @@
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"description": "ResetWeekday renews weekly at panel-local midnight: 1 Monday through 7 Sunday.",
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"subId": {
|
||||
"example": "i7tvdpeffi0hvvf1",
|
||||
"type": "string"
|
||||
@@ -2003,6 +2137,7 @@
|
||||
"resetCount",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday",
|
||||
"subId",
|
||||
"total",
|
||||
"up",
|
||||
@@ -2293,6 +2428,9 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cipherSuites": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
@@ -2426,6 +2564,7 @@
|
||||
"address",
|
||||
"allowInsecure",
|
||||
"alpn",
|
||||
"cipherSuites",
|
||||
"createdAt",
|
||||
"echConfigList",
|
||||
"excludeFromSubTypes",
|
||||
@@ -2470,6 +2609,9 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cipherSuites": {
|
||||
"type": "string"
|
||||
},
|
||||
"echConfigList": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2596,6 +2738,7 @@
|
||||
"required": [
|
||||
"allowInsecure",
|
||||
"alpn",
|
||||
"cipherSuites",
|
||||
"echConfigList",
|
||||
"excludeFromSubTypes",
|
||||
"finalMask",
|
||||
@@ -4119,6 +4262,84 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Sponsor": {
|
||||
"description": "Sponsor is one paid placement published in the repo's sponsors.json.",
|
||||
"properties": {
|
||||
"enable": {
|
||||
"example": true,
|
||||
"nullable": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"example": "acme-2026-10",
|
||||
"type": "string"
|
||||
},
|
||||
"link": {
|
||||
"example": "https://acme.example/?utm_source=3x-ui",
|
||||
"type": "string"
|
||||
},
|
||||
"logo": {
|
||||
"example": "/sponsors/logo/acme.png",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"example": "Acme VPS",
|
||||
"type": "string"
|
||||
},
|
||||
"slots": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"text": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"title": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"until": {
|
||||
"example": "2026-11-01T00:00:00Z",
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"link",
|
||||
"name",
|
||||
"slots",
|
||||
"text",
|
||||
"title",
|
||||
"until"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SponsorList": {
|
||||
"description": "SponsorList is the active sponsor set plus the contact link for new sponsors.",
|
||||
"properties": {
|
||||
"contact": {
|
||||
"example": "https://t.me/example",
|
||||
"type": "string"
|
||||
},
|
||||
"sponsors": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Sponsor"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sponsors"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubBalancer": {
|
||||
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
|
||||
"properties": {
|
||||
@@ -4571,6 +4792,59 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sponsors": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Authentication"
|
||||
],
|
||||
"summary": "Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); expired entries are dropped. Logos are proxied by the panel at /sponsors/logo/{name}. Used by the login page and panel sponsor slots.",
|
||||
"operationId": "get_sponsors",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {
|
||||
"$ref": "#/components/schemas/SponsorList"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"obj": {
|
||||
"contact": "https://t.me/example",
|
||||
"sponsors": [
|
||||
{
|
||||
"enable": true,
|
||||
"id": "acme-2026-10",
|
||||
"link": "https://acme.example/?utm_source=3x-ui",
|
||||
"logo": "/sponsors/logo/acme.png",
|
||||
"name": "Acme VPS",
|
||||
"slots": [
|
||||
""
|
||||
],
|
||||
"text": {},
|
||||
"title": {},
|
||||
"until": "2026-11-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/getTwoFactorEnable": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -4652,6 +4926,7 @@
|
||||
"resetCount": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "i7tvdpeffi0hvvf1",
|
||||
"total": 10737418240,
|
||||
"up": 1048576,
|
||||
@@ -7886,6 +8161,7 @@
|
||||
"reset": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "abcd1234",
|
||||
"totalGB": 53687091200,
|
||||
"traffic": null,
|
||||
@@ -8078,6 +8354,97 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/renewalPreview": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Preview client auto-renewal dates without saving or resetting anything.",
|
||||
"operationId": "post_panel_api_clients_renewalPreview",
|
||||
"description": "Uses the same calendar and catch-up calculation as auto-renew in the panel timezone. resetWeekday is 1 (Monday) to 7 (Sunday), 0 disables weekly mode; it cannot be combined with positive reset or resetDay. Existing resetDay takes precedence over reset. With expiryTime=0, calendar modes suggest a first cutoff but do not activate renewal. Negative expiryTime waits for first-use activation. resetMax and resetCount simulate the existing per-period allowance limit; the preview is informational and does not reserve an allowance or guarantee node availability.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expiryTime": {
|
||||
"type": "integer",
|
||||
"description": "Current cutoff in Unix milliseconds; 0 unlimited, negative first-use duration."
|
||||
},
|
||||
"reset": {
|
||||
"type": "integer",
|
||||
"description": "Fixed interval in days; 0 disabled."
|
||||
},
|
||||
"resetDay": {
|
||||
"type": "integer",
|
||||
"description": "Monthly calendar day 1-31; 0 disabled."
|
||||
},
|
||||
"resetWeekday": {
|
||||
"type": "integer",
|
||||
"description": "Weekly calendar day 1-7 (Monday-Sunday); 0 disabled."
|
||||
},
|
||||
"resetMax": {
|
||||
"type": "integer",
|
||||
"description": "Maximum renewals; 0 unlimited."
|
||||
},
|
||||
"resetCount": {
|
||||
"type": "integer",
|
||||
"description": "Renewals already consumed; defaults to 0."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"expiryTime",
|
||||
"reset",
|
||||
"resetDay",
|
||||
"resetWeekday",
|
||||
"resetMax",
|
||||
"resetCount"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {
|
||||
"$ref": "#/components/schemas/ClientRenewalPreview"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"obj": {
|
||||
"canRenew": true,
|
||||
"delayedStart": false,
|
||||
"nextExpiry": "2030-02-01T00:00:00Z",
|
||||
"renewAt": "2030-01-01T00:00:00Z",
|
||||
"renewals": 1,
|
||||
"suggestedExpiry": "2030-01-01T00:00:00Z",
|
||||
"suggestedExpiryTime": 1893456000000,
|
||||
"timeZone": "UTC",
|
||||
"validThrough": "2029-12-31T23:59:59Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/update/{email}": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -10137,6 +10504,7 @@
|
||||
"resetCount": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "i7tvdpeffi0hvvf1",
|
||||
"total": 10737418240,
|
||||
"up": 1048576,
|
||||
@@ -11260,6 +11628,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
""
|
||||
@@ -11354,6 +11723,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
""
|
||||
@@ -11451,6 +11821,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
""
|
||||
@@ -11601,6 +11972,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"createdAt": 0,
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
@@ -11722,6 +12094,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"createdAt": 0,
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
@@ -11973,6 +12346,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"createdAt": 0,
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
@@ -15504,6 +15878,7 @@
|
||||
"resetCount": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "i7tvdpeffi0hvvf1",
|
||||
"total": 10737418240,
|
||||
"up": 1048576,
|
||||
@@ -15586,6 +15961,7 @@
|
||||
"resetCount": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "i7tvdpeffi0hvvf1",
|
||||
"total": 10737418240,
|
||||
"up": 1048576,
|
||||
@@ -15632,6 +16008,7 @@
|
||||
"resetCount": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "i7tvdpeffi0hvvf1",
|
||||
"total": 10737418240,
|
||||
"up": 1048576,
|
||||
|
||||
@@ -22,10 +22,13 @@ export const withTheme: Decorator = (Story, context) => {
|
||||
useLayoutEffect(() => {
|
||||
document.body.classList.remove('dark', 'light');
|
||||
document.body.classList.add(dark ? 'dark' : 'light');
|
||||
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
|
||||
document.documentElement.removeAttribute('data-theme');
|
||||
}, [dark]);
|
||||
return (
|
||||
<ConfigProvider theme={buildAntdThemeConfig(dark, false)}>
|
||||
// The click wave outlives its story and re-renders from a ResizeObserver
|
||||
// inside the next story's act(), tripping React's act-environment warning.
|
||||
<ConfigProvider theme={buildAntdThemeConfig(dark, false)} wave={{ disabled: true }}>
|
||||
<div style={{ padding: 24, minWidth: 320 }}>
|
||||
<Story />
|
||||
</div>
|
||||
|
||||
Generated
+1164
-830
File diff suppressed because it is too large
Load Diff
+18
-18
@@ -5,8 +5,8 @@
|
||||
"type": "module",
|
||||
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
|
||||
"engines": {
|
||||
"node": ">=24.0.0",
|
||||
"npm": ">=10.0.0"
|
||||
"node": ">=26.0.0",
|
||||
"npm": ">=11.0.0"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -39,9 +39,9 @@
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@hookform/resolvers": "^5.9.1",
|
||||
"@noble/hashes": "^2.4.0",
|
||||
"@tanstack/react-query": "^5.102.8",
|
||||
"@tanstack/react-query-devtools": "^5.102.8",
|
||||
"antd": "^6.6.4",
|
||||
"@tanstack/react-query": "^5.103.2",
|
||||
"@tanstack/react-query-devtools": "^5.103.2",
|
||||
"antd": "^6.6.5",
|
||||
"codemirror": "^6.0.2",
|
||||
"dayjs": "^1.11.23",
|
||||
"i18next": "^26.4.2",
|
||||
@@ -50,9 +50,9 @@
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-hook-form": "^7.88.0",
|
||||
"react-i18next": "^17.0.14",
|
||||
"react-router": "^8.3.1",
|
||||
"swagger-ui-react": "^5.32.15",
|
||||
"react-i18next": "^17.0.15",
|
||||
"react-router": "^8.4.0",
|
||||
"swagger-ui-react": "^5.33.0",
|
||||
"uplot": "^1.6.32",
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
@@ -67,20 +67,20 @@
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@types/swagger-ui-react": "^5.18.0",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"@vitest/browser-playwright": "5.0.0",
|
||||
"@vitest/coverage-v8": "^5.0.0",
|
||||
"@vitest/browser-playwright": "5.0.2",
|
||||
"@vitest/coverage-v8": "^5.0.2",
|
||||
"husky": "^9.1.7",
|
||||
"jsdom": "^30.0.1",
|
||||
"jsdom": "^30.1.1",
|
||||
"lint-staged": "^17.5.1",
|
||||
"msw": "^2.15.0",
|
||||
"oxfmt": "0.68.0",
|
||||
"oxlint": "1.83.0",
|
||||
"oxlint-tsgolint": "^7.0.2001",
|
||||
"oxfmt": "0.70.0",
|
||||
"oxlint": "1.85.0",
|
||||
"oxlint-tsgolint": "^7.0.2003",
|
||||
"playwright": "^1.63.0",
|
||||
"storybook": "^10.6.0",
|
||||
"typescript": "7.0.2",
|
||||
"vite": "8.3.0",
|
||||
"vitest": "^5.0.0"
|
||||
"vite": "8.3.1",
|
||||
"vitest": "^5.0.2"
|
||||
},
|
||||
"overrides": {
|
||||
"dompurify": "^3.4.11",
|
||||
@@ -98,8 +98,8 @@
|
||||
},
|
||||
"@storybook/addon-vitest": {
|
||||
"vitest": "^5.0.0",
|
||||
"@vitest/browser-playwright": "5.0.0",
|
||||
"@vitest/browser": "5.0.0"
|
||||
"@vitest/browser-playwright": "^5.0.0",
|
||||
"@vitest/browser": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"allowScripts": {
|
||||
|
||||
@@ -69,6 +69,9 @@
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"externalSubUserAgent": {
|
||||
"type": "string"
|
||||
},
|
||||
"externalTrafficInformEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -288,6 +291,9 @@
|
||||
"subHappFallbackUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappLocalProxyAuth": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappNewUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -395,6 +401,9 @@
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"subProfileMode": {
|
||||
"type": "string"
|
||||
},
|
||||
"subProfileUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -516,6 +525,7 @@
|
||||
"discordMemory",
|
||||
"discordRunTime",
|
||||
"expireDiff",
|
||||
"externalSubUserAgent",
|
||||
"externalTrafficInformEnable",
|
||||
"externalTrafficInformURI",
|
||||
"happLinkEnable",
|
||||
@@ -583,6 +593,7 @@
|
||||
"subHappExcludeApns",
|
||||
"subHappExcludeRoutes",
|
||||
"subHappFallbackUrl",
|
||||
"subHappLocalProxyAuth",
|
||||
"subHappNewUrl",
|
||||
"subHappNoLimit",
|
||||
"subHappNotificationExpire",
|
||||
@@ -618,6 +629,7 @@
|
||||
"subListen",
|
||||
"subPath",
|
||||
"subPort",
|
||||
"subProfileMode",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
@@ -696,6 +708,9 @@
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"externalSubUserAgent": {
|
||||
"type": "string"
|
||||
},
|
||||
"externalTrafficInformEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -939,6 +954,9 @@
|
||||
"subHappFallbackUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappLocalProxyAuth": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappNewUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1046,6 +1064,9 @@
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"subProfileMode": {
|
||||
"type": "string"
|
||||
},
|
||||
"subProfileUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1167,6 +1188,7 @@
|
||||
"discordMemory",
|
||||
"discordRunTime",
|
||||
"expireDiff",
|
||||
"externalSubUserAgent",
|
||||
"externalTrafficInformEnable",
|
||||
"externalTrafficInformURI",
|
||||
"happLinkEnable",
|
||||
@@ -1242,6 +1264,7 @@
|
||||
"subHappExcludeApns",
|
||||
"subHappExcludeRoutes",
|
||||
"subHappFallbackUrl",
|
||||
"subHappLocalProxyAuth",
|
||||
"subHappNewUrl",
|
||||
"subHappNoLimit",
|
||||
"subHappNotificationExpire",
|
||||
@@ -1277,6 +1300,7 @@
|
||||
"subListen",
|
||||
"subPath",
|
||||
"subPort",
|
||||
"subProfileMode",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
@@ -1515,13 +1539,17 @@
|
||||
"type": "integer"
|
||||
},
|
||||
"resetDay": {
|
||||
"description": "Calendar renewal day 1-31, 0 = interval mode",
|
||||
"description": "Calendar renewal day 1-31, 0 disables monthly renewal",
|
||||
"type": "integer"
|
||||
},
|
||||
"resetMax": {
|
||||
"description": "Max auto-renew count, 0 = unlimited",
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"description": "Calendar weekday 1-7 (Mon-Sun), 0 disables weekly renewal",
|
||||
"type": "integer"
|
||||
},
|
||||
"reverse": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -1584,6 +1612,7 @@
|
||||
"reset",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday",
|
||||
"security",
|
||||
"subId",
|
||||
"tgId",
|
||||
@@ -1735,6 +1764,9 @@
|
||||
"resetMax": {
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"type": "integer"
|
||||
},
|
||||
"reverse": {},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
@@ -1790,6 +1822,7 @@
|
||||
"reset",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday",
|
||||
"reverse",
|
||||
"secret",
|
||||
"security",
|
||||
@@ -1803,6 +1836,97 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ClientRenewalPreview": {
|
||||
"properties": {
|
||||
"canRenew": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"delayedStart": {
|
||||
"example": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"nextExpiry": {
|
||||
"example": "2030-02-01T00:00:00Z",
|
||||
"type": "string"
|
||||
},
|
||||
"renewAt": {
|
||||
"example": "2030-01-01T00:00:00Z",
|
||||
"type": "string"
|
||||
},
|
||||
"renewals": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"suggestedExpiry": {
|
||||
"example": "2030-01-01T00:00:00Z",
|
||||
"type": "string"
|
||||
},
|
||||
"suggestedExpiryTime": {
|
||||
"example": 1893456000000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"timeZone": {
|
||||
"example": "UTC",
|
||||
"type": "string"
|
||||
},
|
||||
"validThrough": {
|
||||
"example": "2029-12-31T23:59:59Z",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"canRenew",
|
||||
"delayedStart",
|
||||
"nextExpiry",
|
||||
"renewAt",
|
||||
"renewals",
|
||||
"suggestedExpiry",
|
||||
"suggestedExpiryTime",
|
||||
"timeZone",
|
||||
"validThrough"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ClientRenewalPreviewRequest": {
|
||||
"properties": {
|
||||
"expiryTime": {
|
||||
"example": 1893456000000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"reset": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetCount": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetDay": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetMax": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"expiryTime",
|
||||
"reset",
|
||||
"resetCount",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ClientReverse": {
|
||||
"properties": {
|
||||
"tag": {
|
||||
@@ -1873,6 +1997,10 @@
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"subId": {
|
||||
"example": "abcd1234",
|
||||
"type": "string"
|
||||
@@ -1907,6 +2035,7 @@
|
||||
"reset",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday",
|
||||
"subId",
|
||||
"totalGB",
|
||||
"updatedAt"
|
||||
@@ -1962,7 +2091,7 @@
|
||||
"type": "integer"
|
||||
},
|
||||
"resetDay": {
|
||||
"description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 keeps the interval behaviour.",
|
||||
"description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 disables monthly renewal.",
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -1971,6 +2100,11 @@
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"description": "ResetWeekday renews weekly at panel-local midnight: 1 Monday through 7 Sunday.",
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"subId": {
|
||||
"example": "i7tvdpeffi0hvvf1",
|
||||
"type": "string"
|
||||
@@ -2003,6 +2137,7 @@
|
||||
"resetCount",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday",
|
||||
"subId",
|
||||
"total",
|
||||
"up",
|
||||
@@ -2293,6 +2428,9 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cipherSuites": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
@@ -2426,6 +2564,7 @@
|
||||
"address",
|
||||
"allowInsecure",
|
||||
"alpn",
|
||||
"cipherSuites",
|
||||
"createdAt",
|
||||
"echConfigList",
|
||||
"excludeFromSubTypes",
|
||||
@@ -2470,6 +2609,9 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cipherSuites": {
|
||||
"type": "string"
|
||||
},
|
||||
"echConfigList": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2596,6 +2738,7 @@
|
||||
"required": [
|
||||
"allowInsecure",
|
||||
"alpn",
|
||||
"cipherSuites",
|
||||
"echConfigList",
|
||||
"excludeFromSubTypes",
|
||||
"finalMask",
|
||||
@@ -4119,6 +4262,84 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Sponsor": {
|
||||
"description": "Sponsor is one paid placement published in the repo's sponsors.json.",
|
||||
"properties": {
|
||||
"enable": {
|
||||
"example": true,
|
||||
"nullable": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"example": "acme-2026-10",
|
||||
"type": "string"
|
||||
},
|
||||
"link": {
|
||||
"example": "https://acme.example/?utm_source=3x-ui",
|
||||
"type": "string"
|
||||
},
|
||||
"logo": {
|
||||
"example": "/sponsors/logo/acme.png",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"example": "Acme VPS",
|
||||
"type": "string"
|
||||
},
|
||||
"slots": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"text": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"title": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"until": {
|
||||
"example": "2026-11-01T00:00:00Z",
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"link",
|
||||
"name",
|
||||
"slots",
|
||||
"text",
|
||||
"title",
|
||||
"until"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SponsorList": {
|
||||
"description": "SponsorList is the active sponsor set plus the contact link for new sponsors.",
|
||||
"properties": {
|
||||
"contact": {
|
||||
"example": "https://t.me/example",
|
||||
"type": "string"
|
||||
},
|
||||
"sponsors": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Sponsor"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sponsors"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubBalancer": {
|
||||
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
|
||||
"properties": {
|
||||
@@ -4571,6 +4792,59 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/sponsors": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Authentication"
|
||||
],
|
||||
"summary": "Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); expired entries are dropped. Logos are proxied by the panel at /sponsors/logo/{name}. Used by the login page and panel sponsor slots.",
|
||||
"operationId": "get_sponsors",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {
|
||||
"$ref": "#/components/schemas/SponsorList"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"obj": {
|
||||
"contact": "https://t.me/example",
|
||||
"sponsors": [
|
||||
{
|
||||
"enable": true,
|
||||
"id": "acme-2026-10",
|
||||
"link": "https://acme.example/?utm_source=3x-ui",
|
||||
"logo": "/sponsors/logo/acme.png",
|
||||
"name": "Acme VPS",
|
||||
"slots": [
|
||||
""
|
||||
],
|
||||
"text": {},
|
||||
"title": {},
|
||||
"until": "2026-11-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/getTwoFactorEnable": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -4652,6 +4926,7 @@
|
||||
"resetCount": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "i7tvdpeffi0hvvf1",
|
||||
"total": 10737418240,
|
||||
"up": 1048576,
|
||||
@@ -7886,6 +8161,7 @@
|
||||
"reset": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "abcd1234",
|
||||
"totalGB": 53687091200,
|
||||
"traffic": null,
|
||||
@@ -8078,6 +8354,97 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/renewalPreview": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Clients"
|
||||
],
|
||||
"summary": "Preview client auto-renewal dates without saving or resetting anything.",
|
||||
"operationId": "post_panel_api_clients_renewalPreview",
|
||||
"description": "Uses the same calendar and catch-up calculation as auto-renew in the panel timezone. resetWeekday is 1 (Monday) to 7 (Sunday), 0 disables weekly mode; it cannot be combined with positive reset or resetDay. Existing resetDay takes precedence over reset. With expiryTime=0, calendar modes suggest a first cutoff but do not activate renewal. Negative expiryTime waits for first-use activation. resetMax and resetCount simulate the existing per-period allowance limit; the preview is informational and does not reserve an allowance or guarantee node availability.",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"expiryTime": {
|
||||
"type": "integer",
|
||||
"description": "Current cutoff in Unix milliseconds; 0 unlimited, negative first-use duration."
|
||||
},
|
||||
"reset": {
|
||||
"type": "integer",
|
||||
"description": "Fixed interval in days; 0 disabled."
|
||||
},
|
||||
"resetDay": {
|
||||
"type": "integer",
|
||||
"description": "Monthly calendar day 1-31; 0 disabled."
|
||||
},
|
||||
"resetWeekday": {
|
||||
"type": "integer",
|
||||
"description": "Weekly calendar day 1-7 (Monday-Sunday); 0 disabled."
|
||||
},
|
||||
"resetMax": {
|
||||
"type": "integer",
|
||||
"description": "Maximum renewals; 0 unlimited."
|
||||
},
|
||||
"resetCount": {
|
||||
"type": "integer",
|
||||
"description": "Renewals already consumed; defaults to 0."
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"expiryTime",
|
||||
"reset",
|
||||
"resetDay",
|
||||
"resetWeekday",
|
||||
"resetMax",
|
||||
"resetCount"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"msg": {
|
||||
"type": "string"
|
||||
},
|
||||
"obj": {
|
||||
"$ref": "#/components/schemas/ClientRenewalPreview"
|
||||
}
|
||||
}
|
||||
},
|
||||
"example": {
|
||||
"success": true,
|
||||
"obj": {
|
||||
"canRenew": true,
|
||||
"delayedStart": false,
|
||||
"nextExpiry": "2030-02-01T00:00:00Z",
|
||||
"renewAt": "2030-01-01T00:00:00Z",
|
||||
"renewals": 1,
|
||||
"suggestedExpiry": "2030-01-01T00:00:00Z",
|
||||
"suggestedExpiryTime": 1893456000000,
|
||||
"timeZone": "UTC",
|
||||
"validThrough": "2029-12-31T23:59:59Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/panel/api/clients/update/{email}": {
|
||||
"post": {
|
||||
"tags": [
|
||||
@@ -10137,6 +10504,7 @@
|
||||
"resetCount": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "i7tvdpeffi0hvvf1",
|
||||
"total": 10737418240,
|
||||
"up": 1048576,
|
||||
@@ -11260,6 +11628,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
""
|
||||
@@ -11354,6 +11723,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
""
|
||||
@@ -11451,6 +11821,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
""
|
||||
@@ -11601,6 +11972,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"createdAt": 0,
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
@@ -11722,6 +12094,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"createdAt": 0,
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
@@ -11973,6 +12346,7 @@
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"createdAt": 0,
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
@@ -15504,6 +15878,7 @@
|
||||
"resetCount": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "i7tvdpeffi0hvvf1",
|
||||
"total": 10737418240,
|
||||
"up": 1048576,
|
||||
@@ -15586,6 +15961,7 @@
|
||||
"resetCount": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "i7tvdpeffi0hvvf1",
|
||||
"total": 10737418240,
|
||||
"up": 1048576,
|
||||
@@ -15632,6 +16008,7 @@
|
||||
"resetCount": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "i7tvdpeffi0hvvf1",
|
||||
"total": 10737418240,
|
||||
"up": 1048576,
|
||||
|
||||
@@ -196,6 +196,10 @@ export async function httpRequest(
|
||||
return { ok: true, status: res.status, statusText: res.statusText, data: parsed };
|
||||
}
|
||||
|
||||
export function withBasePath(path: string): string {
|
||||
return basePathPrefix + path;
|
||||
}
|
||||
|
||||
export function setupHttp(): void {
|
||||
let basePath: string | null | undefined = window.X_UI_BASE_PATH;
|
||||
if (!basePath) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import { keys } from '@/api/queryKeys';
|
||||
import type { SponsorList } from '@/generated/types';
|
||||
|
||||
const EMPTY: SponsorList = { sponsors: [] };
|
||||
|
||||
async function fetchSponsors(): Promise<SponsorList> {
|
||||
const msg = await HttpUtil.get<SponsorList>('/sponsors', undefined, { silent: true });
|
||||
if (!msg?.success || !msg.obj) return EMPTY;
|
||||
return { contact: msg.obj.contact, sponsors: msg.obj.sponsors ?? [] };
|
||||
}
|
||||
|
||||
export function useSponsorsQuery() {
|
||||
const query = useQuery({
|
||||
queryKey: keys.sponsors(),
|
||||
queryFn: fetchSponsors,
|
||||
staleTime: 60 * 60 * 1000,
|
||||
retry: false,
|
||||
});
|
||||
return { data: query.data ?? EMPTY, fetched: query.isFetched };
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export const keys = {
|
||||
sponsors: () => ['sponsors'] as const,
|
||||
server: {
|
||||
status: () => ['server', 'status'] as const,
|
||||
fail2banStatus: () => ['server', 'fail2banStatus'] as const,
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
ClusterOutlined,
|
||||
CodeOutlined,
|
||||
CopyOutlined,
|
||||
CrownOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
DiscordOutlined,
|
||||
@@ -418,6 +419,12 @@ export default function CommandPalette() {
|
||||
keywords: ['api', 'api docs', 'swagger', 'rest api', 'endpoints'],
|
||||
icon: <ApiOutlined />,
|
||||
},
|
||||
{
|
||||
path: '/sponsors',
|
||||
title: t('menu.sponsors'),
|
||||
keywords: ['sponsors', 'sponsor', 'partners'],
|
||||
icon: <CrownOutlined />,
|
||||
},
|
||||
];
|
||||
|
||||
pages
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Select } from 'antd';
|
||||
import type { SelectProps } from 'antd';
|
||||
|
||||
import { TLS_CIPHER_OPTION } from '@/schemas/primitives';
|
||||
|
||||
const CIPHER_SUITE_OPTIONS = Object.values(TLS_CIPHER_OPTION).map((v) => ({ value: v, label: v }));
|
||||
|
||||
type CipherSuitesSelectProps = Omit<
|
||||
SelectProps<string[]>,
|
||||
'value' | 'onChange' | 'mode' | 'options'
|
||||
> & {
|
||||
// Injected by FormField:
|
||||
value?: string;
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
// xray splits cipherSuites on ':' into a list, so the picker edits tags while
|
||||
// the stored value stays the single colon-joined string xray reads.
|
||||
export default function CipherSuitesSelect({
|
||||
value = '',
|
||||
onChange,
|
||||
...rest
|
||||
}: CipherSuitesSelectProps) {
|
||||
const suites = value
|
||||
.split(':')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
return (
|
||||
<Select
|
||||
allowClear
|
||||
tokenSeparators={[':', ',']}
|
||||
{...rest}
|
||||
mode="tags"
|
||||
options={CIPHER_SUITE_OPTIONS}
|
||||
value={suites}
|
||||
onChange={(next) => onChange?.(next.join(':'))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export { default as JsonEditor } from './JsonEditor';
|
||||
export { default as HeaderMapEditor } from './HeaderMapEditor';
|
||||
export { default as GoRegexInput, validateGoRegex } from './GoRegexInput';
|
||||
export { default as SelectAllClearButtons } from './SelectAllClearButtons';
|
||||
export { default as CipherSuitesSelect } from './CipherSuitesSelect';
|
||||
export { default as RemarkTemplateField } from './RemarkTemplateField';
|
||||
export { default as RemarkVarPicker } from './RemarkVarPicker';
|
||||
export { default as CustomSockoptList } from '../../lib/xray/forms/transport/CustomSockoptList';
|
||||
|
||||
@@ -24,6 +24,10 @@ import type { GeoCategory, GeoEntry, GeoFile, GeoKind } from '@/generated/types'
|
||||
import './GeoBrowserModal.css';
|
||||
|
||||
const ENTRY_PAGE_SIZE = 100;
|
||||
|
||||
// Attributes are dropped server-side, so kind:value repeats within real
|
||||
// geosite categories; the page position is the only unique row key.
|
||||
type GeoEntryRow = GeoEntry & { position: number };
|
||||
const CATEGORY_SCROLL_HEIGHT = 438;
|
||||
const ENTRY_FILTER_DELAY = 500;
|
||||
|
||||
@@ -224,7 +228,12 @@ export default function GeoBrowserModal({
|
||||
[t],
|
||||
);
|
||||
|
||||
const entryColumns: ColumnsType<GeoEntry> = useMemo(
|
||||
const entryRows: GeoEntryRow[] = useMemo(
|
||||
() => (entriesQuery.data?.items ?? []).map((entry, position) => ({ ...entry, position })),
|
||||
[entriesQuery.data],
|
||||
);
|
||||
|
||||
const entryColumns: ColumnsType<GeoEntryRow> = useMemo(
|
||||
() => [
|
||||
{
|
||||
dataIndex: 'kind',
|
||||
@@ -391,9 +400,9 @@ export default function GeoBrowserModal({
|
||||
<Table
|
||||
size="small"
|
||||
showHeader={false}
|
||||
rowKey={(entry, index) => `${entry.value}-${index}`}
|
||||
rowKey="position"
|
||||
columns={entryColumns}
|
||||
dataSource={entriesQuery.data?.items ?? []}
|
||||
dataSource={entryRows}
|
||||
loading={entriesQuery.isLoading}
|
||||
locale={{
|
||||
emptyText: entriesQuery.isError
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
.sponsor-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
min-width: 0;
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: var(--ant-border-radius-lg, 8px);
|
||||
background: var(--bg-card, var(--ant-color-fill-quaternary));
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
background 0.2s;
|
||||
}
|
||||
|
||||
.sponsor-card:hover {
|
||||
border-color: var(--ant-color-primary);
|
||||
}
|
||||
|
||||
.sponsor-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 12px 16px;
|
||||
color: var(--ant-color-text);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.sponsor-main:hover,
|
||||
.sponsor-main:focus-visible {
|
||||
color: var(--ant-color-text);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sponsor-logo {
|
||||
flex: 0 0 auto;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
background: var(--ant-color-bg-elevated);
|
||||
}
|
||||
|
||||
.sponsor-logo-fallback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
color: var(--ant-color-primary);
|
||||
background: var(--ant-color-primary-bg);
|
||||
}
|
||||
|
||||
.sponsor-body {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sponsor-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sponsor-tag {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--ant-color-border);
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
color: var(--ant-color-text-tertiary);
|
||||
}
|
||||
|
||||
.sponsor-title {
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sponsor-text {
|
||||
font-size: 13px;
|
||||
color: var(--ant-color-text-secondary);
|
||||
}
|
||||
|
||||
.sponsor-visit {
|
||||
flex: 0 0 auto;
|
||||
font-size: 13px;
|
||||
color: var(--ant-color-primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sponsor-close {
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin: 6px 6px 0 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--ant-color-text-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sponsor-close:hover,
|
||||
.sponsor-close:focus-visible {
|
||||
color: var(--ant-color-text);
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[dir='rtl'] .sponsor-close {
|
||||
margin: 6px 0 0 6px;
|
||||
}
|
||||
|
||||
.sponsor-card-compact .sponsor-main {
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.sponsor-card-compact .sponsor-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.sponsor-card-compact .sponsor-head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sponsor-card-compact .sponsor-title {
|
||||
display: -webkit-box;
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
white-space: normal;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.sponsor-card-compact .sponsor-text {
|
||||
display: -webkit-box;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.sponsor-card-compact .sponsor-close {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin: 4px 4px 0 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.sponsor-card-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sponsor-card-card .sponsor-main {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.sponsor-card-card .sponsor-logo {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.sponsor-card-card .sponsor-visit {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.sponsor-card-icon {
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
border-color: transparent;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sponsor-card-icon .sponsor-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.sponsor-card-banner .sponsor-main {
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.sponsor-card-banner .sponsor-visit {
|
||||
flex-basis: 100%;
|
||||
padding-inline-start: 52px;
|
||||
}
|
||||
}
|
||||
|
||||
.sponsor-card-card {
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
transform 0.2s,
|
||||
box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.sponsor-card-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { expect, within } from 'storybook/test';
|
||||
|
||||
import type { Sponsor } from '@/generated/types';
|
||||
import SponsorCard from './SponsorCard';
|
||||
|
||||
const sponsor: Sponsor = {
|
||||
id: 'acme-2026-10',
|
||||
name: 'Acme VPS',
|
||||
slots: ['dashboard', 'sidebar', 'page', 'login'],
|
||||
until: '2099-01-01T00:00:00Z',
|
||||
title: { en: 'Acme VPS — fast NVMe servers', fa: 'سرورهای سریع Acme' },
|
||||
text: { en: 'Deploy 3X-UI in 60 seconds. 20% off for panel users.' },
|
||||
link: 'https://acme.example/?utm_source=3x-ui',
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: 'Sponsor/SponsorCard',
|
||||
component: SponsorCard,
|
||||
tags: ['autodocs'],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
'Paid sponsor placement fed by the project sponsors.json. Always labelled as a sponsor; links open in a new tab with rel="sponsored".',
|
||||
},
|
||||
},
|
||||
},
|
||||
argTypes: {
|
||||
sponsor: { description: 'Sponsor entry from GET /sponsors.' },
|
||||
variant: {
|
||||
description: 'banner (dashboard), compact (sidebar/login) or card (Sponsors page).',
|
||||
},
|
||||
iconOnly: { description: 'Logo-only rendering for the collapsed sidebar rail.' },
|
||||
onClose: { description: 'When set, shows a close button (temporary dismiss).' },
|
||||
},
|
||||
args: { sponsor },
|
||||
} satisfies Meta<typeof SponsorCard>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Banner: Story = {
|
||||
args: { variant: 'banner', onClose: () => {} },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText('Sponsor')).toBeInTheDocument();
|
||||
await expect(canvas.getByRole('link')).toHaveAttribute('rel', 'noopener noreferrer sponsored');
|
||||
},
|
||||
};
|
||||
|
||||
export const Compact: Story = {
|
||||
args: { variant: 'compact', onClose: () => {} },
|
||||
render: (args) => (
|
||||
<div style={{ width: 204 }}>
|
||||
<SponsorCard {...args} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Card: Story = {
|
||||
args: { variant: 'card' },
|
||||
render: (args) => (
|
||||
<div style={{ width: 320 }}>
|
||||
<SponsorCard {...args} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const IconOnly: Story = {
|
||||
args: { iconOnly: true },
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CloseOutlined, ExportOutlined } from '@ant-design/icons';
|
||||
|
||||
import { withBasePath } from '@/api/http-init';
|
||||
import type { Sponsor } from '@/generated/types';
|
||||
import { pickLocale } from '@/lib/sponsors';
|
||||
import './SponsorCard.css';
|
||||
|
||||
export type SponsorCardVariant = 'banner' | 'compact' | 'card';
|
||||
|
||||
export interface SponsorCardProps {
|
||||
sponsor: Sponsor;
|
||||
variant?: SponsorCardVariant;
|
||||
iconOnly?: boolean;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
function SponsorLogo({ sponsor }: { sponsor: Sponsor }) {
|
||||
const [failedSrc, setFailedSrc] = useState('');
|
||||
if (sponsor.logo && failedSrc !== sponsor.logo) {
|
||||
return (
|
||||
<img
|
||||
className="sponsor-logo"
|
||||
src={withBasePath(sponsor.logo)}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={() => setFailedSrc(sponsor.logo ?? '')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className="sponsor-logo sponsor-logo-fallback" aria-hidden="true">
|
||||
{sponsor.name.slice(0, 1).toUpperCase()}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SponsorCard({
|
||||
sponsor,
|
||||
variant = 'banner',
|
||||
iconOnly = false,
|
||||
onClose,
|
||||
}: SponsorCardProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.resolvedLanguage || i18n.language || 'en';
|
||||
const title = pickLocale(sponsor.title, lang) || sponsor.name;
|
||||
const text = pickLocale(sponsor.text, lang);
|
||||
const tag = t('pages.sponsors.tag');
|
||||
|
||||
if (iconOnly) {
|
||||
return (
|
||||
<a
|
||||
className="sponsor-card sponsor-card-icon"
|
||||
href={sponsor.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer sponsored"
|
||||
title={`${tag} · ${title}`}
|
||||
aria-label={`${tag}: ${title}`}
|
||||
>
|
||||
<SponsorLogo sponsor={sponsor} />
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`sponsor-card sponsor-card-${variant}`}>
|
||||
<a
|
||||
className="sponsor-main"
|
||||
href={sponsor.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer sponsored"
|
||||
>
|
||||
<SponsorLogo sponsor={sponsor} />
|
||||
<span className="sponsor-body" dir="auto">
|
||||
<span className="sponsor-head">
|
||||
<span className="sponsor-tag">{tag}</span>
|
||||
<span className="sponsor-title">{title}</span>
|
||||
</span>
|
||||
{text && <span className="sponsor-text">{text}</span>}
|
||||
</span>
|
||||
{variant !== 'compact' && (
|
||||
<span className="sponsor-visit">
|
||||
{t('pages.sponsors.visit')} <ExportOutlined />
|
||||
</span>
|
||||
)}
|
||||
</a>
|
||||
{onClose && (
|
||||
<button
|
||||
type="button"
|
||||
className="sponsor-close"
|
||||
aria-label={t('close')}
|
||||
title={t('close')}
|
||||
onClick={onClose}
|
||||
>
|
||||
<CloseOutlined />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
|
||||
import {
|
||||
dismissSponsor,
|
||||
isSponsorDismissed,
|
||||
sponsorsForSlot,
|
||||
type SponsorSlot as Slot,
|
||||
} from '@/lib/sponsors';
|
||||
import SponsorCard, { type SponsorCardVariant } from './SponsorCard';
|
||||
|
||||
const ROTATE_MS = 30_000;
|
||||
|
||||
interface SponsorSlotProps {
|
||||
slot: Slot;
|
||||
variant?: SponsorCardVariant;
|
||||
iconOnly?: boolean;
|
||||
rotate?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function SponsorSlot({
|
||||
slot,
|
||||
variant = 'banner',
|
||||
iconOnly,
|
||||
rotate,
|
||||
className,
|
||||
}: SponsorSlotProps) {
|
||||
const { data } = useSponsorsQuery();
|
||||
const [, setDismissTick] = useState(0);
|
||||
const [index, setIndex] = useState(0);
|
||||
|
||||
// Re-filtered each render so a close (dismissTick bump) re-reads localStorage.
|
||||
const visible = sponsorsForSlot(data.sponsors, slot).filter(
|
||||
(s) => !isSponsorDismissed(s.id, slot),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!rotate || visible.length < 2) return;
|
||||
const timer = window.setInterval(() => setIndex((i) => i + 1), ROTATE_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [rotate, visible.length]);
|
||||
|
||||
if (visible.length === 0) return null;
|
||||
const sponsor = visible[(rotate ? index : 0) % visible.length];
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<SponsorCard
|
||||
sponsor={sponsor}
|
||||
variant={variant}
|
||||
iconOnly={iconOnly}
|
||||
onClose={() => {
|
||||
dismissSponsor(sponsor.id, slot);
|
||||
setDismissTick((n) => n + 1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Vendored
+2
@@ -15,6 +15,8 @@ interface SubPageData {
|
||||
subJsonUrl?: string;
|
||||
subClashUrl?: string;
|
||||
subTitle?: string;
|
||||
subSupportUrl?: string;
|
||||
subUpdates?: number;
|
||||
links?: string[];
|
||||
emails?: string[];
|
||||
datepicker?: 'gregorian' | 'jalalian';
|
||||
|
||||
@@ -13,6 +13,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"discordMemory": 0,
|
||||
"discordRunTime": "",
|
||||
"expireDiff": 0,
|
||||
"externalSubUserAgent": "",
|
||||
"externalTrafficInformEnable": false,
|
||||
"externalTrafficInformURI": "",
|
||||
"happLinkEnable": false,
|
||||
@@ -80,6 +81,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subHappExcludeApns": false,
|
||||
"subHappExcludeRoutes": "",
|
||||
"subHappFallbackUrl": "",
|
||||
"subHappLocalProxyAuth": "",
|
||||
"subHappNewUrl": "",
|
||||
"subHappNoLimit": false,
|
||||
"subHappNotificationExpire": false,
|
||||
@@ -115,6 +117,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subListen": "",
|
||||
"subPath": "",
|
||||
"subPort": 1,
|
||||
"subProfileMode": "",
|
||||
"subProfileUrl": "",
|
||||
"subRoutingRules": "",
|
||||
"subShowIdentityOnAllLinks": false,
|
||||
@@ -161,6 +164,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"discordMemory": 0,
|
||||
"discordRunTime": "",
|
||||
"expireDiff": 0,
|
||||
"externalSubUserAgent": "",
|
||||
"externalTrafficInformEnable": false,
|
||||
"externalTrafficInformURI": "",
|
||||
"happLinkEnable": false,
|
||||
@@ -236,6 +240,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subHappExcludeApns": false,
|
||||
"subHappExcludeRoutes": "",
|
||||
"subHappFallbackUrl": "",
|
||||
"subHappLocalProxyAuth": "",
|
||||
"subHappNewUrl": "",
|
||||
"subHappNoLimit": false,
|
||||
"subHappNotificationExpire": false,
|
||||
@@ -271,6 +276,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"subListen": "",
|
||||
"subPath": "",
|
||||
"subPort": 1,
|
||||
"subProfileMode": "",
|
||||
"subProfileUrl": "",
|
||||
"subRoutingRules": "",
|
||||
"subShowIdentityOnAllLinks": false,
|
||||
@@ -367,6 +373,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"reset": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"reverse": null,
|
||||
"secret": "ee1234567890abcdef1234567890abcd7777772e636c6f7564666c6172652e636f6d",
|
||||
"security": "",
|
||||
@@ -406,6 +413,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"reset": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "abcd1234",
|
||||
"totalGB": 53687091200,
|
||||
"traffic": null,
|
||||
@@ -455,6 +463,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"reset": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"reverse": null,
|
||||
"secret": "",
|
||||
"security": "",
|
||||
@@ -466,6 +475,25 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"updatedAt": 0,
|
||||
"uuid": ""
|
||||
},
|
||||
"ClientRenewalPreview": {
|
||||
"canRenew": true,
|
||||
"delayedStart": false,
|
||||
"nextExpiry": "2030-02-01T00:00:00Z",
|
||||
"renewAt": "2030-01-01T00:00:00Z",
|
||||
"renewals": 1,
|
||||
"suggestedExpiry": "2030-01-01T00:00:00Z",
|
||||
"suggestedExpiryTime": 1893456000000,
|
||||
"timeZone": "UTC",
|
||||
"validThrough": "2029-12-31T23:59:59Z"
|
||||
},
|
||||
"ClientRenewalPreviewRequest": {
|
||||
"expiryTime": 1893456000000,
|
||||
"reset": 0,
|
||||
"resetCount": 0,
|
||||
"resetDay": 1,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0
|
||||
},
|
||||
"ClientReverse": {
|
||||
"tag": ""
|
||||
},
|
||||
@@ -485,6 +513,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"reset": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "abcd1234",
|
||||
"totalGB": 53687091200,
|
||||
"traffic": null,
|
||||
@@ -503,6 +532,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"resetCount": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "i7tvdpeffi0hvvf1",
|
||||
"total": 10737418240,
|
||||
"up": 1048576,
|
||||
@@ -589,6 +619,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"createdAt": 0,
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
@@ -634,6 +665,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"alpn": [
|
||||
""
|
||||
],
|
||||
"cipherSuites": "",
|
||||
"echConfigList": "",
|
||||
"excludeFromSubTypes": [
|
||||
""
|
||||
@@ -698,6 +730,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"resetCount": 0,
|
||||
"resetDay": 0,
|
||||
"resetMax": 0,
|
||||
"resetWeekday": 0,
|
||||
"subId": "i7tvdpeffi0hvvf1",
|
||||
"total": 10737418240,
|
||||
"up": 1048576,
|
||||
@@ -1015,6 +1048,37 @@ export const EXAMPLES: Record<string, unknown> = {
|
||||
"key": "",
|
||||
"value": ""
|
||||
},
|
||||
"Sponsor": {
|
||||
"enable": true,
|
||||
"id": "acme-2026-10",
|
||||
"link": "https://acme.example/?utm_source=3x-ui",
|
||||
"logo": "/sponsors/logo/acme.png",
|
||||
"name": "Acme VPS",
|
||||
"slots": [
|
||||
""
|
||||
],
|
||||
"text": {},
|
||||
"title": {},
|
||||
"until": "2026-11-01T00:00:00Z"
|
||||
},
|
||||
"SponsorList": {
|
||||
"contact": "https://t.me/example",
|
||||
"sponsors": [
|
||||
{
|
||||
"enable": true,
|
||||
"id": "acme-2026-10",
|
||||
"link": "https://acme.example/?utm_source=3x-ui",
|
||||
"logo": "/sponsors/logo/acme.png",
|
||||
"name": "Acme VPS",
|
||||
"slots": [
|
||||
""
|
||||
],
|
||||
"text": {},
|
||||
"title": {},
|
||||
"until": "2026-11-01T00:00:00Z"
|
||||
}
|
||||
]
|
||||
},
|
||||
"SubBalancer": {
|
||||
"createdAt": 1710000000000,
|
||||
"enabled": true,
|
||||
|
||||
@@ -43,6 +43,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"externalSubUserAgent": {
|
||||
"type": "string"
|
||||
},
|
||||
"externalTrafficInformEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -262,6 +265,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subHappFallbackUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappLocalProxyAuth": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappNewUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -369,6 +375,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"subProfileMode": {
|
||||
"type": "string"
|
||||
},
|
||||
"subProfileUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -490,6 +499,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"discordMemory",
|
||||
"discordRunTime",
|
||||
"expireDiff",
|
||||
"externalSubUserAgent",
|
||||
"externalTrafficInformEnable",
|
||||
"externalTrafficInformURI",
|
||||
"happLinkEnable",
|
||||
@@ -557,6 +567,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subHappExcludeApns",
|
||||
"subHappExcludeRoutes",
|
||||
"subHappFallbackUrl",
|
||||
"subHappLocalProxyAuth",
|
||||
"subHappNewUrl",
|
||||
"subHappNoLimit",
|
||||
"subHappNotificationExpire",
|
||||
@@ -592,6 +603,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subListen",
|
||||
"subPath",
|
||||
"subPort",
|
||||
"subProfileMode",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
@@ -670,6 +682,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"minimum": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"externalSubUserAgent": {
|
||||
"type": "string"
|
||||
},
|
||||
"externalTrafficInformEnable": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -913,6 +928,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subHappFallbackUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappLocalProxyAuth": {
|
||||
"type": "string"
|
||||
},
|
||||
"subHappNewUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1020,6 +1038,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"minimum": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"subProfileMode": {
|
||||
"type": "string"
|
||||
},
|
||||
"subProfileUrl": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1141,6 +1162,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"discordMemory",
|
||||
"discordRunTime",
|
||||
"expireDiff",
|
||||
"externalSubUserAgent",
|
||||
"externalTrafficInformEnable",
|
||||
"externalTrafficInformURI",
|
||||
"happLinkEnable",
|
||||
@@ -1216,6 +1238,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subHappExcludeApns",
|
||||
"subHappExcludeRoutes",
|
||||
"subHappFallbackUrl",
|
||||
"subHappLocalProxyAuth",
|
||||
"subHappNewUrl",
|
||||
"subHappNoLimit",
|
||||
"subHappNotificationExpire",
|
||||
@@ -1251,6 +1274,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"subListen",
|
||||
"subPath",
|
||||
"subPort",
|
||||
"subProfileMode",
|
||||
"subProfileUrl",
|
||||
"subRoutingRules",
|
||||
"subShowIdentityOnAllLinks",
|
||||
@@ -1489,13 +1513,17 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "integer"
|
||||
},
|
||||
"resetDay": {
|
||||
"description": "Calendar renewal day 1-31, 0 = interval mode",
|
||||
"description": "Calendar renewal day 1-31, 0 disables monthly renewal",
|
||||
"type": "integer"
|
||||
},
|
||||
"resetMax": {
|
||||
"description": "Max auto-renew count, 0 = unlimited",
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"description": "Calendar weekday 1-7 (Mon-Sun), 0 disables weekly renewal",
|
||||
"type": "integer"
|
||||
},
|
||||
"reverse": {
|
||||
"allOf": [
|
||||
{
|
||||
@@ -1558,6 +1586,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"reset",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday",
|
||||
"security",
|
||||
"subId",
|
||||
"tgId",
|
||||
@@ -1709,6 +1738,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"resetMax": {
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"type": "integer"
|
||||
},
|
||||
"reverse": {},
|
||||
"secret": {
|
||||
"type": "string"
|
||||
@@ -1764,6 +1796,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"reset",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday",
|
||||
"reverse",
|
||||
"secret",
|
||||
"security",
|
||||
@@ -1777,6 +1810,97 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ClientRenewalPreview": {
|
||||
"properties": {
|
||||
"canRenew": {
|
||||
"example": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"delayedStart": {
|
||||
"example": false,
|
||||
"type": "boolean"
|
||||
},
|
||||
"nextExpiry": {
|
||||
"example": "2030-02-01T00:00:00Z",
|
||||
"type": "string"
|
||||
},
|
||||
"renewAt": {
|
||||
"example": "2030-01-01T00:00:00Z",
|
||||
"type": "string"
|
||||
},
|
||||
"renewals": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"suggestedExpiry": {
|
||||
"example": "2030-01-01T00:00:00Z",
|
||||
"type": "string"
|
||||
},
|
||||
"suggestedExpiryTime": {
|
||||
"example": 1893456000000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"timeZone": {
|
||||
"example": "UTC",
|
||||
"type": "string"
|
||||
},
|
||||
"validThrough": {
|
||||
"example": "2029-12-31T23:59:59Z",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"canRenew",
|
||||
"delayedStart",
|
||||
"nextExpiry",
|
||||
"renewAt",
|
||||
"renewals",
|
||||
"suggestedExpiry",
|
||||
"suggestedExpiryTime",
|
||||
"timeZone",
|
||||
"validThrough"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ClientRenewalPreviewRequest": {
|
||||
"properties": {
|
||||
"expiryTime": {
|
||||
"example": 1893456000000,
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"reset": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetCount": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetDay": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetMax": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"expiryTime",
|
||||
"reset",
|
||||
"resetCount",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ClientReverse": {
|
||||
"properties": {
|
||||
"tag": {
|
||||
@@ -1847,6 +1971,10 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"subId": {
|
||||
"example": "abcd1234",
|
||||
"type": "string"
|
||||
@@ -1881,6 +2009,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"reset",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday",
|
||||
"subId",
|
||||
"totalGB",
|
||||
"updatedAt"
|
||||
@@ -1936,7 +2065,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"type": "integer"
|
||||
},
|
||||
"resetDay": {
|
||||
"description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 keeps the interval behaviour.",
|
||||
"description": "ResetDay renews on that day of each calendar month instead of every\nReset days; 0 disables monthly renewal.",
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
@@ -1945,6 +2074,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"resetWeekday": {
|
||||
"description": "ResetWeekday renews weekly at panel-local midnight: 1 Monday through 7 Sunday.",
|
||||
"example": 0,
|
||||
"type": "integer"
|
||||
},
|
||||
"subId": {
|
||||
"example": "i7tvdpeffi0hvvf1",
|
||||
"type": "string"
|
||||
@@ -1977,6 +2111,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"resetCount",
|
||||
"resetDay",
|
||||
"resetMax",
|
||||
"resetWeekday",
|
||||
"subId",
|
||||
"total",
|
||||
"up",
|
||||
@@ -2267,6 +2402,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cipherSuites": {
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
@@ -2400,6 +2538,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"address",
|
||||
"allowInsecure",
|
||||
"alpn",
|
||||
"cipherSuites",
|
||||
"createdAt",
|
||||
"echConfigList",
|
||||
"excludeFromSubTypes",
|
||||
@@ -2444,6 +2583,9 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"cipherSuites": {
|
||||
"type": "string"
|
||||
},
|
||||
"echConfigList": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -2570,6 +2712,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
"required": [
|
||||
"allowInsecure",
|
||||
"alpn",
|
||||
"cipherSuites",
|
||||
"echConfigList",
|
||||
"excludeFromSubTypes",
|
||||
"finalMask",
|
||||
@@ -4093,6 +4236,84 @@ export const SCHEMAS: Record<string, unknown> = {
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"Sponsor": {
|
||||
"description": "Sponsor is one paid placement published in the repo's sponsors.json.",
|
||||
"properties": {
|
||||
"enable": {
|
||||
"example": true,
|
||||
"nullable": true,
|
||||
"type": "boolean"
|
||||
},
|
||||
"id": {
|
||||
"example": "acme-2026-10",
|
||||
"type": "string"
|
||||
},
|
||||
"link": {
|
||||
"example": "https://acme.example/?utm_source=3x-ui",
|
||||
"type": "string"
|
||||
},
|
||||
"logo": {
|
||||
"example": "/sponsors/logo/acme.png",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"example": "Acme VPS",
|
||||
"type": "string"
|
||||
},
|
||||
"slots": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"text": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"title": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"until": {
|
||||
"example": "2026-11-01T00:00:00Z",
|
||||
"format": "date-time",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"link",
|
||||
"name",
|
||||
"slots",
|
||||
"text",
|
||||
"title",
|
||||
"until"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SponsorList": {
|
||||
"description": "SponsorList is the active sponsor set plus the contact link for new sponsors.",
|
||||
"properties": {
|
||||
"contact": {
|
||||
"example": "https://t.me/example",
|
||||
"type": "string"
|
||||
},
|
||||
"sponsors": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Sponsor"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sponsors"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"SubBalancer": {
|
||||
"description": "SubBalancer is one extra JSON-subscription config document whose members are\nthe selected inbounds' proxy outbounds. SortOrder shares SubSortIndex semantics.",
|
||||
"properties": {
|
||||
|
||||
@@ -3,6 +3,7 @@ export type GeoKind = string;
|
||||
export type OnlineAPISupport = number;
|
||||
export type ProcessState = string;
|
||||
export type Protocol = string;
|
||||
export type addrFamily = number;
|
||||
export type staticEgressResolver = string;
|
||||
export type trafficLocalApplyAction = number;
|
||||
export type transportBits = number;
|
||||
@@ -20,6 +21,7 @@ export interface AllSetting {
|
||||
discordMemory: number;
|
||||
discordRunTime: string;
|
||||
expireDiff: number;
|
||||
externalSubUserAgent: string;
|
||||
externalTrafficInformEnable: boolean;
|
||||
externalTrafficInformURI: string;
|
||||
happLinkEnable: boolean;
|
||||
@@ -87,6 +89,7 @@ export interface AllSetting {
|
||||
subHappExcludeApns: boolean;
|
||||
subHappExcludeRoutes: string;
|
||||
subHappFallbackUrl: string;
|
||||
subHappLocalProxyAuth: string;
|
||||
subHappNewUrl: string;
|
||||
subHappNoLimit: boolean;
|
||||
subHappNotificationExpire: boolean;
|
||||
@@ -122,6 +125,7 @@ export interface AllSetting {
|
||||
subListen: string;
|
||||
subPath: string;
|
||||
subPort: number;
|
||||
subProfileMode: string;
|
||||
subProfileUrl: string;
|
||||
subRoutingRules: string;
|
||||
subShowIdentityOnAllLinks: boolean;
|
||||
@@ -169,6 +173,7 @@ export interface AllSettingView {
|
||||
discordMemory: number;
|
||||
discordRunTime: string;
|
||||
expireDiff: number;
|
||||
externalSubUserAgent: string;
|
||||
externalTrafficInformEnable: boolean;
|
||||
externalTrafficInformURI: string;
|
||||
happLinkEnable: boolean;
|
||||
@@ -244,6 +249,7 @@ export interface AllSettingView {
|
||||
subHappExcludeApns: boolean;
|
||||
subHappExcludeRoutes: string;
|
||||
subHappFallbackUrl: string;
|
||||
subHappLocalProxyAuth: string;
|
||||
subHappNewUrl: string;
|
||||
subHappNoLimit: boolean;
|
||||
subHappNotificationExpire: boolean;
|
||||
@@ -279,6 +285,7 @@ export interface AllSettingView {
|
||||
subListen: string;
|
||||
subPath: string;
|
||||
subPort: number;
|
||||
subProfileMode: string;
|
||||
subProfileUrl: string;
|
||||
subRoutingRules: string;
|
||||
subShowIdentityOnAllLinks: boolean;
|
||||
@@ -362,6 +369,7 @@ export interface Client {
|
||||
reset: number;
|
||||
resetDay: number;
|
||||
resetMax: number;
|
||||
resetWeekday: number;
|
||||
reverse?: ClientReverse | null;
|
||||
secret?: string;
|
||||
security: string;
|
||||
@@ -413,6 +421,7 @@ export interface ClientRecord {
|
||||
reset: number;
|
||||
resetDay: number;
|
||||
resetMax: number;
|
||||
resetWeekday: number;
|
||||
reverse: unknown;
|
||||
secret: string;
|
||||
security: string;
|
||||
@@ -425,6 +434,27 @@ export interface ClientRecord {
|
||||
uuid: string;
|
||||
}
|
||||
|
||||
export interface ClientRenewalPreview {
|
||||
canRenew: boolean;
|
||||
delayedStart: boolean;
|
||||
nextExpiry: string;
|
||||
renewAt: string;
|
||||
renewals: number;
|
||||
suggestedExpiry: string;
|
||||
suggestedExpiryTime: number;
|
||||
timeZone: string;
|
||||
validThrough: string;
|
||||
}
|
||||
|
||||
export interface ClientRenewalPreviewRequest {
|
||||
expiryTime: number;
|
||||
reset: number;
|
||||
resetCount: number;
|
||||
resetDay: number;
|
||||
resetMax: number;
|
||||
resetWeekday: number;
|
||||
}
|
||||
|
||||
export interface ClientReverse {
|
||||
tag: string;
|
||||
}
|
||||
@@ -442,6 +472,7 @@ export interface ClientSlim {
|
||||
reset: number;
|
||||
resetDay: number;
|
||||
resetMax: number;
|
||||
resetWeekday: number;
|
||||
subId: string;
|
||||
totalGB: number;
|
||||
traffic?: ClientTraffic | null;
|
||||
@@ -461,6 +492,7 @@ export interface ClientTraffic {
|
||||
resetCount: number;
|
||||
resetDay: number;
|
||||
resetMax: number;
|
||||
resetWeekday: number;
|
||||
subId: string;
|
||||
total: number;
|
||||
up: number;
|
||||
@@ -535,6 +567,7 @@ export interface Host {
|
||||
address: string;
|
||||
allowInsecure: boolean;
|
||||
alpn: string[];
|
||||
cipherSuites: string;
|
||||
createdAt: number;
|
||||
echConfigList: string;
|
||||
excludeFromSubTypes: string[];
|
||||
@@ -571,6 +604,7 @@ export interface Host {
|
||||
export interface HostGroup {
|
||||
allowInsecure: boolean;
|
||||
alpn: string[];
|
||||
cipherSuites: string;
|
||||
echConfigList: string;
|
||||
excludeFromSubTypes: string[];
|
||||
finalMask: string;
|
||||
@@ -935,6 +969,23 @@ export interface Setting {
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface Sponsor {
|
||||
enable?: boolean | null;
|
||||
id: string;
|
||||
link: string;
|
||||
logo?: string;
|
||||
name: string;
|
||||
slots: string[];
|
||||
text: Record<string, string>;
|
||||
title: Record<string, string>;
|
||||
until: string;
|
||||
}
|
||||
|
||||
export interface SponsorList {
|
||||
contact?: string;
|
||||
sponsors: Sponsor[];
|
||||
}
|
||||
|
||||
export interface SubBalancer {
|
||||
createdAt: number;
|
||||
enabled: boolean;
|
||||
|
||||
@@ -12,6 +12,9 @@ export type ProcessState = z.infer<typeof ProcessStateSchema>;
|
||||
export const ProtocolSchema = z.string();
|
||||
export type Protocol = z.infer<typeof ProtocolSchema>;
|
||||
|
||||
export const addrFamilySchema = z.number().int();
|
||||
export type addrFamily = z.infer<typeof addrFamilySchema>;
|
||||
|
||||
export const staticEgressResolverSchema = z.string();
|
||||
export type staticEgressResolver = z.infer<typeof staticEgressResolverSchema>;
|
||||
|
||||
@@ -34,6 +37,7 @@ export const AllSettingSchema = z.object({
|
||||
discordMemory: z.number().int().min(0).max(100),
|
||||
discordRunTime: z.string(),
|
||||
expireDiff: z.number().int().min(0),
|
||||
externalSubUserAgent: z.string(),
|
||||
externalTrafficInformEnable: z.boolean(),
|
||||
externalTrafficInformURI: z.string(),
|
||||
happLinkEnable: z.boolean(),
|
||||
@@ -101,6 +105,7 @@ export const AllSettingSchema = z.object({
|
||||
subHappExcludeApns: z.boolean(),
|
||||
subHappExcludeRoutes: z.string(),
|
||||
subHappFallbackUrl: z.string(),
|
||||
subHappLocalProxyAuth: z.string(),
|
||||
subHappNewUrl: z.string(),
|
||||
subHappNoLimit: z.boolean(),
|
||||
subHappNotificationExpire: z.boolean(),
|
||||
@@ -136,6 +141,7 @@ export const AllSettingSchema = z.object({
|
||||
subListen: z.string(),
|
||||
subPath: z.string(),
|
||||
subPort: z.number().int().min(1).max(65535),
|
||||
subProfileMode: z.string(),
|
||||
subProfileUrl: z.string(),
|
||||
subRoutingRules: z.string(),
|
||||
subShowIdentityOnAllLinks: z.boolean(),
|
||||
@@ -184,6 +190,7 @@ export const AllSettingViewSchema = z.object({
|
||||
discordMemory: z.number().int().min(0).max(100),
|
||||
discordRunTime: z.string(),
|
||||
expireDiff: z.number().int().min(0),
|
||||
externalSubUserAgent: z.string(),
|
||||
externalTrafficInformEnable: z.boolean(),
|
||||
externalTrafficInformURI: z.string(),
|
||||
happLinkEnable: z.boolean(),
|
||||
@@ -259,6 +266,7 @@ export const AllSettingViewSchema = z.object({
|
||||
subHappExcludeApns: z.boolean(),
|
||||
subHappExcludeRoutes: z.string(),
|
||||
subHappFallbackUrl: z.string(),
|
||||
subHappLocalProxyAuth: z.string(),
|
||||
subHappNewUrl: z.string(),
|
||||
subHappNoLimit: z.boolean(),
|
||||
subHappNotificationExpire: z.boolean(),
|
||||
@@ -294,6 +302,7 @@ export const AllSettingViewSchema = z.object({
|
||||
subListen: z.string(),
|
||||
subPath: z.string(),
|
||||
subPort: z.number().int().min(1).max(65535),
|
||||
subProfileMode: z.string(),
|
||||
subProfileUrl: z.string(),
|
||||
subRoutingRules: z.string(),
|
||||
subShowIdentityOnAllLinks: z.boolean(),
|
||||
@@ -381,6 +390,7 @@ export const ClientSchema = z.object({
|
||||
reset: z.number().int(),
|
||||
resetDay: z.number().int(),
|
||||
resetMax: z.number().int(),
|
||||
resetWeekday: z.number().int(),
|
||||
reverse: z.lazy(() => ClientReverseSchema).nullable().optional(),
|
||||
secret: z.string().optional(),
|
||||
security: z.string(),
|
||||
@@ -435,6 +445,7 @@ export const ClientRecordSchema = z.object({
|
||||
reset: z.number().int(),
|
||||
resetDay: z.number().int(),
|
||||
resetMax: z.number().int(),
|
||||
resetWeekday: z.number().int(),
|
||||
reverse: z.unknown(),
|
||||
secret: z.string(),
|
||||
security: z.string(),
|
||||
@@ -448,6 +459,29 @@ export const ClientRecordSchema = z.object({
|
||||
});
|
||||
export type ClientRecord = z.infer<typeof ClientRecordSchema>;
|
||||
|
||||
export const ClientRenewalPreviewSchema = z.object({
|
||||
canRenew: z.boolean(),
|
||||
delayedStart: z.boolean(),
|
||||
nextExpiry: z.string(),
|
||||
renewAt: z.string(),
|
||||
renewals: z.number().int(),
|
||||
suggestedExpiry: z.string(),
|
||||
suggestedExpiryTime: z.number().int(),
|
||||
timeZone: z.string(),
|
||||
validThrough: z.string(),
|
||||
});
|
||||
export type ClientRenewalPreview = z.infer<typeof ClientRenewalPreviewSchema>;
|
||||
|
||||
export const ClientRenewalPreviewRequestSchema = z.object({
|
||||
expiryTime: z.number().int(),
|
||||
reset: z.number().int(),
|
||||
resetCount: z.number().int(),
|
||||
resetDay: z.number().int(),
|
||||
resetMax: z.number().int(),
|
||||
resetWeekday: z.number().int(),
|
||||
});
|
||||
export type ClientRenewalPreviewRequest = z.infer<typeof ClientRenewalPreviewRequestSchema>;
|
||||
|
||||
export const ClientReverseSchema = z.object({
|
||||
tag: z.string(),
|
||||
});
|
||||
@@ -466,6 +500,7 @@ export const ClientSlimSchema = z.object({
|
||||
reset: z.number().int(),
|
||||
resetDay: z.number().int(),
|
||||
resetMax: z.number().int(),
|
||||
resetWeekday: z.number().int(),
|
||||
subId: z.string(),
|
||||
totalGB: z.number().int(),
|
||||
traffic: z.lazy(() => ClientTrafficSchema).nullable().optional(),
|
||||
@@ -486,6 +521,7 @@ export const ClientTrafficSchema = z.object({
|
||||
resetCount: z.number().int(),
|
||||
resetDay: z.number().int(),
|
||||
resetMax: z.number().int(),
|
||||
resetWeekday: z.number().int(),
|
||||
subId: z.string(),
|
||||
total: z.number().int(),
|
||||
up: z.number().int(),
|
||||
@@ -571,6 +607,7 @@ export const HostSchema = z.object({
|
||||
address: z.string(),
|
||||
allowInsecure: z.boolean(),
|
||||
alpn: z.array(z.string()),
|
||||
cipherSuites: z.string(),
|
||||
createdAt: z.number().int(),
|
||||
echConfigList: z.string(),
|
||||
excludeFromSubTypes: z.array(z.string()),
|
||||
@@ -608,6 +645,7 @@ export type Host = z.infer<typeof HostSchema>;
|
||||
export const HostGroupSchema = z.object({
|
||||
allowInsecure: z.boolean(),
|
||||
alpn: z.array(z.string()),
|
||||
cipherSuites: z.string(),
|
||||
echConfigList: z.string(),
|
||||
excludeFromSubTypes: z.array(z.string()),
|
||||
finalMask: z.string(),
|
||||
@@ -994,6 +1032,25 @@ export const SettingSchema = z.object({
|
||||
});
|
||||
export type Setting = z.infer<typeof SettingSchema>;
|
||||
|
||||
export const SponsorSchema = z.object({
|
||||
enable: z.boolean().nullable().optional(),
|
||||
id: z.string(),
|
||||
link: z.string(),
|
||||
logo: z.string().optional(),
|
||||
name: z.string(),
|
||||
slots: z.array(z.string()),
|
||||
text: z.record(z.string(), z.string()),
|
||||
title: z.record(z.string(), z.string()),
|
||||
until: z.string(),
|
||||
});
|
||||
export type Sponsor = z.infer<typeof SponsorSchema>;
|
||||
|
||||
export const SponsorListSchema = z.object({
|
||||
contact: z.string().optional(),
|
||||
sponsors: z.array(z.lazy(() => SponsorSchema)),
|
||||
});
|
||||
export type SponsorList = z.infer<typeof SponsorListSchema>;
|
||||
|
||||
export const SubBalancerSchema = z.object({
|
||||
createdAt: z.number().int(),
|
||||
enabled: z.boolean(),
|
||||
|
||||
@@ -696,6 +696,7 @@ export function useClients(options: UseClientsOptions = {}) {
|
||||
tgId: Number(base.tgId) || 0,
|
||||
reset: Number(base.reset) || 0,
|
||||
resetDay: Number(base.resetDay) || 0,
|
||||
resetWeekday: Number(base.resetWeekday) || 0,
|
||||
resetMax: Number(base.resetMax) || 0,
|
||||
trafficReset: base.trafficReset || 'never',
|
||||
trafficResetDay: Number(base.trafficResetDay) || 1,
|
||||
|
||||
@@ -14,6 +14,7 @@ const TITLE_KEYS: Record<string, string> = {
|
||||
'/outbound': 'menu.outbounds',
|
||||
'/routing': 'menu.routing',
|
||||
'/api-docs': 'menu.apiDocs',
|
||||
'/sponsors': 'menu.sponsors',
|
||||
};
|
||||
|
||||
export function usePageTitle() {
|
||||
|
||||
@@ -15,6 +15,8 @@ function readBool(key: string, fallback: boolean): boolean {
|
||||
function applyDom(isDark: boolean, isUltra: boolean) {
|
||||
document.body.classList.remove('dark', 'light');
|
||||
document.body.classList.add(isDark ? 'dark' : 'light');
|
||||
// Native scrollbars read color-scheme, not the body class.
|
||||
document.documentElement.style.colorScheme = isDark ? 'dark' : 'light';
|
||||
if (isUltra) {
|
||||
document.documentElement.setAttribute('data-theme', 'ultra-dark');
|
||||
} else {
|
||||
|
||||
@@ -247,6 +247,10 @@
|
||||
padding: 8px 8px 12px;
|
||||
}
|
||||
|
||||
.sider-sponsor {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.sidebar-pin {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
CloudServerOutlined,
|
||||
ClusterOutlined,
|
||||
CodeOutlined,
|
||||
CrownOutlined,
|
||||
DashboardOutlined,
|
||||
DatabaseOutlined,
|
||||
DiscordOutlined,
|
||||
@@ -43,6 +44,7 @@ import { formatPanelVersion } from '@/lib/panel-version';
|
||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||
import { useAllSettings } from '@/api/queries/useAllSettings';
|
||||
import { useCommandPalette } from '@/components/command-palette/useCommandPalette';
|
||||
import SponsorSlot from '@/components/sponsor/SponsorSlot';
|
||||
import './AppSidebar.css';
|
||||
|
||||
const DONATE_URL = 'https://donate.sanaei.dev/';
|
||||
@@ -68,6 +70,7 @@ type IconName =
|
||||
| 'cluster'
|
||||
| 'hosts'
|
||||
| 'logout'
|
||||
| 'sponsors'
|
||||
| 'apidocs'
|
||||
| 'outbound'
|
||||
| 'routing';
|
||||
@@ -82,6 +85,7 @@ const iconByName: Record<IconName, ComponentType> = {
|
||||
cluster: ClusterOutlined,
|
||||
hosts: GlobalOutlined,
|
||||
logout: LogoutOutlined,
|
||||
sponsors: CrownOutlined,
|
||||
apidocs: ApiOutlined,
|
||||
outbound: ExportOutlined,
|
||||
routing: SwapOutlined,
|
||||
@@ -232,6 +236,7 @@ export default function AppSidebar() {
|
||||
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
|
||||
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
|
||||
{ key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
|
||||
{ key: '/sponsors', icon: 'sponsors', title: t('menu.sponsors') },
|
||||
{ key: LOGOUT_KEY, icon: 'logout', title: t('logout') },
|
||||
],
|
||||
[t],
|
||||
@@ -447,6 +452,13 @@ export default function AppSidebar() {
|
||||
onClick={onMenuClick}
|
||||
/>
|
||||
<div className="sider-footer">
|
||||
<SponsorSlot
|
||||
slot="sidebar"
|
||||
variant="compact"
|
||||
iconOnly={railCollapsed}
|
||||
rotate
|
||||
className="sider-sponsor"
|
||||
/>
|
||||
<VersionBadge version={panelVersion} collapsed={railCollapsed} />
|
||||
</div>
|
||||
</Layout.Sider>
|
||||
@@ -532,6 +544,7 @@ export default function AppSidebar() {
|
||||
}}
|
||||
/>
|
||||
<div className="drawer-footer">
|
||||
<SponsorSlot slot="sidebar" variant="compact" rotate className="sider-sponsor" />
|
||||
<VersionBadge version={panelVersion} />
|
||||
</div>
|
||||
</Drawer>
|
||||
|
||||
@@ -28,6 +28,9 @@ export function formatPanelVersion(version: string | undefined | null): string {
|
||||
|
||||
export function isPanelUpdateAvailable(latest: string, current: string): boolean {
|
||||
if (!latest || !current) return false;
|
||||
// A dev+<sha> label and a release tag sit on different channels and carry no
|
||||
// order, so a node moved to the other channel is not "behind" the master's latest.
|
||||
if (latest.trim().startsWith('dev+') !== current.trim().startsWith('dev+')) return false;
|
||||
const a = parseVersionParts(latest);
|
||||
const b = parseVersionParts(current);
|
||||
if (!a || !b) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Sponsor } from '@/generated/types';
|
||||
|
||||
export type SponsorSlot = 'dashboard' | 'sidebar' | 'page' | 'login';
|
||||
|
||||
const DISMISS_KEY = 'xui.sponsor.dismissed';
|
||||
export const SPONSOR_DISMISS_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export function pickLocale(map: Record<string, string> | undefined, lang: string): string {
|
||||
if (!map) return '';
|
||||
const short = lang.split('-')[0].toLowerCase();
|
||||
return map[lang] || map[short] || map.en || '';
|
||||
}
|
||||
|
||||
export function sponsorsForSlot(sponsors: Sponsor[], slot: SponsorSlot): Sponsor[] {
|
||||
return sponsors.filter((s) => s.slots.includes(slot));
|
||||
}
|
||||
|
||||
function readDismissed(): Record<string, number> {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(localStorage.getItem(DISMISS_KEY) || '{}');
|
||||
return parsed && typeof parsed === 'object' ? (parsed as Record<string, number>) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function isSponsorDismissed(id: string, slot: SponsorSlot, now = Date.now()): boolean {
|
||||
const at = readDismissed()[`${id}:${slot}`];
|
||||
return typeof at === 'number' && now - at < SPONSOR_DISMISS_MS;
|
||||
}
|
||||
|
||||
export function dismissSponsor(id: string, slot: SponsorSlot, now = Date.now()) {
|
||||
const next = Object.fromEntries(
|
||||
Object.entries(readDismissed()).filter(([, at]) => now - at < SPONSOR_DISMISS_MS),
|
||||
);
|
||||
next[`${id}:${slot}`] = now;
|
||||
try {
|
||||
localStorage.setItem(DISMISS_KEY, JSON.stringify(next));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Mirrors the backend: dashboard/login show one sponsor, the sidebar rotates up to three.
|
||||
export const SLOT_CAPACITY: Partial<Record<SponsorSlot, number>> = {
|
||||
dashboard: 1,
|
||||
login: 1,
|
||||
sidebar: 3,
|
||||
};
|
||||
|
||||
export interface PlacementStatus {
|
||||
count: number;
|
||||
capacity?: number;
|
||||
takenUntil?: string;
|
||||
}
|
||||
|
||||
// When full, a place frees up once enough bookings end to drop below capacity.
|
||||
export function placementStatus(sponsors: Sponsor[], slot: SponsorSlot): PlacementStatus {
|
||||
const booked = sponsorsForSlot(sponsors, slot);
|
||||
const capacity = SLOT_CAPACITY[slot];
|
||||
if (!capacity || booked.length < capacity) return { count: booked.length, capacity };
|
||||
const ends = booked.map((s) => s.until).sort((a, b) => Date.parse(a) - Date.parse(b));
|
||||
return { count: booked.length, capacity, takenUntil: ends[booked.length - capacity] };
|
||||
}
|
||||
@@ -786,7 +786,8 @@ function dnsRuleToWire(r: DnsRuleForm) {
|
||||
const result: Raw = { action };
|
||||
const qType = r.qType.trim();
|
||||
if (qType) {
|
||||
result.qType = /^\d+$/.test(qType) ? Number(qType) : qType;
|
||||
// The core reads a numeric 0 as no qType at all, which matches every query.
|
||||
result.qType = /^\d+$/.test(qType) && Number(qType) > 0 ? Number(qType) : qType;
|
||||
}
|
||||
const domains = r.domain
|
||||
.split(',')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ObjectUtil } from '@/utils';
|
||||
import type { SubProfileMode } from '@/schemas/setting';
|
||||
|
||||
export class AllSetting {
|
||||
webListen = '';
|
||||
@@ -46,6 +47,7 @@ export class AllSetting {
|
||||
subClashUserAgentRegex = '';
|
||||
subTitle = '';
|
||||
subSupportUrl = '';
|
||||
subProfileMode: SubProfileMode = 'none';
|
||||
subProfileUrl = '';
|
||||
subAnnounce = '';
|
||||
subEnableRouting = false;
|
||||
@@ -64,6 +66,7 @@ export class AllSetting {
|
||||
restartXrayOnClientDisable = true;
|
||||
subCertFile = '';
|
||||
subKeyFile = '';
|
||||
externalSubUserAgent = 'v2rayNG/1.8.5';
|
||||
subUpdates = 12;
|
||||
subEncrypt = true;
|
||||
subURI = '';
|
||||
@@ -102,6 +105,7 @@ export class AllSetting {
|
||||
subHappAutoConnectType = 'lowestdelay';
|
||||
subHappPerAppMode = 'off';
|
||||
subHappPerAppList = '';
|
||||
subHappLocalProxyAuth = 'auto';
|
||||
|
||||
timeLocation = 'Local';
|
||||
|
||||
@@ -167,6 +171,15 @@ export class AllSetting {
|
||||
if (data != null) {
|
||||
ObjectUtil.cloneProps(this, data);
|
||||
}
|
||||
// Legacy settings with a custom URL retain it until an explicit mode is saved.
|
||||
if (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
(!('subProfileMode' in data) || data.subProfileMode === undefined) &&
|
||||
this.subProfileUrl.trim() !== ''
|
||||
) {
|
||||
this.subProfileMode = 'custom';
|
||||
}
|
||||
const cpu = Math.round(Number(this.tgCpu));
|
||||
this.tgCpu = Number.isFinite(cpu) ? Math.min(100, Math.max(0, cpu)) : 80;
|
||||
const threshold = Math.round(Number(this.outboundDownThreshold));
|
||||
|
||||
@@ -45,15 +45,14 @@
|
||||
}
|
||||
|
||||
.api-docs-page .websocket-events {
|
||||
margin-bottom: 16px;
|
||||
padding: 20px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.api-docs-page .websocket-events h2 {
|
||||
margin-top: 0;
|
||||
.api-docs-page .swagger-ui .section-tabs {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.api-docs-page .websocket-events pre {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Card, Col, ConfigProvider, Layout, Row, Typography } from 'antd';
|
||||
import { Card, Col, ConfigProvider, Layout, Row, Tabs, Typography } from 'antd';
|
||||
import SwaggerUI from 'swagger-ui-react';
|
||||
import 'swagger-ui-react/swagger-ui.css';
|
||||
|
||||
@@ -14,6 +14,61 @@ const basePath = window.X_UI_BASE_PATH || '';
|
||||
const openApiUrl = `${basePath}panel/api/openapi.json`;
|
||||
const websocketEvents = buildWebSocketEvents(EXAMPLES);
|
||||
|
||||
interface TaggedOperations {
|
||||
keySeq: () => { first: () => string | undefined };
|
||||
filter: (keep: (operations: unknown, tag: string) => boolean) => TaggedOperations;
|
||||
}
|
||||
|
||||
interface LayoutSelectors {
|
||||
currentFilter: () => string | false;
|
||||
}
|
||||
|
||||
interface SectionTabsProps {
|
||||
specSelectors: { tags: () => { toJS: () => { name: string }[] } };
|
||||
layoutSelectors: LayoutSelectors;
|
||||
layoutActions: { updateFilter: (tag: string) => void };
|
||||
}
|
||||
|
||||
function SectionTabs({ specSelectors, layoutSelectors, layoutActions }: SectionTabsProps) {
|
||||
const tags = specSelectors
|
||||
.tags()
|
||||
.toJS()
|
||||
.map((tag) => tag.name);
|
||||
return (
|
||||
<div className="wrapper section-tabs">
|
||||
<Tabs
|
||||
size="small"
|
||||
activeKey={layoutSelectors.currentFilter() || tags[0]}
|
||||
onChange={layoutActions.updateFilter}
|
||||
items={tags.map((tag) => ({ key: tag, label: tag }))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Shows one tag at a time, the first until a tab is picked. Swagger's own filter is a
|
||||
// substring match ("Settings" would also show "Xray Settings") and no-op while unset.
|
||||
const sectionTabsPlugin = {
|
||||
statePlugins: {
|
||||
spec: {
|
||||
wrapSelectors: {
|
||||
taggedOperations:
|
||||
(
|
||||
select: (...args: unknown[]) => TaggedOperations,
|
||||
system: { getSystem: () => { layoutSelectors: LayoutSelectors } },
|
||||
) =>
|
||||
(...args: unknown[]) => {
|
||||
const operations = select(...args);
|
||||
const active =
|
||||
system.getSystem().layoutSelectors.currentFilter() || operations.keySeq().first();
|
||||
return operations.filter((_, tag) => tag === active);
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: { FilterContainer: SectionTabs },
|
||||
};
|
||||
|
||||
export default function ApiDocsPage() {
|
||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||
const { t } = useTranslation();
|
||||
@@ -32,36 +87,54 @@ export default function ApiDocsPage() {
|
||||
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content className="content-area">
|
||||
<section className="websocket-events" aria-labelledby="websocket-events-title">
|
||||
<Typography.Title id="websocket-events-title" level={2}>
|
||||
WebSocket events
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph>
|
||||
After the cookie-authenticated <Typography.Text code>GET /ws</Typography.Text>{' '}
|
||||
upgrade, every server message uses{' '}
|
||||
<Typography.Text code>{'{ type, payload, time }'}</Typography.Text>. The time value
|
||||
is Unix milliseconds.
|
||||
</Typography.Paragraph>
|
||||
<Row gutter={[12, 12]}>
|
||||
{websocketEvents.map((event) => (
|
||||
<Col key={event.type} xs={24} sm={12} xl={8}>
|
||||
<Card size="small" title={<Typography.Text code>{event.type}</Typography.Text>}>
|
||||
<Typography.Paragraph>{event.summary}</Typography.Paragraph>
|
||||
<pre>{JSON.stringify(event.example, null, 2)}</pre>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</section>
|
||||
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
|
||||
<SwaggerUI
|
||||
url={openApiUrl}
|
||||
docExpansion="list"
|
||||
deepLinking={false}
|
||||
tryItOutEnabled
|
||||
persistAuthorization
|
||||
/>
|
||||
</div>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'panel-api',
|
||||
label: '3X-UI Panel API',
|
||||
children: (
|
||||
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
|
||||
<SwaggerUI
|
||||
url={openApiUrl}
|
||||
docExpansion="list"
|
||||
deepLinking={false}
|
||||
plugins={[sectionTabsPlugin]}
|
||||
tryItOutEnabled
|
||||
persistAuthorization
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'websocket-events',
|
||||
label: 'WebSocket events',
|
||||
children: (
|
||||
<section className="websocket-events">
|
||||
<Typography.Paragraph>
|
||||
After the cookie-authenticated{' '}
|
||||
<Typography.Text code>GET /ws</Typography.Text> upgrade, every server
|
||||
message uses{' '}
|
||||
<Typography.Text code>{'{ type, payload, time }'}</Typography.Text>. The
|
||||
time value is Unix milliseconds.
|
||||
</Typography.Paragraph>
|
||||
<Row gutter={[12, 12]}>
|
||||
{websocketEvents.map((event) => (
|
||||
<Col key={event.type} xs={24} sm={12} xl={8}>
|
||||
<Card
|
||||
size="small"
|
||||
title={<Typography.Text code>{event.type}</Typography.Text>}
|
||||
>
|
||||
<Typography.Paragraph>{event.summary}</Typography.Paragraph>
|
||||
<pre>{JSON.stringify(event.example, null, 2)}</pre>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</section>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
|
||||
@@ -236,6 +236,13 @@ export const sections: readonly Section[] = [
|
||||
'Mint a CSRF token for the current session. The SPA replays it in the X-CSRF-Token header on unsafe requests. Bearer-token callers can skip this — the middleware short-circuits CSRF for authenticated API requests.',
|
||||
response: '{\n "success": true,\n "obj": "csrf-token-string"\n}',
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
path: '/sponsors',
|
||||
summary:
|
||||
'Public. Active paid sponsor placements read from the project sponsors.json (cached for 1h); expired entries are dropped. Logos are proxied by the panel at /sponsors/logo/{name}. Used by the login page and panel sponsor slots.',
|
||||
responseSchema: 'SponsorList',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/getTwoFactorEnable',
|
||||
@@ -1162,6 +1169,52 @@ export const sections: readonly Section[] = [
|
||||
body: '{\n "client": {\n "email": "alice@example.com",\n "totalGB": 53687091200,\n "expiryTime": 1735689600000,\n "tgId": 0,\n "limitIp": 0,\n "limitHwid": 0,\n "enable": true\n },\n "inboundIds": [3, 5]\n}',
|
||||
response: '{\n "success": true,\n "msg": "Client added"\n}',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/renewalPreview',
|
||||
summary: 'Preview client auto-renewal dates without saving or resetting anything.',
|
||||
description:
|
||||
'Uses the same calendar and catch-up calculation as auto-renew in the panel timezone. resetWeekday is 1 (Monday) to 7 (Sunday), 0 disables weekly mode; it cannot be combined with positive reset or resetDay. Existing resetDay takes precedence over reset. With expiryTime=0, calendar modes suggest a first cutoff but do not activate renewal. Negative expiryTime waits for first-use activation. resetMax and resetCount simulate the existing per-period allowance limit; the preview is informational and does not reserve an allowance or guarantee node availability.',
|
||||
params: [
|
||||
{
|
||||
name: 'expiryTime',
|
||||
in: 'body (json)',
|
||||
type: 'integer',
|
||||
desc: 'Current cutoff in Unix milliseconds; 0 unlimited, negative first-use duration.',
|
||||
},
|
||||
{
|
||||
name: 'reset',
|
||||
in: 'body (json)',
|
||||
type: 'integer',
|
||||
desc: 'Fixed interval in days; 0 disabled.',
|
||||
},
|
||||
{
|
||||
name: 'resetDay',
|
||||
in: 'body (json)',
|
||||
type: 'integer',
|
||||
desc: 'Monthly calendar day 1-31; 0 disabled.',
|
||||
},
|
||||
{
|
||||
name: 'resetWeekday',
|
||||
in: 'body (json)',
|
||||
type: 'integer',
|
||||
desc: 'Weekly calendar day 1-7 (Monday-Sunday); 0 disabled.',
|
||||
},
|
||||
{
|
||||
name: 'resetMax',
|
||||
in: 'body (json)',
|
||||
type: 'integer',
|
||||
desc: 'Maximum renewals; 0 unlimited.',
|
||||
},
|
||||
{
|
||||
name: 'resetCount',
|
||||
in: 'body (json)',
|
||||
type: 'integer',
|
||||
desc: 'Renewals already consumed; defaults to 0.',
|
||||
},
|
||||
],
|
||||
responseSchema: 'ClientRenewalPreview',
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
path: '/panel/api/clients/update/:email',
|
||||
|
||||
@@ -25,6 +25,7 @@ import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { useClients, type InboundOption } from '@/hooks/useClients';
|
||||
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
|
||||
import ClientRenewalFields from './ClientRenewalFields';
|
||||
import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client';
|
||||
|
||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||
@@ -57,6 +58,7 @@ const EMPTY: ClientBulkAddFormValues = {
|
||||
expiryTime: 0,
|
||||
reset: 0,
|
||||
resetDay: 0,
|
||||
resetWeekday: 0,
|
||||
resetMax: 0,
|
||||
trafficReset: 'never' as const,
|
||||
trafficResetDay: 1,
|
||||
@@ -215,6 +217,7 @@ export default function ClientBulkAddModal({
|
||||
expiryTime: current.expiryTime,
|
||||
reset: Number(current.reset) || 0,
|
||||
resetDay: Number(current.resetDay) || 0,
|
||||
resetWeekday: Number(current.resetWeekday) || 0,
|
||||
resetMax: Number(current.resetMax) || 0,
|
||||
trafficReset: current.trafficReset || 'never',
|
||||
trafficResetDay: Number(current.trafficResetDay) || 1,
|
||||
@@ -437,32 +440,13 @@ export default function ClientBulkAddModal({
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<FormField
|
||||
name="reset"
|
||||
label={t('pages.clients.renew')}
|
||||
tooltip={t('pages.clients.renewDesc')}
|
||||
transform={{ output: (v) => Number(v) || 0 }}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
name="resetDay"
|
||||
label={t('pages.clients.renewOnDay')}
|
||||
tooltip={t('pages.clients.renewOnDayDesc')}
|
||||
transform={{ output: (v) => Number(v) || 0 }}
|
||||
>
|
||||
<InputNumber min={0} max={31} />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
name="resetMax"
|
||||
label={t('pages.clients.renewMax')}
|
||||
tooltip={t('pages.clients.renewMaxDesc')}
|
||||
transform={{ output: (v) => Number(v) || 0 }}
|
||||
>
|
||||
<InputNumber min={0} />
|
||||
</FormField>
|
||||
<ClientRenewalFields
|
||||
active={open}
|
||||
delayedStart={delayedStart}
|
||||
expiryTime={expiryTime}
|
||||
bulk
|
||||
setExpiry={(expiry) => methods.setValue('expiryTime', expiry)}
|
||||
/>
|
||||
|
||||
<FormField name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
|
||||
<Select
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
/* The body is capped at the viewport and scrolls; a trailing item margin
|
||||
alone must not push it past the cap and summon a scrollbar. */
|
||||
.client-form-modal .ant-tabs-content > .ant-form-item:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
@@ -48,7 +48,9 @@ import type {
|
||||
ExternalLinkInput,
|
||||
} from '@/hooks/useClients';
|
||||
import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
|
||||
import ClientRenewalFields from './ClientRenewalFields';
|
||||
import { ClientFormSchema, ClientCreateFormSchema, type ClientFormValues } from '@/schemas/client';
|
||||
import './ClientFormModal.css';
|
||||
|
||||
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
|
||||
const VMESS_SECURITY_OPTIONS = ['auto', 'aes-128-gcm', 'chacha20-poly1305'] as const;
|
||||
@@ -154,6 +156,7 @@ const EMPTY: Values = {
|
||||
delayedDays: 0,
|
||||
reset: 0,
|
||||
resetDay: 0,
|
||||
resetWeekday: 0,
|
||||
resetMax: 0,
|
||||
trafficReset: 'never' as const,
|
||||
trafficResetDay: 1,
|
||||
@@ -258,6 +261,7 @@ export default function ClientFormModal({
|
||||
const methods = useForm<Values>({ defaultValues: EMPTY });
|
||||
const inboundIds = useWatch({ control: methods.control, name: 'inboundIds' });
|
||||
const delayedStart = useWatch({ control: methods.control, name: 'delayedStart' });
|
||||
const delayedDays = useWatch({ control: methods.control, name: 'delayedDays' });
|
||||
const expiryDate = useWatch({ control: methods.control, name: 'expiryDate' });
|
||||
const enable = useWatch({ control: methods.control, name: 'enable' });
|
||||
const flow = useWatch({ control: methods.control, name: 'flow' });
|
||||
@@ -365,6 +369,7 @@ export default function ClientFormModal({
|
||||
totalGB: bytesToGB(client.totalGB || 0),
|
||||
reset: Number(client.reset) || 0,
|
||||
resetDay: Number(client.resetDay) || 0,
|
||||
resetWeekday: Number(client.resetWeekday) || 0,
|
||||
resetMax: Number(client.resetMax) || 0,
|
||||
trafficReset: (client.trafficReset as ClientFormValues['trafficReset']) || 'never',
|
||||
trafficResetDay: Number(client.trafficResetDay) || 1,
|
||||
@@ -662,6 +667,7 @@ export default function ClientFormModal({
|
||||
delayedDays: values.delayedDays,
|
||||
reset: values.reset,
|
||||
resetDay: values.resetDay,
|
||||
resetWeekday: values.resetWeekday,
|
||||
resetMax: values.resetMax,
|
||||
trafficReset: values.trafficReset,
|
||||
trafficResetDay: values.trafficResetDay,
|
||||
@@ -695,6 +701,7 @@ export default function ClientFormModal({
|
||||
expiryTime,
|
||||
reset: Number(values.reset) || 0,
|
||||
resetDay: Number(values.resetDay) || 0,
|
||||
resetWeekday: Number(values.resetWeekday) || 0,
|
||||
resetMax: Number(values.resetMax) || 0,
|
||||
trafficReset: values.trafficReset || 'never',
|
||||
trafficResetDay: Number(values.trafficResetDay) || 1,
|
||||
@@ -803,6 +810,7 @@ export default function ClientFormModal({
|
||||
open={open}
|
||||
title={isEdit ? t('pages.clients.editClient') : t('pages.clients.addClient')}
|
||||
destroyOnHidden
|
||||
className="client-form-modal"
|
||||
width={720}
|
||||
zIndex={CLIENT_FORM_MODAL_Z_INDEX}
|
||||
style={{ top: 20 }}
|
||||
@@ -983,35 +991,16 @@ export default function ClientFormModal({
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<FormField
|
||||
name="reset"
|
||||
label={t('pages.clients.renewDays')}
|
||||
tooltip={t('pages.clients.renewDesc')}
|
||||
transform={{ output: (v) => Number(v) || 0 }}
|
||||
>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<FormField
|
||||
name="resetDay"
|
||||
label={t('pages.clients.renewOnDay')}
|
||||
tooltip={t('pages.clients.renewOnDayDesc')}
|
||||
transform={{ output: (v) => Number(v) || 0 }}
|
||||
>
|
||||
<InputNumber min={0} max={31} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<FormField
|
||||
name="resetMax"
|
||||
label={t('pages.clients.renewMax')}
|
||||
tooltip={t('pages.clients.renewMaxDesc')}
|
||||
transform={{ output: (v) => Number(v) || 0 }}
|
||||
>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
<Col xs={24}>
|
||||
<ClientRenewalFields
|
||||
active={open}
|
||||
delayedStart={delayedStart}
|
||||
expiryTime={
|
||||
delayedStart ? -86400000 * (delayedDays || 0) : expiryDate || 0
|
||||
}
|
||||
resetCount={client?.traffic?.resetCount || 0}
|
||||
setExpiry={(expiry) => methods.setValue('expiryDate', expiry)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<FormField
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import { useEffect, useId, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button, Form, InputNumber, Select, Space, Typography } from 'antd';
|
||||
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import { ClientRenewalPreviewSchema } from '@/generated/zod';
|
||||
import { HttpUtil } from '@/utils';
|
||||
import type { ClientFormValues } from '@/schemas/client';
|
||||
|
||||
type RenewalFields = Pick<ClientFormValues, 'reset' | 'resetDay' | 'resetWeekday' | 'resetMax'>;
|
||||
type RenewalMode = 'none' | 'interval' | 'weekly' | 'monthly';
|
||||
|
||||
export default function ClientRenewalFields({
|
||||
active,
|
||||
expiryTime,
|
||||
resetCount = 0,
|
||||
bulk = false,
|
||||
delayedStart = false,
|
||||
setExpiry,
|
||||
}: {
|
||||
active: boolean;
|
||||
expiryTime: number;
|
||||
resetCount?: number;
|
||||
bulk?: boolean;
|
||||
delayedStart?: boolean;
|
||||
setExpiry: (expiry: number) => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const formId = useId();
|
||||
const modeId = 'client-renewal-mode-' + formId;
|
||||
const { control, setValue } = useFormContext<RenewalFields>();
|
||||
const [reset, resetDay, resetWeekday, resetMax] = useWatch({
|
||||
control,
|
||||
name: ['reset', 'resetDay', 'resetWeekday', 'resetMax'],
|
||||
});
|
||||
const mode: RenewalMode =
|
||||
resetDay > 0 ? 'monthly' : resetWeekday > 0 ? 'weekly' : reset > 0 ? 'interval' : 'none';
|
||||
const request = useMemo(
|
||||
() => ({
|
||||
expiryTime,
|
||||
reset: reset || 0,
|
||||
resetDay: resetDay || 0,
|
||||
resetWeekday: resetWeekday || 0,
|
||||
resetMax: resetMax || 0,
|
||||
resetCount,
|
||||
}),
|
||||
[expiryTime, reset, resetDay, resetWeekday, resetMax, resetCount],
|
||||
);
|
||||
const [debounced, setDebounced] = useState(request);
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebounced(request), 250);
|
||||
return () => clearTimeout(timer);
|
||||
}, [request]);
|
||||
const query = useQuery({
|
||||
queryKey: ['clients', 'renewalPreview', debounced],
|
||||
enabled: active && mode !== 'none' && request === debounced,
|
||||
retry: false,
|
||||
queryFn: async () => {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/renewalPreview', debounced, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
silent: true,
|
||||
});
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Renewal preview failed');
|
||||
return ClientRenewalPreviewSchema.parse(msg.obj);
|
||||
},
|
||||
});
|
||||
const preview = request === debounced ? query.data : undefined;
|
||||
const weekdayFormatter = new Intl.DateTimeFormat(i18n.language, {
|
||||
weekday: 'long',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
function changeMode(next: RenewalMode) {
|
||||
setValue('reset', next === 'interval' ? Math.max(1, reset || 0) : 0);
|
||||
setValue('resetDay', next === 'monthly' ? Math.max(1, resetDay || 0) : 0);
|
||||
setValue('resetWeekday', next === 'weekly' ? Math.max(1, resetWeekday || 0) : 0);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Form.Item label={t('pages.clients.renewMode')} htmlFor={modeId}>
|
||||
<Select
|
||||
id={modeId}
|
||||
value={mode}
|
||||
onChange={changeMode}
|
||||
options={[
|
||||
{ value: 'none', label: t('pages.clients.renewModeNone') },
|
||||
{ value: 'interval', label: t('pages.clients.renewModeInterval') },
|
||||
{ value: 'weekly', label: t('pages.clients.renewModeWeekly') },
|
||||
{ value: 'monthly', label: t('pages.clients.renewModeMonthly') },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{mode === 'interval' && (
|
||||
<FormField
|
||||
name="reset"
|
||||
label={bulk ? t('pages.clients.renew') : t('pages.clients.renewDays')}
|
||||
tooltip={t('pages.clients.renewDesc')}
|
||||
transform={{ output: (v) => Number(v) || 1 }}
|
||||
>
|
||||
<InputNumber id={'client-renewal-interval-' + formId} min={1} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
)}
|
||||
{mode === 'monthly' && (
|
||||
<FormField
|
||||
name="resetDay"
|
||||
label={t('pages.clients.renewOnDay')}
|
||||
tooltip={t('pages.clients.renewOnDayDesc')}
|
||||
transform={{ output: (v) => Number(v) || 1 }}
|
||||
>
|
||||
<InputNumber
|
||||
id={'client-renewal-day-' + formId}
|
||||
min={1}
|
||||
max={31}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
{mode === 'weekly' && (
|
||||
<FormField name="resetWeekday" label={t('pages.clients.renewWeekday')}>
|
||||
<Select
|
||||
id={'client-renewal-weekday-' + formId}
|
||||
options={Array.from({ length: 7 }, (_, i) => ({
|
||||
value: i + 1,
|
||||
label: weekdayFormatter.format(new Date(Date.UTC(2026, 0, i + 5))),
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
{mode !== 'none' && (
|
||||
<>
|
||||
<FormField
|
||||
name="resetMax"
|
||||
label={t('pages.clients.renewMax')}
|
||||
tooltip={t('pages.clients.renewMaxDesc')}
|
||||
transform={{ output: (v) => Number(v) || 0 }}
|
||||
>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
<Typography.Paragraph type="secondary">
|
||||
{t('pages.clients.renewScheduleDesc')}
|
||||
</Typography.Paragraph>
|
||||
{query.isError && request === debounced && (
|
||||
<Typography.Paragraph type="warning">
|
||||
{t('pages.clients.renewPreviewError')}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
{preview && (
|
||||
<Space orientation="vertical" size={4} style={{ marginBottom: 16 }}>
|
||||
<Typography.Text>
|
||||
{t('pages.clients.renewPreview', { zone: preview.timeZone })}
|
||||
</Typography.Text>
|
||||
{delayedStart || preview.delayedStart ? (
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.clients.renewFirstUse')}
|
||||
</Typography.Text>
|
||||
) : expiryTime === 0 ? (
|
||||
<>
|
||||
<Typography.Text type="warning">
|
||||
{t('pages.clients.renewNeedsExpiry')}
|
||||
</Typography.Text>
|
||||
{preview.suggestedExpiryTime > 0 && (
|
||||
<Button onClick={() => setExpiry(preview.suggestedExpiryTime)}>
|
||||
{t('pages.clients.renewSetExpiry')}: {preview.suggestedExpiry}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Typography.Text>
|
||||
{t('pages.clients.renewAt')}: {preview.renewAt}
|
||||
</Typography.Text>
|
||||
<Typography.Text>
|
||||
{t('pages.clients.renewValidThrough')}: {preview.validThrough}
|
||||
</Typography.Text>
|
||||
{preview.nextExpiry && (
|
||||
<Typography.Text>
|
||||
{t('pages.clients.renewNextExpiry')}: {preview.nextExpiry}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Typography.Text>
|
||||
{t('pages.clients.renewPeriods', { count: preview.renewals })}
|
||||
</Typography.Text>
|
||||
{!preview.canRenew && (
|
||||
<Typography.Text type="warning">
|
||||
{t('pages.clients.renewUnavailable')}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -61,6 +61,23 @@
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.summary-stat {
|
||||
margin: -4px -8px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
|
||||
.summary-stat:hover,
|
||||
.summary-stat:focus-visible {
|
||||
background: var(--ant-color-fill-tertiary);
|
||||
}
|
||||
|
||||
.summary-stat.selected {
|
||||
background: var(--ant-color-primary-bg);
|
||||
}
|
||||
|
||||
.dot {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useLocation, useSearchParams } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -153,6 +154,40 @@ function ClientEmailList({ emails, total }: { emails: string[]; total: number })
|
||||
);
|
||||
}
|
||||
|
||||
interface SummaryStatProps {
|
||||
title: string;
|
||||
value: number;
|
||||
prefix: ReactNode;
|
||||
emails?: string[];
|
||||
selected?: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
function SummaryStat({ title, value, prefix, emails, selected, onSelect }: SummaryStatProps) {
|
||||
const stat = (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={selected}
|
||||
className={selected ? 'summary-stat selected' : 'summary-stat'}
|
||||
onClick={onSelect}
|
||||
onKeyDown={activateOnKey(onSelect)}
|
||||
>
|
||||
<Statistic title={title} value={String(value)} prefix={prefix} />
|
||||
</div>
|
||||
);
|
||||
if (!emails) return stat;
|
||||
return (
|
||||
<Popover
|
||||
title={title}
|
||||
open={value ? undefined : false}
|
||||
content={<ClientEmailList emails={emails} total={value} />}
|
||||
>
|
||||
{stat}
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
|
||||
|
||||
interface PersistedFilterState {
|
||||
@@ -1224,6 +1259,15 @@ export default function ClientsPage() {
|
||||
const someSelected =
|
||||
selectedRowKeys.length > 0 && selectedRowKeys.length < filteredClients.length;
|
||||
|
||||
const isOnlyBucket = (bucket: string) =>
|
||||
filters.buckets.length === 1 && filters.buckets[0] === bucket;
|
||||
|
||||
// Clicking the card that is already the sole status filter clears it again.
|
||||
function selectBucket(bucket: string | null) {
|
||||
const buckets = bucket && !isOnlyBucket(bucket) ? [bucket] : [];
|
||||
setFilters({ ...filters, buckets });
|
||||
}
|
||||
|
||||
function clearOneFilter<K extends keyof ClientFilters>(key: K) {
|
||||
if (key === 'expiryFrom' || key === 'expiryTo') {
|
||||
setFilters({ ...filters, expiryFrom: undefined, expiryTo: undefined });
|
||||
@@ -1265,89 +1309,60 @@ export default function ClientsPage() {
|
||||
<Card size="small" hoverable className="summary-card">
|
||||
<Row gutter={[16, 12]}>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Statistic
|
||||
<SummaryStat
|
||||
title={t('clients')}
|
||||
value={String(summary.total)}
|
||||
value={summary.total}
|
||||
prefix={<TeamOutlined />}
|
||||
onSelect={() => selectBucket(null)}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Popover
|
||||
<SummaryStat
|
||||
title={t('online')}
|
||||
open={summary.onlineCount ? undefined : false}
|
||||
content={
|
||||
<ClientEmailList
|
||||
emails={summary.online}
|
||||
total={summary.onlineCount}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Statistic
|
||||
title={t('online')}
|
||||
value={String(summary.onlineCount)}
|
||||
prefix={<span className="dot dot-blue" />}
|
||||
/>
|
||||
</Popover>
|
||||
value={summary.onlineCount}
|
||||
emails={summary.online}
|
||||
prefix={<span className="dot dot-blue" />}
|
||||
selected={isOnlyBucket('online')}
|
||||
onSelect={() => selectBucket('online')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Popover
|
||||
<SummaryStat
|
||||
title={t('depleted')}
|
||||
open={summary.depletedCount ? undefined : false}
|
||||
content={
|
||||
<ClientEmailList
|
||||
emails={summary.depleted}
|
||||
total={summary.depletedCount}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Statistic
|
||||
title={t('depleted')}
|
||||
value={String(summary.depletedCount)}
|
||||
prefix={<span className="dot dot-red" />}
|
||||
/>
|
||||
</Popover>
|
||||
value={summary.depletedCount}
|
||||
emails={summary.depleted}
|
||||
prefix={<span className="dot dot-red" />}
|
||||
selected={isOnlyBucket('depleted')}
|
||||
onSelect={() => selectBucket('depleted')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Popover
|
||||
<SummaryStat
|
||||
title={t('depletingSoon')}
|
||||
open={summary.expiringCount ? undefined : false}
|
||||
content={
|
||||
<ClientEmailList
|
||||
emails={summary.expiring}
|
||||
total={summary.expiringCount}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Statistic
|
||||
title={t('depletingSoon')}
|
||||
value={String(summary.expiringCount)}
|
||||
prefix={<span className="dot dot-orange" />}
|
||||
/>
|
||||
</Popover>
|
||||
value={summary.expiringCount}
|
||||
emails={summary.expiring}
|
||||
prefix={<span className="dot dot-orange" />}
|
||||
selected={isOnlyBucket('expiring')}
|
||||
onSelect={() => selectBucket('expiring')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Popover
|
||||
<SummaryStat
|
||||
title={t('disabled')}
|
||||
open={summary.deactiveCount ? undefined : false}
|
||||
content={
|
||||
<ClientEmailList
|
||||
emails={summary.deactive}
|
||||
total={summary.deactiveCount}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Statistic
|
||||
title={t('disabled')}
|
||||
value={String(summary.deactiveCount)}
|
||||
prefix={<span className="dot dot-gray" />}
|
||||
/>
|
||||
</Popover>
|
||||
value={summary.deactiveCount}
|
||||
emails={summary.deactive}
|
||||
prefix={<span className="dot dot-gray" />}
|
||||
selected={isOnlyBucket('deactive')}
|
||||
onSelect={() => selectBucket('deactive')}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} sm={8} md={4}>
|
||||
<Statistic
|
||||
<SummaryStat
|
||||
title={t('subscription.active')}
|
||||
value={String(summary.active)}
|
||||
value={summary.active}
|
||||
prefix={<span className="dot dot-green" />}
|
||||
selected={isOnlyBucket('active')}
|
||||
onSelect={() => selectBucket('active')}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
@@ -17,6 +17,7 @@ import type { HostRecord } from '@/api/queries/useHostsQuery';
|
||||
import { BulkAddHostSchema, type BulkAddHostValues } from '@/schemas/api/host';
|
||||
import type { InboundOption } from '@/schemas/client';
|
||||
import { ALPN_OPTION, UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||
import { CipherSuitesSelect } from '@/components/form';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import { useNodesQuery } from '@/api/queries/useNodesQuery';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
@@ -56,6 +57,7 @@ function defaultsFor(host: HostRecord | null): FormShape {
|
||||
path: host?.path ?? '',
|
||||
alpn: (host?.alpn as BulkAddHostValues['alpn']) ?? [],
|
||||
fingerprint: host?.fingerprint as BulkAddHostValues['fingerprint'],
|
||||
cipherSuites: host?.cipherSuites ?? '',
|
||||
overrideSniFromAddress: host?.overrideSniFromAddress ?? false,
|
||||
keepSniBlank: host?.keepSniBlank ?? false,
|
||||
pinnedPeerCertSha256: host?.pinnedPeerCertSha256 ?? [],
|
||||
@@ -332,6 +334,12 @@ export default function HostFormModal({
|
||||
<FormField name="alpn" label={t('pages.hosts.fields.alpn')}>
|
||||
<Select mode="multiple" allowClear options={alpnOptions} />
|
||||
</FormField>
|
||||
<FormField
|
||||
name="cipherSuites"
|
||||
label={t('pages.inbounds.form.cipherSuites')}
|
||||
>
|
||||
<CipherSuitesSelect />
|
||||
</FormField>
|
||||
<FormField name="pinnedPeerCertSha256" label={t('pages.hosts.fields.pins')}>
|
||||
<Select mode="tags" allowClear tokenSeparators={[',']} />
|
||||
</FormField>
|
||||
|
||||
@@ -103,13 +103,13 @@ export default function AmneziawgFields({
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
<FormField name={['settings', 'server', 's1']} label={t('pages.xray.amneziawg.s1')}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
<InputNumber min={0} max={1552} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
<FormField name={['settings', 'server', 's2']} label={t('pages.xray.amneziawg.s2')}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
<InputNumber min={0} max={1608} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
<FormField name={['settings', 'server', 's3']} label={t('pages.xray.amneziawg.s3')}>
|
||||
<InputNumber min={0} max={64} style={{ width: '100%' }} />
|
||||
<InputNumber min={0} max={1636} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
<FormField name={['settings', 'server', 's4']} label={t('pages.xray.amneziawg.s4')}>
|
||||
<InputNumber min={0} max={32} style={{ width: '100%' }} />
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
|
||||
|
||||
import { CipherSuitesSelect } from '@/components/form';
|
||||
import { FormField } from '@/components/form/rhf';
|
||||
import {
|
||||
ALPN_OPTION,
|
||||
DOMAIN_STRATEGY_OPTION,
|
||||
TLS_CIPHER_OPTION,
|
||||
TLS_VERSION_OPTION,
|
||||
USAGE_OPTION,
|
||||
UTLS_FINGERPRINT,
|
||||
@@ -240,12 +240,7 @@ export default function TlsForm({
|
||||
name={['streamSettings', 'tlsSettings', 'cipherSuites']}
|
||||
label={t('pages.inbounds.form.cipherSuites')}
|
||||
>
|
||||
<Select
|
||||
options={[
|
||||
{ value: '', label: t('pages.inbounds.form.autoOption') },
|
||||
...Object.entries(TLS_CIPHER_OPTION).map(([k, v]) => ({ value: v, label: k })),
|
||||
]}
|
||||
/>
|
||||
<CipherSuitesSelect placeholder={t('pages.inbounds.form.autoOption')} />
|
||||
</FormField>
|
||||
<Form.Item label={t('pages.inbounds.form.minMaxVersion')}>
|
||||
<Space.Compact block>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
.qr-panel-canvas .qr-code {
|
||||
cursor: pointer;
|
||||
background: #fff;
|
||||
border-radius: 4px;
|
||||
border-radius: 8px;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ export default function QrPanel({
|
||||
value={value}
|
||||
size={size}
|
||||
errorLevel="L"
|
||||
marginSize={4}
|
||||
marginSize={2}
|
||||
type="svg"
|
||||
bordered={false}
|
||||
color="#000000"
|
||||
|
||||
@@ -119,6 +119,18 @@ function toGuidOnlineMap(data: Record<string, string[]>): Map<string, Set<string
|
||||
return map;
|
||||
}
|
||||
|
||||
// Most pushes repeat the previous online sets; handing back a new Map anyway
|
||||
// re-ran the client rollup over every inbound on each traffic event.
|
||||
function sameGuidSets(a: Map<string, Set<string>>, b: Map<string, Set<string>>): boolean {
|
||||
if (a.size !== b.size) return false;
|
||||
for (const [key, set] of b) {
|
||||
const prev = a.get(key);
|
||||
if (!prev || prev.size !== set.size) return false;
|
||||
for (const value of set) if (!prev.has(value)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function fetchLastOnlineMap(): Promise<Record<string, number>> {
|
||||
const msg = await HttpUtil.post('/panel/api/clients/lastOnline', undefined, { silent: true });
|
||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch lastOnline');
|
||||
@@ -440,10 +452,12 @@ export function useInbounds() {
|
||||
setOnlineClients(p.onlineClients);
|
||||
}
|
||||
if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
|
||||
setOnlineByGuid(toGuidOnlineMap(p.onlineByGuid));
|
||||
const next = toGuidOnlineMap(p.onlineByGuid);
|
||||
setOnlineByGuid((prev) => (sameGuidSets(prev, next) ? prev : next));
|
||||
}
|
||||
if (p.activeInbounds && typeof p.activeInbounds === 'object') {
|
||||
setActiveByGuid(toGuidOnlineMap(p.activeInbounds));
|
||||
const next = toGuidOnlineMap(p.activeInbounds);
|
||||
setActiveByGuid((prev) => (sameGuidSets(prev, next) ? prev : next));
|
||||
}
|
||||
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
|
||||
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
|
||||
@@ -537,8 +551,7 @@ export function useInbounds() {
|
||||
? stats.map((stat) => {
|
||||
const su = byEmail.get(stat.email);
|
||||
if (!su) return stat;
|
||||
statsTouched = true;
|
||||
return {
|
||||
const merged = {
|
||||
...stat,
|
||||
up: typeof su.up === 'number' ? su.up : stat.up,
|
||||
down: typeof su.down === 'number' ? su.down : stat.down,
|
||||
@@ -546,9 +559,27 @@ export function useInbounds() {
|
||||
expiryTime: typeof su.expiryTime === 'number' ? su.expiryTime : stat.expiryTime,
|
||||
enable: typeof su.enable === 'boolean' ? su.enable : stat.enable,
|
||||
} as ClientStats;
|
||||
if (
|
||||
merged.up === stat.up &&
|
||||
merged.down === stat.down &&
|
||||
merged.total === stat.total &&
|
||||
merged.expiryTime === stat.expiryTime &&
|
||||
merged.enable === stat.enable
|
||||
) {
|
||||
return stat;
|
||||
}
|
||||
statsTouched = true;
|
||||
return merged;
|
||||
})
|
||||
: null;
|
||||
if (!upd && !statsTouched) return ib;
|
||||
// Every push lists all inbounds' totals, so only a row whose numbers moved counts.
|
||||
const inboundMoved =
|
||||
!!upd &&
|
||||
((typeof upd.up === 'number' && upd.up !== ib.up) ||
|
||||
(typeof upd.down === 'number' && upd.down !== ib.down) ||
|
||||
(typeof upd.total === 'number' && upd.total !== ib.total) ||
|
||||
(typeof upd.enable === 'boolean' && upd.enable !== ib.enable));
|
||||
if (!inboundMoved && !statsTouched) return ib;
|
||||
touched = true;
|
||||
const row = new DBInbound(ib as DBInboundInit) as DBInboundInstance;
|
||||
if (upd) {
|
||||
|
||||
@@ -122,7 +122,7 @@ export default function AmneziaWGLogModal({ open, onClose }: AmneziaWGLogModalPr
|
||||
<Select
|
||||
value={rows}
|
||||
size="small"
|
||||
style={{ width: 70 }}
|
||||
style={{ width: 100 }}
|
||||
onChange={setRows}
|
||||
options={[
|
||||
{ value: '20', label: '20' },
|
||||
|
||||
@@ -22,6 +22,7 @@ import { useStatusQuery } from '@/api/queries/useStatusQuery';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import AppSidebar from '@/layouts/AppSidebar';
|
||||
import { LazyMount } from '@/components/utility';
|
||||
import SponsorSlot from '@/components/sponsor/SponsorSlot';
|
||||
import { setMessageInstance } from '@/utils/messageBus';
|
||||
import OverviewActionBar from './OverviewActionBar';
|
||||
import VitalTile from './VitalTile';
|
||||
@@ -213,6 +214,8 @@ export default function IndexPage() {
|
||||
onOpenVersionSwitch={() => setVersionOpen(true)}
|
||||
/>
|
||||
|
||||
<SponsorSlot slot="dashboard" />
|
||||
|
||||
{health && (
|
||||
<div className="ov-health" style={{ color: health.color }}>
|
||||
<span className="ov-health-mark" />
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
|
||||
<Select
|
||||
value={rows}
|
||||
size="small"
|
||||
style={{ width: 70 }}
|
||||
style={{ width: 100 }}
|
||||
onChange={setRows}
|
||||
options={[
|
||||
{ value: '20', label: '20' },
|
||||
|
||||
@@ -159,7 +159,7 @@ export default function OverviewActionBar({
|
||||
|
||||
return (
|
||||
<div className="ov-bar">
|
||||
{status.xray.state === 'error' && status.xray.errorMsg ? (
|
||||
{status.xray.errorMsg ? (
|
||||
<Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
|
||||
{statePill}
|
||||
</Tooltip>
|
||||
@@ -167,6 +167,12 @@ export default function OverviewActionBar({
|
||||
statePill
|
||||
)}
|
||||
|
||||
{status.xray.state === 'running' && status.xray.errorMsg ? (
|
||||
<Tooltip title={<span className="ov-error-detail">{status.xray.errorMsg}</span>}>
|
||||
<Tag color="error">{t('pages.index.xrayStatusError')}</Tag>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
|
||||
{updateAvailable ? (
|
||||
<Tag
|
||||
className="ov-update-tag"
|
||||
|
||||
@@ -175,7 +175,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
|
||||
<Select
|
||||
value={rows}
|
||||
size="small"
|
||||
style={{ width: 70 }}
|
||||
style={{ width: 100 }}
|
||||
onChange={setRows}
|
||||
options={[
|
||||
{ value: '20', label: '20' },
|
||||
|
||||
@@ -411,3 +411,7 @@
|
||||
.submit-row {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.login-sponsor {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { HttpUtil, LanguageManager } from '@/utils';
|
||||
import { FormField, rhfZodValidate } from '@/components/form/rhf';
|
||||
import { setMessageInstance } from '@/utils/messageBus';
|
||||
import SponsorSlot from '@/components/sponsor/SponsorSlot';
|
||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||
import { LoginFormSchema, TwoFactorCodeSchema, type LoginFormValues } from '@/schemas/login';
|
||||
import './LoginPage.css';
|
||||
@@ -247,6 +248,7 @@ export default function LoginPage() {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</FormProvider>
|
||||
<SponsorSlot slot="login" variant="compact" className="login-sponsor" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -25,6 +25,8 @@ interface ApiMsg<T = unknown> {
|
||||
|
||||
const REFRESH_MS = 15000;
|
||||
|
||||
const formatKbps = (v: number) => v.toLocaleString(undefined, { maximumFractionDigits: 1 });
|
||||
|
||||
export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [cpuPoints, setCpuPoints] = useState<number[]>([]);
|
||||
@@ -51,7 +53,7 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
|
||||
};
|
||||
|
||||
// cpu/mem are percentages (clamp 0-100); net throughput is bytes/sec shown
|
||||
// as KB/s (no upper clamp, the sparkline auto-scales).
|
||||
// as KB/s, which must opt out of Sparkline's 0-100 "%" defaults.
|
||||
const fetchSeries = async (metric: string, kind: 'pct' | 'rate') => {
|
||||
try {
|
||||
const url = `/panel/api/nodes/history/${node.id}/${metric}/${bucket}`;
|
||||
@@ -148,6 +150,8 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
|
||||
fillOpacity={0.18}
|
||||
markerRadius={2.6}
|
||||
showTooltip
|
||||
valueMax={null}
|
||||
yFormatter={formatKbps}
|
||||
/>
|
||||
</div>
|
||||
<div className="series">
|
||||
@@ -164,6 +168,8 @@ export default function NodeHistoryPanel({ node, bucket = 30 }: NodeHistoryPanel
|
||||
fillOpacity={0.18}
|
||||
markerRadius={2.6}
|
||||
showTooltip
|
||||
valueMax={null}
|
||||
yFormatter={formatKbps}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -145,17 +145,22 @@ function formatUptime(secs?: number): string {
|
||||
return `${mins}m`;
|
||||
}
|
||||
|
||||
// Stable per language: the columns memo depends on it, and a fresh function each
|
||||
// render rebuilt every column, re-rendering all rows on each heartbeat push.
|
||||
function useRelativeTime() {
|
||||
const { t } = useTranslation();
|
||||
return (unixSeconds?: number) => {
|
||||
if (!unixSeconds) return t('pages.nodes.never');
|
||||
const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
|
||||
if (diffSec < 5) return t('pages.nodes.justNow');
|
||||
if (diffSec < 60) return `${diffSec}s`;
|
||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`;
|
||||
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`;
|
||||
return `${Math.floor(diffSec / 86400)}d`;
|
||||
};
|
||||
return useMemo(
|
||||
() => (unixSeconds?: number) => {
|
||||
if (!unixSeconds) return t('pages.nodes.never');
|
||||
const diffSec = Math.max(0, Math.floor(Date.now() / 1000 - unixSeconds));
|
||||
if (diffSec < 5) return t('pages.nodes.justNow');
|
||||
if (diffSec < 60) return `${diffSec}s`;
|
||||
if (diffSec < 3600) return `${Math.floor(diffSec / 60)}m`;
|
||||
if (diffSec < 86400) return `${Math.floor(diffSec / 3600)}h`;
|
||||
return `${Math.floor(diffSec / 86400)}d`;
|
||||
},
|
||||
[t],
|
||||
);
|
||||
}
|
||||
|
||||
export default function NodeList({
|
||||
@@ -530,6 +535,47 @@ export default function NodeList({
|
||||
],
|
||||
);
|
||||
|
||||
// rc-table re-runs every cell renderer whenever the Table re-renders, so keep the
|
||||
// same element until its inputs change rather than re-rendering all rows each time.
|
||||
const nodeTable = useMemo(
|
||||
() => (
|
||||
<Table<NodeRow>
|
||||
dataSource={dataSource}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
size="middle"
|
||||
rowKey="key"
|
||||
rowSelection={
|
||||
dataSource.length > 1
|
||||
? {
|
||||
selectedRowKeys: selectedIds,
|
||||
onChange: (keys) =>
|
||||
onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
|
||||
getCheckboxProps: (record) => ({
|
||||
disabled: !!record.transitive || !isUpdateEligible(record),
|
||||
}),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="card-empty">
|
||||
<ClusterOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
||||
<div>{t('noData')}</div>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
expandable={{
|
||||
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
|
||||
rowExpandable: (record) => !record.transitive,
|
||||
}}
|
||||
/>
|
||||
),
|
||||
[dataSource, columns, loading, selectedIds, onSelectionChange, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Card size="small" hoverable>
|
||||
<div className="toolbar">
|
||||
@@ -806,39 +852,7 @@ export default function NodeList({
|
||||
</Modal>
|
||||
</>
|
||||
) : (
|
||||
<Table<NodeRow>
|
||||
dataSource={dataSource}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
loading={loading}
|
||||
scroll={{ x: 'max-content' }}
|
||||
size="middle"
|
||||
rowKey="key"
|
||||
rowSelection={
|
||||
dataSource.length > 1
|
||||
? {
|
||||
selectedRowKeys: selectedIds,
|
||||
onChange: (keys) =>
|
||||
onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
|
||||
getCheckboxProps: (record) => ({
|
||||
disabled: !!record.transitive || !isUpdateEligible(record),
|
||||
}),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
locale={{
|
||||
emptyText: (
|
||||
<div className="card-empty">
|
||||
<ClusterOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
||||
<div>{t('noData')}</div>
|
||||
</div>
|
||||
),
|
||||
}}
|
||||
expandable={{
|
||||
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
|
||||
rowExpandable: (record) => !record.transitive,
|
||||
}}
|
||||
/>
|
||||
nodeTable
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { useId, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Input, Modal, Space, Tabs, Typography } from 'antd';
|
||||
|
||||
import JsonEditor from '@/components/form/JsonEditor';
|
||||
import type { HappRoutingProfile } from '@/schemas/happRouting';
|
||||
import {
|
||||
buildHappRoutingDeeplink,
|
||||
loadHappRouting,
|
||||
parseHappRoutingJson,
|
||||
parseHappRoutingList,
|
||||
type HappRoutingListKey,
|
||||
} from './happRoutingEditor';
|
||||
|
||||
interface HappRoutingEditorModalProps {
|
||||
input: string;
|
||||
onCancel: () => void;
|
||||
onGenerate: (deeplink: string) => void;
|
||||
}
|
||||
|
||||
const basicFields: { key: HappRoutingListKey; label: string; placeholder: string }[] = [
|
||||
{
|
||||
key: 'DirectSites',
|
||||
label: 'subHappDirectDomains',
|
||||
placeholder: 'domain:ir\ndomain:cn\nexample.local',
|
||||
},
|
||||
{
|
||||
key: 'ProxySites',
|
||||
label: 'subHappProxyDomains',
|
||||
placeholder: 'geosite:google\nyoutube.com',
|
||||
},
|
||||
{
|
||||
key: 'BlockSites',
|
||||
label: 'subHappBlockDomains',
|
||||
placeholder: 'geosite:category-ads-all\nanalytics.google.com',
|
||||
},
|
||||
{
|
||||
key: 'DirectIp',
|
||||
label: 'subHappDirectIPs',
|
||||
placeholder: 'geoip:ir\n192.168.0.0/16\n10.0.0.0/8',
|
||||
},
|
||||
{
|
||||
key: 'ProxyIp',
|
||||
label: 'subHappProxyIPs',
|
||||
placeholder: '1.1.1.1/32\n8.8.8.8/32',
|
||||
},
|
||||
{
|
||||
key: 'BlockIp',
|
||||
label: 'subHappBlockIPs',
|
||||
placeholder: 'geoip:phishing\n0.0.0.0/8',
|
||||
},
|
||||
];
|
||||
|
||||
type BasicBuffers = Record<HappRoutingListKey, string>;
|
||||
|
||||
function basicBuffers(profile: HappRoutingProfile | null): BasicBuffers {
|
||||
return {
|
||||
DirectSites: profile?.DirectSites?.join('\n') ?? '',
|
||||
ProxySites: profile?.ProxySites?.join('\n') ?? '',
|
||||
BlockSites: profile?.BlockSites?.join('\n') ?? '',
|
||||
DirectIp: profile?.DirectIp?.join('\n') ?? '',
|
||||
ProxyIp: profile?.ProxyIp?.join('\n') ?? '',
|
||||
BlockIp: profile?.BlockIp?.join('\n') ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function mergeBasicRules(profile: HappRoutingProfile, buffers: BasicBuffers): HappRoutingProfile {
|
||||
const next = { ...profile };
|
||||
// Only replace edited lists; absent lists and all other profile fields must survive unchanged.
|
||||
for (const { key } of basicFields) {
|
||||
if (buffers[key] !== (profile[key]?.join('\n') ?? '')) {
|
||||
next[key] = parseHappRoutingList(buffers[key]);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
const loadErrorKeys = {
|
||||
off: 'subHappEditorLoadOff',
|
||||
remote: 'subHappEditorLoadRemote',
|
||||
invalid: 'subHappEditorLoadInvalid',
|
||||
};
|
||||
|
||||
export default function HappRoutingEditorModal({
|
||||
input,
|
||||
onCancel,
|
||||
onGenerate,
|
||||
}: HappRoutingEditorModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const fieldId = useId();
|
||||
const [loaded] = useState(() => loadHappRouting(input));
|
||||
const [profile, setProfile] = useState(() => (loaded.success ? loaded.profile : null));
|
||||
const [activeTab, setActiveTab] = useState('basic');
|
||||
// Keep raw buffers while typing so trailing newlines and temporarily invalid JSON are not lost.
|
||||
const [buffers, setBuffers] = useState(() => basicBuffers(profile));
|
||||
const [jsonText, setJsonText] = useState(() => (profile ? JSON.stringify(profile, null, 2) : ''));
|
||||
const advancedProfile = activeTab === 'advanced' ? parseHappRoutingJson(jsonText) : null;
|
||||
const invalidJson = activeTab === 'advanced' && advancedProfile === null;
|
||||
|
||||
const switchTab = (nextTab: string) => {
|
||||
if (!profile || nextTab === activeTab) return;
|
||||
if (nextTab === 'advanced') {
|
||||
const next = mergeBasicRules(profile, buffers);
|
||||
setProfile(next);
|
||||
setJsonText(JSON.stringify(next, null, 2));
|
||||
} else {
|
||||
if (!advancedProfile) return;
|
||||
setProfile(advancedProfile);
|
||||
setBuffers(basicBuffers(advancedProfile));
|
||||
}
|
||||
setActiveTab(nextTab);
|
||||
};
|
||||
|
||||
const generate = () => {
|
||||
if (!loaded.success || !profile) return;
|
||||
const next = activeTab === 'advanced' ? advancedProfile : mergeBasicRules(profile, buffers);
|
||||
if (next) onGenerate(buildHappRoutingDeeplink(next, loaded.mode));
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('pages.settings.subHappModalTitle')}
|
||||
open
|
||||
onCancel={onCancel}
|
||||
onOk={generate}
|
||||
okText={t('pages.settings.subHappBuildDeeplink')}
|
||||
okButtonProps={{ disabled: !loaded.success || invalidJson }}
|
||||
width={650}
|
||||
>
|
||||
<Space orientation="vertical" style={{ width: '100%', marginTop: 12 }} size="middle">
|
||||
{!loaded.success ? (
|
||||
<Alert type="error" showIcon title={t(`pages.settings.${loadErrorKeys[loaded.error]}`)} />
|
||||
) : loaded.isNew ? (
|
||||
<Alert type="info" showIcon title={t('pages.settings.subHappEditorNew')} />
|
||||
) : null}
|
||||
{loaded.success ? (
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={switchTab}
|
||||
destroyOnHidden
|
||||
items={[
|
||||
{
|
||||
key: 'basic',
|
||||
label: t('pages.settings.subHappEditorBasic'),
|
||||
disabled: invalidJson,
|
||||
children: (
|
||||
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Typography.Text type="secondary" id={`${fieldId}-hint`}>
|
||||
{t('pages.settings.subHappEditorListHint')}
|
||||
</Typography.Text>
|
||||
{basicFields.map(({ key, label, placeholder }) => (
|
||||
<div key={key}>
|
||||
<label
|
||||
htmlFor={`${fieldId}-${key}`}
|
||||
style={{ display: 'block', fontWeight: 600, marginBottom: 4 }}
|
||||
>
|
||||
{t(`pages.settings.${label}`)}
|
||||
</label>
|
||||
<Input.TextArea
|
||||
id={`${fieldId}-${key}`}
|
||||
aria-describedby={`${fieldId}-hint`}
|
||||
rows={2}
|
||||
value={buffers[key]}
|
||||
placeholder={placeholder}
|
||||
onChange={(event) =>
|
||||
setBuffers((previous) => ({ ...previous, [key]: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'advanced',
|
||||
label: t('pages.settings.subHappEditorAdvanced'),
|
||||
children: (
|
||||
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.settings.subHappEditorAdvancedHint')}
|
||||
</Typography.Text>
|
||||
{invalidJson ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
title={t('pages.settings.subHappEditorInvalidJson')}
|
||||
/>
|
||||
) : null}
|
||||
<JsonEditor
|
||||
value={jsonText}
|
||||
onChange={setJsonText}
|
||||
minHeight="320px"
|
||||
maxHeight="50vh"
|
||||
/>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Input, Modal, Select, Space, Switch, Tabs, message } from 'antd';
|
||||
import { Button, Input, Select, Space, Switch, Tabs, message } from 'antd';
|
||||
import {
|
||||
BranchesOutlined,
|
||||
BuildOutlined,
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import { SettingListItem } from '@/components/ui';
|
||||
import { buildHappPresetDeeplink, parseList, toBase64Utf8 } from './happPresets';
|
||||
import { buildHappPresetDeeplink } from './happPresets';
|
||||
import HappRoutingEditorModal from './HappRoutingEditorModal';
|
||||
import { catTabLabel } from './catTabLabel';
|
||||
|
||||
interface HappSettingsContentProps {
|
||||
@@ -32,38 +33,20 @@ export default function HappSettingsContent({
|
||||
defaultActiveTab = 'routing',
|
||||
}: HappSettingsContentProps) {
|
||||
const { t } = useTranslation();
|
||||
// Generator choices stay local until Apply updates the draft; page Save persists it.
|
||||
const [selectedPreset, setSelectedPreset] = useState<string>('iran-bypass');
|
||||
const [includeAdblock, setIncludeAdblock] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const [directDomains, setDirectDomains] = useState('');
|
||||
const [proxyDomains, setProxyDomains] = useState('');
|
||||
const [blockDomains, setBlockDomains] = useState('');
|
||||
const [directIPs, setDirectIPs] = useState('');
|
||||
const [proxyIPs, setProxyIPs] = useState('');
|
||||
const [blockIPs, setBlockIPs] = useState('');
|
||||
|
||||
const applyPreset = () => {
|
||||
const payload = buildHappPresetDeeplink(selectedPreset);
|
||||
const payload = buildHappPresetDeeplink(selectedPreset, includeAdblock);
|
||||
if (payload) {
|
||||
updateSetting({ subRoutingRules: payload });
|
||||
message.success(t('pages.settings.subHappPresetApplied'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBuildDeeplink = () => {
|
||||
const profile = {
|
||||
Name: 'Custom Rules',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: parseList(directDomains),
|
||||
DirectIp: parseList(directIPs),
|
||||
ProxySites: parseList(proxyDomains),
|
||||
ProxyIp: parseList(proxyIPs),
|
||||
BlockSites: parseList(blockDomains),
|
||||
BlockIp: parseList(blockIPs),
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
};
|
||||
|
||||
const deeplink = 'happ://routing/onadd/' + toBase64Utf8(JSON.stringify(profile));
|
||||
const handleBuildDeeplink = (deeplink: string) => {
|
||||
updateSetting({ subRoutingRules: deeplink });
|
||||
setIsModalOpen(false);
|
||||
message.success(t('pages.settings.subHappDeeplinkGenerated'));
|
||||
@@ -112,21 +95,31 @@ export default function HappSettingsContent({
|
||||
title={t('pages.settings.subHappPresets')}
|
||||
description={t('pages.settings.subHappPresetsDesc')}
|
||||
>
|
||||
<Space orientation="horizontal" style={{ width: '100%' }}>
|
||||
<Space orientation="horizontal" wrap style={{ width: '100%' }}>
|
||||
<Select
|
||||
aria-label={t('pages.settings.subHappPresets')}
|
||||
value={selectedPreset}
|
||||
style={{ minWidth: 170 }}
|
||||
onChange={setSelectedPreset}
|
||||
options={[
|
||||
{ value: 'iran-bypass', label: t('pages.settings.subHappPresetIran') },
|
||||
{ value: 'china-direct', label: t('pages.settings.subHappPresetChina') },
|
||||
{ value: 'adblock', label: t('pages.settings.subHappPresetAdblock') },
|
||||
{ value: 'global', label: t('pages.settings.subHappPresetGlobal') },
|
||||
{ value: 'lan-bypass', label: t('pages.settings.subHappPresetLocal') },
|
||||
{ value: 'off', label: t('pages.settings.subHappPresetOff') },
|
||||
]}
|
||||
/>
|
||||
<Space size="small">
|
||||
<Switch
|
||||
aria-label={t('pages.settings.subHappIncludeAdblock')}
|
||||
checked={includeAdblock}
|
||||
disabled={selectedPreset === 'off'}
|
||||
onChange={setIncludeAdblock}
|
||||
/>
|
||||
<span>{t('pages.settings.subHappIncludeAdblock')}</span>
|
||||
</Space>
|
||||
<Button type="primary" onClick={applyPreset}>
|
||||
{t('pages.settings.subHappPresets')}
|
||||
{t('pages.settings.subHappApplyPreset')}
|
||||
</Button>
|
||||
</Space>
|
||||
</SettingListItem>
|
||||
@@ -382,6 +375,23 @@ export default function HappSettingsContent({
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappLocalProxyAuth')}
|
||||
description={t('pages.settings.subHappLocalProxyAuthDesc')}
|
||||
>
|
||||
<Select
|
||||
value={allSetting.subHappLocalProxyAuth ?? 'auto'}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(v) => updateSetting({ subHappLocalProxyAuth: v })}
|
||||
options={[
|
||||
{ value: 'auto', label: t('pages.settings.subHappLocalProxyAuthAuto') },
|
||||
{ value: 'disable', label: t('pages.settings.subHappLocalProxyAuthDisable') },
|
||||
{ value: '', label: t('pages.settings.subHappLocalProxyAuthUnset') },
|
||||
]}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subHappAutoConnect')}
|
||||
@@ -572,83 +582,14 @@ export default function HappSettingsContent({
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={t('pages.settings.subHappModalTitle')}
|
||||
open={isModalOpen}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
onOk={handleBuildDeeplink}
|
||||
okText={t('pages.settings.subHappBuildDeeplink')}
|
||||
width={650}
|
||||
>
|
||||
<Space orientation="vertical" style={{ width: '100%', marginTop: 12 }} size="middle">
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{t('pages.settings.subHappDirectDomains')}
|
||||
</div>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
value={directDomains}
|
||||
placeholder="domain:ir, domain:cn, example.local"
|
||||
onChange={(e) => setDirectDomains(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{t('pages.settings.subHappProxyDomains')}
|
||||
</div>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
value={proxyDomains}
|
||||
placeholder="geosite:google, youtube.com"
|
||||
onChange={(e) => setProxyDomains(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{t('pages.settings.subHappBlockDomains')}
|
||||
</div>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
value={blockDomains}
|
||||
placeholder="geosite:category-ads-all, analytics.google.com"
|
||||
onChange={(e) => setBlockDomains(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{t('pages.settings.subHappDirectIPs')}
|
||||
</div>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
value={directIPs}
|
||||
placeholder="geoip:ir, 192.168.0.0/16, 10.0.0.0/8"
|
||||
onChange={(e) => setDirectIPs(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{t('pages.settings.subHappProxyIPs')}
|
||||
</div>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
value={proxyIPs}
|
||||
placeholder="1.1.1.1/32, 8.8.8.8/32"
|
||||
onChange={(e) => setProxyIPs(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontWeight: 600, marginBottom: 4 }}>
|
||||
{t('pages.settings.subHappBlockIPs')}
|
||||
</div>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
value={blockIPs}
|
||||
placeholder="geoip:phishing, 0.0.0.0/8"
|
||||
onChange={(e) => setBlockIPs(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Space>
|
||||
</Modal>
|
||||
{/* Mount per opening so canceled edits are discarded and the latest parent draft is loaded. */}
|
||||
{isModalOpen ? (
|
||||
<HappRoutingEditorModal
|
||||
input={allSetting.subRoutingRules}
|
||||
onCancel={() => setIsModalOpen(false)}
|
||||
onGenerate={handleBuildDeeplink}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Alert, Button, Input, InputNumber, Switch, Tabs } from 'antd';
|
||||
import { Alert, Button, Input, InputNumber, Select, Switch, Tabs } from 'antd';
|
||||
import {
|
||||
BranchesOutlined,
|
||||
CompassOutlined,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useSearchParams } from 'react-router';
|
||||
import type { AllSetting } from '@/models/setting';
|
||||
import type { SubProfileMode } from '@/schemas/setting';
|
||||
import { onNumber } from '@/utils/onNumber';
|
||||
import { DefaultSettingTag, SettingListItem } from '@/components/ui';
|
||||
import { RemarkTemplateField } from '@/components/form';
|
||||
@@ -246,6 +247,26 @@ export default function SubscriptionGeneralTab({
|
||||
onChange={onNumber((v) => updateSetting({ subUpdates: v }))}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.externalSubUserAgent')}
|
||||
badge={
|
||||
<DefaultSettingTag
|
||||
settingKey="externalSubUserAgent"
|
||||
value={allSetting.externalSubUserAgent}
|
||||
/>
|
||||
}
|
||||
description={t('pages.settings.externalSubUserAgentDesc')}
|
||||
>
|
||||
<Input
|
||||
value={allSetting.externalSubUserAgent}
|
||||
placeholder="v2rayNG/1.8.5"
|
||||
maxLength={512}
|
||||
allowClear
|
||||
onChange={(e) => updateSetting({ externalSubUserAgent: e.target.value })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
</>
|
||||
),
|
||||
},
|
||||
@@ -279,16 +300,44 @@ export default function SubscriptionGeneralTab({
|
||||
</SettingListItem>
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subProfileUrl')}
|
||||
description={t('pages.settings.subProfileUrlDesc')}
|
||||
title={t('pages.settings.subProfileMode')}
|
||||
description={t('pages.settings.subProfileModeDesc')}
|
||||
>
|
||||
<RemarkTemplateField
|
||||
value={allSetting.subProfileUrl}
|
||||
placeholder="https://example.com"
|
||||
onChange={(v) => updateSetting({ subProfileUrl: v })}
|
||||
metadataOnly
|
||||
<Select<SubProfileMode>
|
||||
id="sub-profile-mode"
|
||||
aria-label={t('pages.settings.subProfileMode')}
|
||||
value={allSetting.subProfileMode}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => updateSetting({ subProfileMode: value })}
|
||||
options={[
|
||||
{ value: 'none', label: t('pages.settings.subProfileModeNone') },
|
||||
{ value: 'builtin', label: t('pages.settings.subProfileModeBuiltin') },
|
||||
{ value: 'custom', label: t('pages.settings.subProfileModeCustom') },
|
||||
]}
|
||||
/>
|
||||
</SettingListItem>
|
||||
{allSetting.subProfileMode === 'builtin' ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ margin: '12px 20px' }}
|
||||
title={t('pages.settings.subProfileBuiltinWarning')}
|
||||
/>
|
||||
) : null}
|
||||
{allSetting.subProfileMode === 'custom' ? (
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subProfileUrl')}
|
||||
description={t('pages.settings.subProfileUrlDesc')}
|
||||
>
|
||||
<RemarkTemplateField
|
||||
value={allSetting.subProfileUrl}
|
||||
placeholder="https://example.com"
|
||||
onChange={(v) => updateSetting({ subProfileUrl: v })}
|
||||
metadataOnly
|
||||
/>
|
||||
</SettingListItem>
|
||||
) : null}
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.subAnnounce')}
|
||||
|
||||
@@ -7,16 +7,11 @@ export function toBase64Utf8(str: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
// Splits multiline or comma-separated string into clean unique token arrays.
|
||||
export function parseList(input: string): string[] {
|
||||
return input
|
||||
.split(/[\n,]+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
// Build standard Happ routing deeplink or special state for curated presets.
|
||||
export function buildHappPresetDeeplink(preset: string): string {
|
||||
export function buildHappPresetDeeplink(preset: string, includeAdblock = false): string {
|
||||
// Ad blocking is opt-in for each generated profile, independent of the base routing rules.
|
||||
const blockSites = includeAdblock ? ['geosite:category-ads-all'] : [];
|
||||
|
||||
switch (preset) {
|
||||
case 'off':
|
||||
return 'happ://routing/off';
|
||||
@@ -27,9 +22,20 @@ export function buildHappPresetDeeplink(preset: string): string {
|
||||
JSON.stringify({
|
||||
Name: 'Iran Bypass',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: ['domain:ir', 'regexp:.*\\.ir$'],
|
||||
DirectIp: ['geoip:ir', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
|
||||
BlockSites: ['geosite:category-ads-all'],
|
||||
RouteOrder: 'block-proxy-direct',
|
||||
DirectSites: ['geosite:private', 'domain:ir', 'geosite:category-ir'],
|
||||
DirectIp: [
|
||||
'geoip:ir',
|
||||
'geoip:private',
|
||||
'127.0.0.0/8',
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
'169.254.0.0/16',
|
||||
'224.0.0.0/4',
|
||||
'255.255.255.255',
|
||||
],
|
||||
BlockSites: blockSites,
|
||||
BlockIp: [],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
@@ -42,32 +48,38 @@ export function buildHappPresetDeeplink(preset: string): string {
|
||||
'happ://routing/onadd/' +
|
||||
toBase64Utf8(
|
||||
JSON.stringify({
|
||||
Name: 'China Direct',
|
||||
Name: 'Bypass-CN',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: ['geosite:cn', 'geosite:geolocation-cn'],
|
||||
DirectIp: ['geoip:cn', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
|
||||
BlockSites: ['geosite:category-ads-all'],
|
||||
BlockIp: [],
|
||||
RouteOrder: 'block-proxy-direct',
|
||||
RemoteDNSType: 'DoH',
|
||||
RemoteDNSDomain: 'https://cloudflare-dns.com/dns-query',
|
||||
RemoteDNSIP: '1.1.1.1',
|
||||
DomesticDNSType: 'DoH',
|
||||
DomesticDNSDomain: 'https://dns.alidns.com/dns-query',
|
||||
DomesticDNSIP: '223.5.5.5',
|
||||
DnsHosts: {
|
||||
'cloudflare-dns.com': '1.1.1.1',
|
||||
'dns.alidns.com': '223.5.5.5',
|
||||
},
|
||||
DirectSites: ['geosite:private', 'geosite:cn', 'geosite:geolocation-cn'],
|
||||
DirectIp: [
|
||||
'geoip:cn',
|
||||
'geoip:private',
|
||||
'127.0.0.0/8',
|
||||
'10.0.0.0/8',
|
||||
'172.16.0.0/12',
|
||||
'192.168.0.0/16',
|
||||
'169.254.0.0/16',
|
||||
'224.0.0.0/4',
|
||||
'255.255.255.255',
|
||||
],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
}),
|
||||
)
|
||||
);
|
||||
case 'adblock':
|
||||
return (
|
||||
'happ://routing/onadd/' +
|
||||
toBase64Utf8(
|
||||
JSON.stringify({
|
||||
Name: 'AdBlock',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: [],
|
||||
DirectIp: ['10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'],
|
||||
BlockSites: ['geosite:category-ads-all'],
|
||||
BlockSites: blockSites,
|
||||
BlockIp: [],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
FakeDNS: 'false',
|
||||
UseChunkFiles: 'true',
|
||||
}),
|
||||
)
|
||||
);
|
||||
@@ -80,7 +92,25 @@ export function buildHappPresetDeeplink(preset: string): string {
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: [],
|
||||
DirectIp: [],
|
||||
BlockSites: [],
|
||||
BlockSites: blockSites,
|
||||
BlockIp: [],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
DomainStrategy: 'AsIs',
|
||||
}),
|
||||
)
|
||||
);
|
||||
// LAN bypass has its own identity so applying it does not overwrite the Global profile.
|
||||
case 'lan-bypass':
|
||||
return (
|
||||
'happ://routing/onadd/' +
|
||||
toBase64Utf8(
|
||||
JSON.stringify({
|
||||
Name: 'Global Bypass Local Network',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: ['geosite:private'],
|
||||
DirectIp: ['geoip:private'],
|
||||
BlockSites: blockSites,
|
||||
BlockIp: [],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { HappRoutingProfileSchema, type HappRoutingProfile } from '@/schemas/happRouting';
|
||||
import { toBase64Utf8 } from './happPresets';
|
||||
import { isRemoteRoutingSource } from './subscriptionShared';
|
||||
|
||||
export type HappRoutingMode = 'add' | 'onadd';
|
||||
export type HappRoutingListKey =
|
||||
| 'DirectSites'
|
||||
| 'DirectIp'
|
||||
| 'ProxySites'
|
||||
| 'ProxyIp'
|
||||
| 'BlockSites'
|
||||
| 'BlockIp';
|
||||
|
||||
export type HappRoutingLoadResult =
|
||||
| { success: true; profile: HappRoutingProfile; mode: HappRoutingMode; isNew: boolean }
|
||||
| { success: false; error: 'off' | 'remote' | 'invalid' };
|
||||
|
||||
export function parseHappRoutingJson(input: string): HappRoutingProfile | null {
|
||||
try {
|
||||
const value: unknown = JSON.parse(input);
|
||||
const result = HappRoutingProfileSchema.safeParse(value);
|
||||
// Keep the validated input object; schema output can discard some unknown extension keys.
|
||||
return result.success ? (value as HappRoutingProfile) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadHappRouting(input: string): HappRoutingLoadResult {
|
||||
const source = input.trim();
|
||||
if (!source) {
|
||||
return {
|
||||
success: true,
|
||||
profile: {
|
||||
Name: 'Custom Rules',
|
||||
GlobalProxy: 'true',
|
||||
DirectSites: [],
|
||||
DirectIp: [],
|
||||
ProxySites: [],
|
||||
ProxyIp: [],
|
||||
BlockSites: [],
|
||||
BlockIp: [],
|
||||
DomainStrategy: 'IPIfNonMatch',
|
||||
},
|
||||
mode: 'onadd',
|
||||
isNew: true,
|
||||
};
|
||||
}
|
||||
if (source === 'happ://routing/off') return { success: false, error: 'off' };
|
||||
if (isRemoteRoutingSource(source)) return { success: false, error: 'remote' };
|
||||
|
||||
if (source.startsWith('{')) {
|
||||
const profile = parseHappRoutingJson(source);
|
||||
return profile
|
||||
? { success: true, profile, mode: 'onadd', isNew: false }
|
||||
: { success: false, error: 'invalid' };
|
||||
}
|
||||
|
||||
const match = /^happ:\/\/routing\/(onadd|add)\/([A-Za-z0-9+/_-]+={0,2})$/.exec(source);
|
||||
if (!match) return { success: false, error: 'invalid' };
|
||||
|
||||
try {
|
||||
const binary = atob(match[2].replace(/-/g, '+').replace(/_/g, '/'));
|
||||
// Decode UTF-8 strictly so malformed bytes cannot silently change profile names or rules.
|
||||
const json = new TextDecoder('utf-8', { fatal: true }).decode(
|
||||
Uint8Array.from(binary, (character) => character.charCodeAt(0)),
|
||||
);
|
||||
const profile = parseHappRoutingJson(json);
|
||||
return profile
|
||||
? { success: true, profile, mode: match[1] as HappRoutingMode, isNew: false }
|
||||
: { success: false, error: 'invalid' };
|
||||
} catch {
|
||||
return { success: false, error: 'invalid' };
|
||||
}
|
||||
}
|
||||
|
||||
export function buildHappRoutingDeeplink(
|
||||
profile: HappRoutingProfile,
|
||||
mode: HappRoutingMode = 'onadd',
|
||||
): string {
|
||||
return `happ://routing/${mode}/` + toBase64Utf8(JSON.stringify(profile));
|
||||
}
|
||||
|
||||
export function parseHappRoutingList(input: string): string[] {
|
||||
// Commas can belong to regexp rules, so the editor uses one rule per line.
|
||||
return input
|
||||
.split(/\r?\n/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
.sponsors-page .ant-layout,
|
||||
.sponsors-page .ant-layout-content,
|
||||
.sponsors-page .content-shell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sponsors-page .content-area {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.sponsors-inner {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.sponsors-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.sponsors-page .sponsors-title {
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.sponsors-title-icon {
|
||||
color: #faad14;
|
||||
}
|
||||
|
||||
.sponsors-page .sponsors-section-title {
|
||||
margin: 32px 0 12px;
|
||||
color: var(--ant-color-text-secondary);
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.sponsors-yourbrand {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
min-height: 170px;
|
||||
padding: 16px;
|
||||
border: 1.5px dashed var(--ant-color-border);
|
||||
border-radius: var(--ant-border-radius-lg, 8px);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sponsors-yourbrand.is-large {
|
||||
align-items: center;
|
||||
min-height: 0;
|
||||
padding: 40px 24px;
|
||||
text-align: center;
|
||||
background: var(--bg-card, transparent);
|
||||
}
|
||||
|
||||
.sponsors-yourbrand-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
font-size: 22px;
|
||||
color: var(--ant-color-primary);
|
||||
background: var(--ant-color-primary-bg);
|
||||
}
|
||||
|
||||
.sponsors-yourbrand-title {
|
||||
font-weight: 600;
|
||||
font-size: 15px;
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
.sponsors-yourbrand-text {
|
||||
max-width: 420px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 13px;
|
||||
color: var(--ant-color-text-secondary);
|
||||
}
|
||||
|
||||
.sponsors-yourbrand:not(.is-large) .ant-btn {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.sponsors-placement {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: var(--ant-border-radius-lg, 8px);
|
||||
background: var(--bg-card, transparent);
|
||||
}
|
||||
|
||||
.sponsors-placement-icon {
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
font-size: 17px;
|
||||
color: var(--ant-color-primary);
|
||||
background: var(--ant-color-primary-bg);
|
||||
}
|
||||
|
||||
.sponsors-placement-title {
|
||||
font-weight: 600;
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
.sponsors-placement-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sponsors-placement-status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.sponsors-placement-status .ant-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sponsors-placement-desc {
|
||||
font-size: 13px;
|
||||
color: var(--ant-color-text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sponsors-page .content-area {
|
||||
padding: 12px;
|
||||
padding-top: 64px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Col, ConfigProvider, Layout, Row, Spin, Tag, Typography } from 'antd';
|
||||
import {
|
||||
CrownOutlined,
|
||||
DashboardOutlined,
|
||||
LoginOutlined,
|
||||
MenuUnfoldOutlined,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import AppSidebar from '@/layouts/AppSidebar';
|
||||
import { useSponsorsQuery } from '@/api/queries/useSponsorsQuery';
|
||||
import SponsorCard from '@/components/sponsor/SponsorCard';
|
||||
import { IntlUtil } from '@/utils';
|
||||
import { useDatepicker } from '@/hooks/useDatepicker';
|
||||
import { placementStatus, sponsorsForSlot, type SponsorSlot } from '@/lib/sponsors';
|
||||
import './SponsorsPage.css';
|
||||
|
||||
function BecomeButton({ contact, block }: { contact?: string; block?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
if (!contact) return null;
|
||||
return (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CrownOutlined />}
|
||||
href={contact}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
block={block}
|
||||
>
|
||||
{t('pages.sponsors.become')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function YourBrandCard({ contact, large }: { contact?: string; large?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className={`sponsors-yourbrand${large ? ' is-large' : ''}`}>
|
||||
<span className="sponsors-yourbrand-icon">
|
||||
<PlusOutlined />
|
||||
</span>
|
||||
<div className="sponsors-yourbrand-title">{t('pages.sponsors.yourBrand')}</div>
|
||||
<div className="sponsors-yourbrand-text">{t('pages.sponsors.yourBrandText')}</div>
|
||||
<BecomeButton contact={contact} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SponsorsPage() {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||
const { data, fetched } = useSponsorsQuery();
|
||||
const sponsors = sponsorsForSlot(data.sponsors, 'page');
|
||||
|
||||
const { datepicker } = useDatepicker();
|
||||
const placements: { slot: SponsorSlot; icon: ReactNode; title: string; desc: string }[] = [
|
||||
{
|
||||
slot: 'dashboard',
|
||||
icon: <DashboardOutlined />,
|
||||
title: t('pages.sponsors.placementDashboard'),
|
||||
desc: t('pages.sponsors.placementDashboardDesc'),
|
||||
},
|
||||
{
|
||||
slot: 'sidebar',
|
||||
icon: <MenuUnfoldOutlined />,
|
||||
title: t('pages.sponsors.placementSidebar'),
|
||||
desc: t('pages.sponsors.placementSidebarDesc'),
|
||||
},
|
||||
{
|
||||
slot: 'login',
|
||||
icon: <LoginOutlined />,
|
||||
title: t('pages.sponsors.placementLogin'),
|
||||
desc: t('pages.sponsors.placementLoginDesc'),
|
||||
},
|
||||
{
|
||||
slot: 'page',
|
||||
icon: <CrownOutlined />,
|
||||
title: t('pages.sponsors.placementPage'),
|
||||
desc: t('pages.sponsors.placementPageDesc'),
|
||||
},
|
||||
];
|
||||
|
||||
const pageClass = useMemo(() => {
|
||||
const classes = ['sponsors-page'];
|
||||
if (isDark) classes.push('is-dark');
|
||||
if (isUltra) classes.push('is-ultra');
|
||||
return classes.join(' ');
|
||||
}, [isDark, isUltra]);
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={antdThemeConfig}>
|
||||
<Layout className={pageClass}>
|
||||
<AppSidebar />
|
||||
<Layout className="content-shell">
|
||||
<Layout.Content className="content-area">
|
||||
<div className="sponsors-inner">
|
||||
<div className="sponsors-header">
|
||||
<div>
|
||||
<Typography.Title level={3} className="sponsors-title">
|
||||
<CrownOutlined className="sponsors-title-icon" /> {t('pages.sponsors.title')}
|
||||
</Typography.Title>
|
||||
<Typography.Text type="secondary">{t('pages.sponsors.intro')}</Typography.Text>
|
||||
</div>
|
||||
<BecomeButton contact={data.contact} />
|
||||
</div>
|
||||
|
||||
<Spin spinning={!fetched} delay={200}>
|
||||
{sponsors.length === 0 ? (
|
||||
fetched && <YourBrandCard contact={data.contact} large />
|
||||
) : (
|
||||
<Row gutter={[16, 16]}>
|
||||
{sponsors.map((sponsor) => (
|
||||
<Col key={sponsor.id} xs={24} sm={12} xl={8}>
|
||||
<SponsorCard sponsor={sponsor} variant="card" />
|
||||
</Col>
|
||||
))}
|
||||
{data.contact && (
|
||||
<Col xs={24} sm={12} xl={8}>
|
||||
<YourBrandCard contact={data.contact} />
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
<Typography.Title level={5} className="sponsors-section-title">
|
||||
{t('pages.sponsors.placements')}
|
||||
</Typography.Title>
|
||||
<Row gutter={[16, 16]}>
|
||||
{placements.map((p) => {
|
||||
const status = placementStatus(data.sponsors, p.slot);
|
||||
return (
|
||||
<Col key={p.slot} xs={24} sm={12} xl={6}>
|
||||
<div className="sponsors-placement">
|
||||
<span className="sponsors-placement-icon">{p.icon}</span>
|
||||
<div className="sponsors-placement-body">
|
||||
<div className="sponsors-placement-title">{p.title}</div>
|
||||
<div className="sponsors-placement-desc">{p.desc}</div>
|
||||
<div className="sponsors-placement-status">
|
||||
{status.takenUntil ? (
|
||||
<Tag color="orange">
|
||||
{t('pages.sponsors.takenUntil', {
|
||||
date: IntlUtil.formatDate(status.takenUntil, datepicker),
|
||||
})}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color="green">{t('pages.sponsors.available')}</Tag>
|
||||
)}
|
||||
{status.count > 0 && !status.takenUntil && (
|
||||
<Tag>
|
||||
{t('pages.sponsors.activeCount', {
|
||||
count:
|
||||
status.capacity && status.capacity > 1
|
||||
? `${status.count}/${status.capacity}`
|
||||
: status.count,
|
||||
})}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
);
|
||||
})}
|
||||
</Row>
|
||||
</div>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Segmented } from 'antd';
|
||||
import { AndroidOutlined, AppleOutlined } from '@ant-design/icons';
|
||||
|
||||
import { APP_ICONS } from './appIcons';
|
||||
import type { AppPlatform, SubApp } from './subPageModel';
|
||||
|
||||
interface SubAppsTabProps {
|
||||
apps: Record<AppPlatform, SubApp[]>;
|
||||
initialPlatform: AppPlatform;
|
||||
onOpen: (url: string) => void;
|
||||
}
|
||||
|
||||
const PLATFORM_OPTIONS = [
|
||||
{ value: 'android' as const, label: 'Android', icon: <AndroidOutlined /> },
|
||||
{ value: 'ios' as const, label: 'iOS', icon: <AppleOutlined /> },
|
||||
];
|
||||
|
||||
function AppIcon({ name }: { name: string }) {
|
||||
const icon = APP_ICONS[name];
|
||||
if (!icon) {
|
||||
return (
|
||||
<span className="sub-app-mark" aria-hidden="true">
|
||||
{name.charAt(0)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (icon.tinted) {
|
||||
const mask = `url("${icon.src}")`;
|
||||
return (
|
||||
<span className="sub-app-mark" aria-hidden="true">
|
||||
<span className="sub-app-glyph" style={{ maskImage: mask, WebkitMaskImage: mask }} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return <img className="sub-app-logo" src={icon.src} alt="" width={32} height={32} />;
|
||||
}
|
||||
|
||||
export default function SubAppsTab({ apps, initialPlatform, onOpen }: SubAppsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const [platform, setPlatform] = useState<AppPlatform>(initialPlatform);
|
||||
|
||||
return (
|
||||
<div className="sub-apps">
|
||||
<Segmented<AppPlatform> value={platform} onChange={setPlatform} options={PLATFORM_OPTIONS} />
|
||||
<div className="sub-app-grid">
|
||||
{apps[platform].map((app) => (
|
||||
<div key={app.name} className="sub-row">
|
||||
<AppIcon name={app.name} />
|
||||
<span className="sub-app-name">{app.name}</span>
|
||||
<Button type="primary" size="small" onClick={() => onOpen(app.url)}>
|
||||
{t('add')}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Fragment } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Tag } from 'antd';
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
|
||||
import ConfigBlock from '@/components/clients/ConfigBlock';
|
||||
import {
|
||||
amneziawgConfigFromLink,
|
||||
isPostQuantumLink,
|
||||
wireguardConfigFromLink,
|
||||
} from '@/lib/xray/inbound-link';
|
||||
import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
|
||||
import SubQrButton from './SubQrButton';
|
||||
|
||||
interface SubConfigsTabProps {
|
||||
links: string[];
|
||||
onCopy: (value: string, toast?: string) => void;
|
||||
}
|
||||
|
||||
export default function SubConfigsTab({ links, onCopy }: SubConfigsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="sub-rows">
|
||||
<div className="sub-configs-bar">
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => onCopy(links.join('\n'), t('subscription.copyAllConfigsCopied'))}
|
||||
>
|
||||
{t('subscription.copyAllConfigs')}
|
||||
</Button>
|
||||
</div>
|
||||
{links.map((link, idx) => {
|
||||
const parts = parseLinkParts(link);
|
||||
const rowTitle = parts?.remark || `Link ${idx + 1}`;
|
||||
const isWireguardLink = link.startsWith('wireguard://') || link.startsWith('wg://');
|
||||
const isAmneziawgLink = link.startsWith('vpn://');
|
||||
return (
|
||||
<Fragment key={link}>
|
||||
<div className="sub-row">
|
||||
{parts ? <LinkTags parts={parts} /> : <Tag className="sub-row-tag">LINK</Tag>}
|
||||
<span className="sub-row-title" dir="auto" title={rowTitle}>
|
||||
{rowTitle}
|
||||
</span>
|
||||
<div className="sub-row-actions">
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => onCopy(link)}
|
||||
aria-label={t('copy')}
|
||||
title={t('copy')}
|
||||
/>
|
||||
{!isPostQuantumLink(link) && (
|
||||
<SubQrButton value={link} label={rowTitle} onCopy={onCopy} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isWireguardLink && (
|
||||
<ConfigBlock
|
||||
label={t('pages.clients.wireguardConfig')}
|
||||
text={wireguardConfigFromLink(link, rowTitle)}
|
||||
fileName={`${rowTitle || 'peer'}.conf`}
|
||||
qrRemark={rowTitle}
|
||||
tagColor="cyan"
|
||||
/>
|
||||
)}
|
||||
{isAmneziawgLink && (
|
||||
<ConfigBlock
|
||||
label={t('pages.clients.amneziaWgConfig')}
|
||||
text={amneziawgConfigFromLink(link)}
|
||||
fileName={`${rowTitle || 'peer'}.conf`}
|
||||
qrRemark={rowTitle}
|
||||
tagColor="purple"
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Menu, Popover, Space } from 'antd';
|
||||
import {
|
||||
MoonFilled,
|
||||
MoonOutlined,
|
||||
SunOutlined,
|
||||
TranslationOutlined,
|
||||
WifiOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { LanguageManager } from '@/utils';
|
||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||
|
||||
interface SubHeaderProps {
|
||||
title: string;
|
||||
sId: string;
|
||||
email: string;
|
||||
lang: string;
|
||||
onLangChange: (lang: string) => void;
|
||||
}
|
||||
|
||||
export default function SubHeader({ title, sId, email, lang, onLangChange }: SubHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, isUltra, toggleTheme, toggleUltra } = useTheme();
|
||||
|
||||
const cycleTheme = () => {
|
||||
pauseAnimationsUntilLeave('sub-theme-cycle');
|
||||
if (!isDark) {
|
||||
toggleTheme();
|
||||
if (isUltra) toggleUltra();
|
||||
} else if (!isUltra) {
|
||||
toggleUltra();
|
||||
} else {
|
||||
toggleUltra();
|
||||
toggleTheme();
|
||||
}
|
||||
};
|
||||
|
||||
const langMenuItems = useMemo(
|
||||
() =>
|
||||
(LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map(
|
||||
(l) => ({
|
||||
key: l.value,
|
||||
label: (
|
||||
<Space size={8}>
|
||||
<span aria-hidden="true">{l.icon}</span>
|
||||
<span>{l.name}</span>
|
||||
</Space>
|
||||
),
|
||||
}),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const themeIcon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
|
||||
const initial = Array.from(title)[0]?.toUpperCase();
|
||||
|
||||
return (
|
||||
<header className="sub-header">
|
||||
<div className="sub-brand">
|
||||
<span className="sub-brand-mark" aria-hidden="true">
|
||||
{initial ?? <WifiOutlined />}
|
||||
</span>
|
||||
<div className="sub-brand-text">
|
||||
<div className="sub-brand-title" dir="auto">
|
||||
{title || t('subscription.title')}
|
||||
</div>
|
||||
<div className="sub-brand-id">
|
||||
<bdi>{email ? `${sId} - ${email}` : sId}</bdi>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sub-toolbar">
|
||||
<Button
|
||||
id="sub-theme-cycle"
|
||||
shape="circle"
|
||||
size="large"
|
||||
className="toolbar-btn"
|
||||
aria-label={t('menu.theme')}
|
||||
title={t('menu.theme')}
|
||||
icon={themeIcon}
|
||||
onClick={cycleTheme}
|
||||
/>
|
||||
<Popover
|
||||
rootClassName={isDark ? 'dark' : 'light'}
|
||||
placement="bottomRight"
|
||||
trigger="click"
|
||||
styles={{ content: { padding: 4 } }}
|
||||
content={
|
||||
<Menu
|
||||
mode="vertical"
|
||||
selectable
|
||||
selectedKeys={[lang]}
|
||||
items={langMenuItems}
|
||||
onClick={({ key }) => onLangChange(key)}
|
||||
style={{ border: 'none', minWidth: 160 }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
className="toolbar-btn"
|
||||
aria-label={t('pages.settings.language')}
|
||||
icon={<TranslationOutlined />}
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Progress, Tag, theme } from 'antd';
|
||||
|
||||
import { IntlUtil } from '@/utils';
|
||||
import type { CalendarKind } from '@/utils';
|
||||
import { usagePercent } from './subPageModel';
|
||||
import type { SubStatus } from './subPageModel';
|
||||
|
||||
interface SubHeroProps {
|
||||
status: SubStatus;
|
||||
daysLeft: number | null;
|
||||
usedByte: number;
|
||||
totalByte: number;
|
||||
expireMs: number;
|
||||
lastOnlineMs: number;
|
||||
download: string;
|
||||
upload: string;
|
||||
used: string;
|
||||
total: string;
|
||||
remained: string;
|
||||
datepicker: CalendarKind;
|
||||
lang: string;
|
||||
}
|
||||
|
||||
const STATUS_TAGS: Record<SubStatus, { color: string; label: string }> = {
|
||||
active: { color: 'green', label: 'subscription.active' },
|
||||
unlimited: { color: 'purple', label: 'subscription.unlimited' },
|
||||
expired: { color: 'red', label: 'subscription.expired' },
|
||||
depleted: { color: 'red', label: 'subscription.depleted' },
|
||||
disabled: { color: 'red', label: 'subscription.inactive' },
|
||||
};
|
||||
|
||||
// FormatTraffic renders "37.60GB"; the amount and unit are sized apart.
|
||||
function splitSize(label: string): [string, string] {
|
||||
const match = /^([\d.,]+)\s*(\D*)$/.exec(label.trim());
|
||||
return match ? [match[1], match[2]] : [label, ''];
|
||||
}
|
||||
|
||||
export default function SubHero({
|
||||
status,
|
||||
daysLeft,
|
||||
usedByte,
|
||||
totalByte,
|
||||
expireMs,
|
||||
lastOnlineMs,
|
||||
download,
|
||||
upload,
|
||||
used,
|
||||
total,
|
||||
remained,
|
||||
datepicker,
|
||||
lang,
|
||||
}: SubHeroProps) {
|
||||
const { t } = useTranslation();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const hasQuota = totalByte > 0;
|
||||
const healthy = status === 'active' || status === 'unlimited';
|
||||
const pct = usagePercent(usedByte, totalByte);
|
||||
const ringColor =
|
||||
!healthy || pct >= 90 ? token.colorError : pct >= 75 ? token.colorWarning : token.colorPrimary;
|
||||
const [amount, unit] = splitSize(hasQuota ? remained : used);
|
||||
const formatDate = (ms: number) => IntlUtil.formatDate(ms, datepicker, lang);
|
||||
const statusTag = STATUS_TAGS[status];
|
||||
|
||||
const stats: { key: string; label: string; value: ReactNode }[] = [
|
||||
{ key: 'days', label: t('subscription.daysLeft'), value: daysLeft ?? '∞' },
|
||||
{
|
||||
key: 'expiry',
|
||||
label: t('subscription.expiry'),
|
||||
value: expireMs > 0 ? formatDate(expireMs) : t('subscription.noExpiry'),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: t('subscription.status'),
|
||||
value: <Tag color={statusTag.color}>{t(statusTag.label)}</Tag>,
|
||||
},
|
||||
{ key: 'down', label: t('subscription.downloaded'), value: <bdi>{download}</bdi> },
|
||||
{ key: 'up', label: t('subscription.uploaded'), value: <bdi>{upload}</bdi> },
|
||||
{ key: 'total', label: t('subscription.totalQuota'), value: <bdi>{total}</bdi> },
|
||||
{
|
||||
key: 'lastOnline',
|
||||
label: t('lastOnline'),
|
||||
value: lastOnlineMs > 0 ? formatDate(lastOnlineMs) : '-',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className={healthy ? 'sub-hero' : 'sub-hero is-alert'}>
|
||||
<Progress
|
||||
type="circle"
|
||||
className="sub-ring"
|
||||
percent={pct}
|
||||
status="normal"
|
||||
size={156}
|
||||
strokeColor={ringColor}
|
||||
format={() => (
|
||||
<span className="sub-ring-center">
|
||||
<span className="sub-ring-value">{hasQuota ? `${pct.toFixed(1)}%` : '∞'}</span>
|
||||
<span className="sub-ring-label">
|
||||
{hasQuota ? t('usage') : t('subscription.unlimited')}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
<div className="sub-hero-summary">
|
||||
<div className="sub-label">{hasQuota ? t('remained') : t('usage')}</div>
|
||||
<bdi className="sub-big">
|
||||
<span className="sub-big-num">{amount}</span>
|
||||
{unit && <span className="sub-big-unit">{unit}</span>}
|
||||
</bdi>
|
||||
<div className="sub-muted">
|
||||
{hasQuota ? t('subscription.ofTotal', { total }) : t('subscription.unlimited')}
|
||||
</div>
|
||||
<dl className="sub-stats">
|
||||
{stats.map((stat) => (
|
||||
<div key={stat.key} className="sub-stat">
|
||||
<dt className="sub-label">{stat.label}</dt>
|
||||
<dd className="sub-stat-value">{stat.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, QRCode, Tag } from 'antd';
|
||||
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
|
||||
import SubQrButton from './SubQrButton';
|
||||
|
||||
interface SubLinksTabProps {
|
||||
subUrl: string;
|
||||
subJsonUrl: string;
|
||||
subClashUrl: string;
|
||||
onCopy: (value: string) => void;
|
||||
}
|
||||
|
||||
const appendRawView = (url: string) => `${url}${url.includes('?') ? '&' : '?'}view=raw`;
|
||||
|
||||
export default function SubLinksTab({ subUrl, subJsonUrl, subClashUrl, onCopy }: SubLinksTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const subLabel = t('pages.settings.subSettings');
|
||||
const rows = [
|
||||
{ kind: 'SUB', color: 'green', url: subUrl, title: subLabel, downloadable: false },
|
||||
{
|
||||
kind: 'JSON',
|
||||
color: 'purple',
|
||||
url: subJsonUrl,
|
||||
title: `${subLabel} JSON`,
|
||||
downloadable: true,
|
||||
},
|
||||
{ kind: 'CLASH', color: 'gold', url: subClashUrl, title: 'Clash / Mihomo', downloadable: true },
|
||||
].filter((row) => row.url);
|
||||
|
||||
return (
|
||||
<div className="sub-rows">
|
||||
{rows.map((row) => (
|
||||
<div key={row.kind} className="sub-row">
|
||||
<Tag color={row.color} className="sub-row-tag">
|
||||
{row.kind}
|
||||
</Tag>
|
||||
<div className="sub-row-main">
|
||||
<a href={row.url} target="_blank" rel="noopener noreferrer" className="sub-row-title">
|
||||
{row.title}
|
||||
</a>
|
||||
<div className="sub-row-url" dir="ltr" title={row.url}>
|
||||
{row.url}
|
||||
</div>
|
||||
</div>
|
||||
<div className="sub-row-actions">
|
||||
{row.downloadable && (
|
||||
<Button
|
||||
href={appendRawView(row.url)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
icon={<DownloadOutlined />}
|
||||
aria-label={t('download')}
|
||||
title={t('download')}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => onCopy(row.url)}
|
||||
aria-label={t('copy')}
|
||||
title={t('copy')}
|
||||
/>
|
||||
<SubQrButton value={row.url} label={row.title} onCopy={onCopy} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{subUrl && (
|
||||
<div className="sub-qr-card">
|
||||
<div className="sub-qr-code">
|
||||
<QRCode
|
||||
value={subUrl}
|
||||
size={112}
|
||||
type="svg"
|
||||
bordered={false}
|
||||
color="#000000"
|
||||
bgColor="#ffffff"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="sub-qr-title">{t('subscription.scanTitle')}</div>
|
||||
<div className="sub-muted">{t('subscription.scanHint')}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,78 @@
|
||||
.subscription-page {
|
||||
--bg-page: #e6e8ec;
|
||||
--bg-card: #ffffff;
|
||||
/* --sub-grad-* paints graphics, --sub-ink-* paints text: the cyan end darkens
|
||||
so text clears 4.5:1. --sub-accent is ACCENT.primary in SubPage.tsx. */
|
||||
--sub-grad-from: #8b5cf6;
|
||||
--sub-grad-to: #06b6d4;
|
||||
--sub-ink-from: #6d28d9;
|
||||
--sub-ink-to: #0e7490;
|
||||
--sub-accent: #7c3aed;
|
||||
--bg-page: linear-gradient(135deg, #e9e4ff 0%, #ddeefc 52%, #e2f7f2 100%);
|
||||
--sub-card-bg: rgba(255, 255, 255, 0.72);
|
||||
--sub-card-border: rgba(255, 255, 255, 0.7);
|
||||
--sub-card-shadow: 0 1px 3px rgba(15, 23, 42, 0.05), 0 20px 56px rgba(124, 58, 237, 0.16);
|
||||
--sub-card-sheen: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.75),
|
||||
rgba(255, 255, 255, 0) 42%,
|
||||
rgba(124, 58, 237, 0.22) 88%
|
||||
);
|
||||
--sub-blob-1: rgba(139, 92, 246, 0.55);
|
||||
--sub-blob-2: rgba(6, 182, 212, 0.45);
|
||||
--sub-grid: rgba(124, 58, 237, 0.06);
|
||||
--sub-hairline: linear-gradient(90deg, rgba(139, 92, 246, 0.4), rgba(6, 182, 212, 0.4));
|
||||
--sub-tile-bg: rgba(124, 58, 237, 0.05);
|
||||
--sub-tile-border: rgba(124, 58, 237, 0.12);
|
||||
--sub-row-bg: rgba(124, 58, 237, 0.045);
|
||||
--sub-row-border: rgba(124, 58, 237, 0.11);
|
||||
--sub-row-bg-hover: rgba(124, 58, 237, 0.09);
|
||||
--sub-row-border-hover: rgba(124, 58, 237, 0.3);
|
||||
--sub-row-glow: rgba(124, 58, 237, 0.28);
|
||||
--sub-glass-bg: rgba(255, 255, 255, 0.6);
|
||||
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
background: var(--bg-page);
|
||||
}
|
||||
|
||||
.subscription-page.is-dark {
|
||||
--bg-page: #1a1b1f;
|
||||
--bg-card: #23252b;
|
||||
--sub-grad-from: #a78bfa;
|
||||
--sub-grad-to: #22d3ee;
|
||||
--sub-ink-from: #c4b5fd;
|
||||
--sub-ink-to: #67e8f9;
|
||||
--sub-accent: #a78bfa;
|
||||
--bg-page: radial-gradient(ellipse 120% 90% at 18% -10%, #1f1740 0%, #16171d 52%, #101116 100%);
|
||||
--sub-card-bg: rgba(35, 37, 43, 0.62);
|
||||
--sub-card-border: rgba(255, 255, 255, 0.08);
|
||||
--sub-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 24px 64px rgba(109, 40, 217, 0.24);
|
||||
--sub-card-sheen: linear-gradient(
|
||||
135deg,
|
||||
rgba(255, 255, 255, 0.16),
|
||||
rgba(255, 255, 255, 0) 42%,
|
||||
rgba(167, 139, 250, 0.4) 88%
|
||||
);
|
||||
--sub-blob-1: rgba(139, 92, 246, 0.4);
|
||||
--sub-blob-2: rgba(34, 211, 238, 0.26);
|
||||
--sub-grid: rgba(255, 255, 255, 0.035);
|
||||
--sub-hairline: linear-gradient(90deg, rgba(167, 139, 250, 0.45), rgba(34, 211, 238, 0.45));
|
||||
--sub-tile-bg: rgba(167, 139, 250, 0.07);
|
||||
--sub-tile-border: rgba(167, 139, 250, 0.14);
|
||||
--sub-row-bg: rgba(167, 139, 250, 0.06);
|
||||
--sub-row-border: rgba(167, 139, 250, 0.12);
|
||||
--sub-row-bg-hover: rgba(167, 139, 250, 0.12);
|
||||
--sub-row-border-hover: rgba(167, 139, 250, 0.35);
|
||||
--sub-row-glow: rgba(139, 92, 246, 0.45);
|
||||
--sub-glass-bg: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.subscription-page.is-dark.is-ultra {
|
||||
--bg-page: #000;
|
||||
--bg-card: #101013;
|
||||
--bg-page: radial-gradient(ellipse 120% 90% at 18% -10%, #120a2b 0%, #050509 55%, #000 100%);
|
||||
--sub-card-bg: rgba(16, 16, 19, 0.68);
|
||||
--sub-card-border: rgba(255, 255, 255, 0.055);
|
||||
--sub-card-shadow: 0 1px 3px rgba(0, 0, 0, 0.6), 0 24px 64px rgba(88, 28, 135, 0.3);
|
||||
--sub-blob-1: rgba(139, 92, 246, 0.22);
|
||||
--sub-blob-2: rgba(34, 211, 238, 0.14);
|
||||
--sub-grid: rgba(255, 255, 255, 0.022);
|
||||
--sub-glass-bg: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.subscription-page .ant-layout,
|
||||
@@ -20,104 +80,204 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.subscription-page .content {
|
||||
padding: 24px 12px;
|
||||
/* aurora backdrop */
|
||||
.sub-aurora {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.subscription-card {
|
||||
margin-top: 8px;
|
||||
.sub-aurora-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image:
|
||||
linear-gradient(var(--sub-grid) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--sub-grid) 1px, transparent 1px);
|
||||
background-size: 48px 48px;
|
||||
background-position: center;
|
||||
-webkit-mask-image: radial-gradient(ellipse at 50% 30%, black 20%, transparent 72%);
|
||||
mask-image: radial-gradient(ellipse at 50% 30%, black 20%, transparent 72%);
|
||||
}
|
||||
|
||||
.qr-tag {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
.sub-aurora::before,
|
||||
.sub-aurora::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 70vmax;
|
||||
height: 70vmax;
|
||||
max-width: 820px;
|
||||
max-height: 820px;
|
||||
border-radius: 50%;
|
||||
filter: blur(80px);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.info-table {
|
||||
margin-top: 4px;
|
||||
.sub-aurora::before {
|
||||
top: -22vmax;
|
||||
left: -16vmax;
|
||||
background: radial-gradient(circle, var(--sub-blob-1) 0%, transparent 65%);
|
||||
animation: sub-blob-a 28s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.links-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
.sub-aurora::after {
|
||||
bottom: -24vmax;
|
||||
right: -18vmax;
|
||||
background: radial-gradient(circle, var(--sub-blob-2) 0%, transparent 65%);
|
||||
animation: sub-blob-b 34s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.sub-link-anchor {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
@keyframes sub-blob-a {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: translate(16vw, 14vh) scale(1.18);
|
||||
}
|
||||
}
|
||||
|
||||
.sub-link-anchor:hover {
|
||||
text-decoration: underline;
|
||||
@keyframes sub-blob-b {
|
||||
0% {
|
||||
transform: translate(0, 0) scale(1);
|
||||
}
|
||||
100% {
|
||||
transform: translate(-14vw, -12vh) scale(1.15);
|
||||
}
|
||||
}
|
||||
|
||||
.sub-link-row {
|
||||
.sub-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 32px 16px;
|
||||
}
|
||||
|
||||
.sub-card {
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.subscription-page .sub-card {
|
||||
position: relative;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--sub-card-border);
|
||||
background: var(--sub-card-bg);
|
||||
box-shadow: var(--sub-card-shadow);
|
||||
-webkit-backdrop-filter: blur(24px) saturate(180%);
|
||||
backdrop-filter: blur(24px) saturate(180%);
|
||||
}
|
||||
|
||||
/* Hairline gradient rim: a padded sheen layer with its own middle masked out. */
|
||||
.subscription-page .sub-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
border-radius: inherit;
|
||||
padding: 1px;
|
||||
background: var(--sub-card-sheen);
|
||||
-webkit-mask:
|
||||
linear-gradient(#000 0 0) content-box,
|
||||
linear-gradient(#000 0 0);
|
||||
-webkit-mask-composite: xor;
|
||||
mask-composite: exclude;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sub-card > .ant-card-body {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.sub-label,
|
||||
.sub-muted {
|
||||
font-size: 12px;
|
||||
color: var(--ant-color-text-tertiary);
|
||||
}
|
||||
|
||||
.sub-muted {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Gradient hairline shared by the header, the stats grid and the footer. */
|
||||
.sub-header::after,
|
||||
.sub-stats::before,
|
||||
.sub-footer::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset-inline: 0;
|
||||
height: 1px;
|
||||
background: var(--sub-hairline);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
/* header */
|
||||
.sub-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 10px;
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
transition:
|
||||
background 120ms ease,
|
||||
border-color 120ms ease;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.sub-link-row:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border-color: rgba(0, 0, 0, 0.14);
|
||||
.sub-header::after {
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.is-dark .sub-link-row {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.is-dark .sub-link-row:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.sub-link-tag {
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.sub-link-title {
|
||||
flex: 1;
|
||||
.sub-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sub-brand-mark {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 13px;
|
||||
background: linear-gradient(135deg, var(--sub-grad-from), var(--sub-grad-to));
|
||||
box-shadow: 0 8px 20px -8px var(--sub-row-glow);
|
||||
color: #fff;
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sub-brand-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sub-brand-title,
|
||||
.sub-brand-id {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sub-link-actions {
|
||||
.sub-brand-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
.sub-brand-id {
|
||||
font-size: 12px;
|
||||
color: var(--ant-color-text-tertiary);
|
||||
}
|
||||
|
||||
.sub-toolbar {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sub-link-qr-popover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.apps-row {
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.app-col {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
@@ -129,3 +289,436 @@
|
||||
.toolbar-btn .anticon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.subscription-page .toolbar-btn {
|
||||
border-color: var(--sub-row-border);
|
||||
background: var(--sub-glass-bg);
|
||||
color: var(--sub-accent);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.subscription-page .toolbar-btn:hover {
|
||||
border-color: var(--sub-row-border-hover);
|
||||
background: var(--sub-row-bg-hover);
|
||||
color: var(--sub-accent);
|
||||
}
|
||||
|
||||
.sub-announce {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* usage hero */
|
||||
.sub-hero {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 32px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sub-ring .ant-progress-text {
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
.sub-ring-center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.sub-ring-value {
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sub-ring-label {
|
||||
font-size: 12px;
|
||||
color: var(--ant-color-text-tertiary);
|
||||
}
|
||||
|
||||
.sub-big {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
margin: 2px 0;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.sub-big-num {
|
||||
font-size: 44px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
background: linear-gradient(135deg, var(--sub-ink-from), var(--sub-ink-to));
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
color: var(--sub-ink-from);
|
||||
}
|
||||
|
||||
.sub-big-unit {
|
||||
font-size: 18px;
|
||||
color: var(--ant-color-text-secondary);
|
||||
}
|
||||
|
||||
.sub-hero.is-alert .sub-big-num {
|
||||
background: none;
|
||||
-webkit-text-fill-color: var(--ant-color-error);
|
||||
color: var(--ant-color-error);
|
||||
}
|
||||
|
||||
.sub-stats {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin: 20px 0 0;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.sub-stats::before {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.sub-stat {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--sub-tile-border);
|
||||
background: var(--sub-tile-bg);
|
||||
}
|
||||
|
||||
.sub-stat-value {
|
||||
margin: 2px 0 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--ant-color-text);
|
||||
font-variant-numeric: tabular-nums;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.sub-stat-value .ant-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* tabs */
|
||||
.sub-tabs {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.sub-tabs.ant-tabs .ant-tabs-ink-bar {
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, var(--sub-grad-from), var(--sub-grad-to));
|
||||
}
|
||||
|
||||
.sub-tab-count {
|
||||
margin-inline-start: 6px;
|
||||
padding: 0 7px;
|
||||
border-radius: 10px;
|
||||
font-size: 12px;
|
||||
background: var(--sub-tile-bg);
|
||||
border: 1px solid var(--sub-tile-border);
|
||||
color: var(--sub-accent);
|
||||
}
|
||||
|
||||
.sub-rows {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sub-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--sub-row-bg);
|
||||
border: 1px solid var(--sub-row-border);
|
||||
transition:
|
||||
background 160ms ease,
|
||||
border-color 160ms ease,
|
||||
box-shadow 160ms ease,
|
||||
transform 160ms ease;
|
||||
}
|
||||
|
||||
.sub-row:hover {
|
||||
background: var(--sub-row-bg-hover);
|
||||
border-color: var(--sub-row-border-hover);
|
||||
box-shadow: 0 8px 20px -14px var(--sub-row-glow);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.sub-row-tag {
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.sub-row-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sub-row-title {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
color: var(--ant-color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sub-row > .sub-row-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
a.sub-row-title:hover {
|
||||
color: var(--sub-accent);
|
||||
}
|
||||
|
||||
.sub-row-url {
|
||||
font-size: 12px;
|
||||
color: var(--ant-color-text-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
[dir='rtl'] .sub-row-url,
|
||||
[dir='rtl'] .sub-row > .sub-row-title {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.sub-row-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sub-qr-modal .ant-modal-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sub-qr-modal .ant-modal-close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.sub-qr-modal-hint {
|
||||
margin: 2px 0 20px;
|
||||
}
|
||||
|
||||
.sub-qr-modal-code {
|
||||
width: fit-content;
|
||||
margin: 0 auto 20px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.sub-qr-modal-code canvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sub-qr-modal-link {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.sub-qr-modal-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.sub-qr-modal-actions > .ant-btn:first-child {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sub-qr-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
margin-top: 8px;
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
border: 1px dashed var(--sub-row-border-hover);
|
||||
background: var(--sub-row-bg);
|
||||
}
|
||||
|
||||
.sub-qr-code {
|
||||
flex-shrink: 0;
|
||||
padding: 6px;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.sub-qr-title {
|
||||
margin-bottom: 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
/* apps */
|
||||
.sub-apps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sub-app-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sub-app-mark {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9px;
|
||||
border: 1px solid var(--sub-tile-border);
|
||||
background: linear-gradient(135deg, var(--sub-row-bg-hover), var(--sub-tile-bg));
|
||||
color: var(--sub-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sub-app-glyph {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: currentColor;
|
||||
-webkit-mask-position: center;
|
||||
mask-position: center;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-size: contain;
|
||||
mask-size: contain;
|
||||
}
|
||||
|
||||
.sub-app-logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 9px;
|
||||
object-fit: cover;
|
||||
box-shadow: 0 0 0 1px var(--sub-tile-border);
|
||||
}
|
||||
|
||||
.sub-app-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 14px;
|
||||
color: var(--ant-color-text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* configs */
|
||||
.sub-configs-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
/* footer */
|
||||
.sub-footer {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
gap: 8px 16px;
|
||||
margin-top: 28px;
|
||||
padding-top: 16px;
|
||||
font-size: 12px;
|
||||
color: var(--ant-color-text-tertiary);
|
||||
}
|
||||
|
||||
.sub-footer::before {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.sub-footer > span,
|
||||
.sub-footer > a {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sub-aurora::before,
|
||||
.sub-aurora::after {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.sub-row:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.sub-content {
|
||||
padding: 16px 8px;
|
||||
}
|
||||
|
||||
.sub-card > .ant-card-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* One static blob: two animated 70vmax blurs drop frames on low-end phones. */
|
||||
.sub-aurora::before {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.sub-aurora::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sub-hero {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.sub-ring {
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.sub-big-num {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.sub-stats {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sub-app-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.sub-tabs .ant-tabs-tab-icon {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.sub-qr-card {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
+163
-586
@@ -1,98 +1,89 @@
|
||||
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Card, ConfigProvider, Layout, Tabs, message } from 'antd';
|
||||
import type { TabsProps } from 'antd';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
ConfigProvider,
|
||||
Descriptions,
|
||||
Divider,
|
||||
Dropdown,
|
||||
Layout,
|
||||
Menu,
|
||||
message,
|
||||
Popover,
|
||||
QRCode,
|
||||
Row,
|
||||
Space,
|
||||
Tag,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import {
|
||||
AndroidOutlined,
|
||||
AppleOutlined,
|
||||
CopyOutlined,
|
||||
DownOutlined,
|
||||
DownloadOutlined,
|
||||
MoonFilled,
|
||||
MoonOutlined,
|
||||
QrcodeOutlined,
|
||||
SunOutlined,
|
||||
TranslationOutlined,
|
||||
AppstoreOutlined,
|
||||
ClockCircleOutlined,
|
||||
CustomerServiceOutlined,
|
||||
LinkOutlined,
|
||||
UnorderedListOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
|
||||
import {
|
||||
amneziawgConfigFromLink,
|
||||
isPostQuantumLink,
|
||||
wireguardConfigFromLink,
|
||||
} from '@/lib/xray/inbound-link';
|
||||
import { LinkTags, parseLinkParts } from '@/lib/xray/link-label';
|
||||
import ConfigBlock from '@/components/clients/ConfigBlock';
|
||||
import { ClipboardManager, LanguageManager } from '@/utils';
|
||||
import { setMessageInstance } from '@/utils/messageBus';
|
||||
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
|
||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||
import SubUsageSummary from './SubUsageSummary';
|
||||
import { useTheme } from '@/hooks/useTheme';
|
||||
import SubAppsTab from './SubAppsTab';
|
||||
import SubConfigsTab from './SubConfigsTab';
|
||||
import SubHeader from './SubHeader';
|
||||
import SubHero from './SubHero';
|
||||
import SubLinksTab from './SubLinksTab';
|
||||
import { buildSubApps, daysUntil, detectPlatform, resolveSubStatus } from './subPageModel';
|
||||
import './SubPage.css';
|
||||
|
||||
const QR_SIZE = 240;
|
||||
|
||||
const subData = window.__SUB_PAGE_DATA__ || {};
|
||||
|
||||
const sId = subData.sId || '';
|
||||
const enabled = !!subData.enabled;
|
||||
const download = subData.download || '0';
|
||||
const upload = subData.upload || '0';
|
||||
const total = subData.total || '∞';
|
||||
const used = subData.used || '0';
|
||||
const remained = subData.remained || '';
|
||||
const totalByte = Number(subData.totalByte || 0);
|
||||
const expireMs = Number(subData.expire || 0) * 1000;
|
||||
const lastOnlineMs = Number(subData.lastOnline || 0);
|
||||
const subUrl = subData.subUrl || '';
|
||||
const subJsonUrl = subData.subJsonUrl || '';
|
||||
const subClashUrl = subData.subClashUrl || '';
|
||||
const subTitle = subData.subTitle || '';
|
||||
const subSupportUrl = subData.subSupportUrl || '';
|
||||
const updateHours = Number(subData.subUpdates || 0);
|
||||
const announce = subData.announce || '';
|
||||
const links: string[] = Array.isArray(subData.links) ? subData.links : [];
|
||||
const linkEmails: string[] = Array.isArray(subData.emails) ? subData.emails : [];
|
||||
const subEmail = [...new Set(linkEmails.filter(Boolean))].join(', ');
|
||||
const datepicker = subData.datepicker || 'gregorian';
|
||||
const announce = subData.announce || '';
|
||||
const totalByte = Number(subData.totalByte || 0);
|
||||
const usedByte =
|
||||
Number(subData.usedByte || 0) ||
|
||||
Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0);
|
||||
const expireMs = Number(subData.expire || 0) * 1000;
|
||||
const clientEmail = [...new Set(linkEmails.filter(Boolean))].join(', ');
|
||||
const loadedAt = Date.now();
|
||||
|
||||
const appendRawView = (url: string) => `${url}${url.includes('?') ? '&' : '?'}view=raw`;
|
||||
const heroData = {
|
||||
status: resolveSubStatus({ enabled: !!subData.enabled, usedByte, totalByte, expireMs }, loadedAt),
|
||||
daysLeft: daysUntil(expireMs, loadedAt),
|
||||
usedByte,
|
||||
totalByte,
|
||||
expireMs,
|
||||
lastOnlineMs: Number(subData.lastOnline || 0),
|
||||
download: subData.download || '0',
|
||||
upload: subData.upload || '0',
|
||||
used: subData.used || '0',
|
||||
total: subData.total || '∞',
|
||||
remained: subData.remained || '',
|
||||
datepicker: subData.datepicker || 'gregorian',
|
||||
};
|
||||
|
||||
const isUnlimited = totalByte <= 0 && expireMs === 0;
|
||||
const isActive = (() => {
|
||||
if (!enabled) return false;
|
||||
if (totalByte > 0) {
|
||||
const usedByteCalc =
|
||||
Number(subData.usedByte || 0) ||
|
||||
Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0);
|
||||
if (usedByteCalc >= totalByte) return false;
|
||||
}
|
||||
if (expireMs > 0 && Date.now() >= expireMs) return false;
|
||||
return true;
|
||||
})();
|
||||
const apps = buildSubApps({ subUrl, sId, subTitle });
|
||||
const initialPlatform = detectPlatform(navigator.userAgent);
|
||||
const RTL_LANGUAGES = new Set(['fa-IR', 'ar-EG']);
|
||||
|
||||
// The sub page runs its own violet accent, so every antd control on it picks the
|
||||
// hue up instead of the panel blue useTheme pins. Mirrored in SubPage.css.
|
||||
const ACCENT = {
|
||||
light: {
|
||||
primary: '#7c3aed',
|
||||
hover: '#8b5cf6',
|
||||
active: '#6d28d9',
|
||||
rail: 'rgba(124, 58, 237, 0.16)',
|
||||
},
|
||||
dark: {
|
||||
primary: '#a78bfa',
|
||||
hover: '#c4b5fd',
|
||||
active: '#8b5cf6',
|
||||
rail: 'rgba(167, 139, 250, 0.18)',
|
||||
},
|
||||
};
|
||||
|
||||
export default function SubPage() {
|
||||
const { t } = useTranslation();
|
||||
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
|
||||
const { isDark, isUltra, antdThemeConfig } = useTheme();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
useEffect(() => {
|
||||
setMessageInstance(messageApi);
|
||||
}, [messageApi]);
|
||||
const { isMobile } = useMediaQuery(576);
|
||||
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage('subscription'));
|
||||
|
||||
const onLangChange = useCallback((next: string) => {
|
||||
@@ -100,538 +91,124 @@ export default function SubPage() {
|
||||
LanguageManager.setLanguage(next, 'subscription');
|
||||
}, []);
|
||||
|
||||
const cycleTheme = useCallback(() => {
|
||||
pauseAnimationsUntilLeave('sub-theme-cycle');
|
||||
if (!isDark) {
|
||||
toggleTheme();
|
||||
if (isUltra) toggleUltra();
|
||||
} else if (!isUltra) {
|
||||
toggleUltra();
|
||||
} else {
|
||||
toggleUltra();
|
||||
toggleTheme();
|
||||
}
|
||||
}, [isDark, isUltra, toggleTheme, toggleUltra]);
|
||||
|
||||
const copy = useCallback(
|
||||
async (value: string) => {
|
||||
async (value: string, toast?: string) => {
|
||||
if (!value) return;
|
||||
const ok = await ClipboardManager.copyText(value);
|
||||
if (ok) messageApi.success(t('copied'));
|
||||
if (ok) messageApi.success(toast ?? t('copied'));
|
||||
},
|
||||
[t, messageApi],
|
||||
);
|
||||
|
||||
const copyAll = useCallback(async () => {
|
||||
if (links.length === 0) return;
|
||||
const allLinks = links.join('\n');
|
||||
const ok = await ClipboardManager.copyText(allLinks);
|
||||
if (ok) messageApi.success(t('subscription.copyAllConfigsCopied'));
|
||||
}, [t, messageApi]);
|
||||
|
||||
const open = useCallback((url: string) => {
|
||||
if (!url) return;
|
||||
window.open(url, '_blank');
|
||||
if (url) window.open(url, '_blank');
|
||||
}, []);
|
||||
|
||||
const shadowrocketUrl = useMemo(() => {
|
||||
if (!subUrl) return '';
|
||||
const separator = subUrl.includes('?') ? '&' : '?';
|
||||
const rawUrl = subUrl + separator + 'flag=shadowrocket';
|
||||
const base64Url = btoa(rawUrl);
|
||||
const remark = encodeURIComponent(subTitle || sId || 'Subscription');
|
||||
return `shadowrocket://add/sub://${base64Url}?remark=${remark}`;
|
||||
}, []);
|
||||
|
||||
const v2boxUrl = useMemo(
|
||||
() => `v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`,
|
||||
[],
|
||||
);
|
||||
const streisandUrl = useMemo(() => `streisand://import/${encodeURIComponent(subUrl)}`, []);
|
||||
const happUrl = useMemo(() => `happ://add/${subUrl}`, []);
|
||||
const incyUrl = useMemo(() => `incy://add/${subUrl}`, []);
|
||||
|
||||
const pageClass = useMemo(() => {
|
||||
const classes = ['subscription-page'];
|
||||
if (isDark) classes.push('is-dark');
|
||||
if (isUltra) classes.push('is-ultra');
|
||||
return classes.join(' ');
|
||||
}, [isDark, isUltra]);
|
||||
|
||||
const descriptionsItems = useMemo(() => {
|
||||
const items = [
|
||||
{ key: 'subId', label: t('subscription.subId'), children: sId },
|
||||
...(subEmail ? [{ key: 'email', label: t('subscription.email'), children: subEmail }] : []),
|
||||
{
|
||||
key: 'status',
|
||||
label: t('subscription.status'),
|
||||
children: !enabled ? (
|
||||
<Tag color="red">{t('subscription.inactive')}</Tag>
|
||||
) : isUnlimited ? (
|
||||
<Tag color="purple">{t('subscription.unlimited')}</Tag>
|
||||
) : (
|
||||
<Tag color={isActive ? 'green' : 'red'}>
|
||||
{isActive ? t('subscription.active') : t('subscription.inactive')}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{ key: 'down', label: t('subscription.downloaded'), children: download },
|
||||
{ key: 'up', label: t('subscription.uploaded'), children: upload },
|
||||
{ key: 'used', label: t('usage'), children: used },
|
||||
{ key: 'total', label: t('subscription.totalQuota'), children: total },
|
||||
];
|
||||
if (totalByte > 0) {
|
||||
items.push({ key: 'remained', label: t('remained'), children: remained });
|
||||
}
|
||||
items.push({
|
||||
key: 'lastOnline',
|
||||
label: t('lastOnline'),
|
||||
children: lastOnlineMs > 0 ? IntlUtil.formatDate(lastOnlineMs, datepicker, lang) : '-',
|
||||
});
|
||||
items.push({
|
||||
key: 'expiry',
|
||||
label: t('subscription.expiry'),
|
||||
children:
|
||||
expireMs === 0
|
||||
? t('subscription.noExpiry')
|
||||
: IntlUtil.formatDate(expireMs, datepicker, lang),
|
||||
});
|
||||
return items;
|
||||
}, [t, lang]);
|
||||
|
||||
const androidMenuItems = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'android-v2box',
|
||||
label: 'V2Box',
|
||||
onClick: () =>
|
||||
open(
|
||||
`v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`,
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'android-v2rayng',
|
||||
label: 'V2RayNG',
|
||||
onClick: () => open(`v2rayng://install-config?url=${encodeURIComponent(subUrl)}`),
|
||||
},
|
||||
{ key: 'android-singbox', label: 'Sing-box', onClick: () => copy(subUrl) },
|
||||
{ key: 'android-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) },
|
||||
{ key: 'android-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) },
|
||||
{ key: 'android-happ', label: 'Happ', onClick: () => open(`happ://add/${subUrl}`) },
|
||||
{ key: 'android-incy', label: 'Incy', onClick: () => open(`incy://add/${subUrl}`) },
|
||||
],
|
||||
[copy, open],
|
||||
);
|
||||
|
||||
const iosMenuItems = useMemo(
|
||||
() => [
|
||||
{ key: 'ios-shadowrocket', label: 'Shadowrocket', onClick: () => open(shadowrocketUrl) },
|
||||
{ key: 'ios-v2box', label: 'V2Box', onClick: () => open(v2boxUrl) },
|
||||
{ key: 'ios-streisand', label: 'Streisand', onClick: () => open(streisandUrl) },
|
||||
{ key: 'ios-v2raytun', label: 'V2RayTun', onClick: () => copy(subUrl) },
|
||||
{ key: 'ios-npvtunnel', label: 'NPV Tunnel', onClick: () => copy(subUrl) },
|
||||
{ key: 'ios-happ', label: 'Happ', onClick: () => open(happUrl) },
|
||||
{ key: 'ios-incy', label: 'Incy', onClick: () => open(incyUrl) },
|
||||
],
|
||||
[copy, open, shadowrocketUrl, v2boxUrl, streisandUrl, happUrl, incyUrl],
|
||||
);
|
||||
|
||||
const langMenuItems = useMemo(
|
||||
() =>
|
||||
(LanguageManager.supportedLanguages as { value: string; name: string; icon: string }[]).map(
|
||||
(l) => ({
|
||||
key: l.value,
|
||||
label: (
|
||||
<Space size={8}>
|
||||
<span aria-hidden="true">{l.icon}</span>
|
||||
<span>{l.name}</span>
|
||||
</Space>
|
||||
),
|
||||
}),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const themeIcon = !isDark ? <SunOutlined /> : !isUltra ? <MoonOutlined /> : <MoonFilled />;
|
||||
|
||||
const cardTitle = (
|
||||
<Space>
|
||||
<span>{t('subscription.title')}</span>
|
||||
<Tag>{sId}</Tag>
|
||||
</Space>
|
||||
);
|
||||
|
||||
const cardExtra = (
|
||||
<Space size={8} align="center">
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
className="toolbar-btn"
|
||||
aria-label={t('menu.theme')}
|
||||
title={t('menu.theme')}
|
||||
icon={themeIcon}
|
||||
onClick={cycleTheme}
|
||||
/>
|
||||
<Popover
|
||||
rootClassName={isDark ? 'dark' : 'light'}
|
||||
placement="bottomRight"
|
||||
trigger="click"
|
||||
styles={{ content: { padding: 4 } }}
|
||||
content={
|
||||
<Menu
|
||||
mode="vertical"
|
||||
selectable
|
||||
selectedKeys={[lang]}
|
||||
items={langMenuItems}
|
||||
onClick={({ key }) => onLangChange(key)}
|
||||
style={{ border: 'none', minWidth: 160 }}
|
||||
const tabs = useMemo(() => {
|
||||
const items: NonNullable<TabsProps['items']> = [];
|
||||
if (subUrl || subJsonUrl || subClashUrl) {
|
||||
items.push({
|
||||
key: 'subscription',
|
||||
icon: <LinkOutlined />,
|
||||
label: t('subscription.tabLinks'),
|
||||
children: (
|
||||
<SubLinksTab
|
||||
subUrl={subUrl}
|
||||
subJsonUrl={subJsonUrl}
|
||||
subClashUrl={subClashUrl}
|
||||
onCopy={copy}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
shape="circle"
|
||||
size="large"
|
||||
className="toolbar-btn"
|
||||
aria-label={t('pages.settings.language')}
|
||||
icon={<TranslationOutlined />}
|
||||
/>
|
||||
</Popover>
|
||||
</Space>
|
||||
);
|
||||
),
|
||||
});
|
||||
}
|
||||
if (subUrl) {
|
||||
items.push({
|
||||
key: 'apps',
|
||||
icon: <AppstoreOutlined />,
|
||||
label: t('subscription.tabApps'),
|
||||
children: <SubAppsTab apps={apps} initialPlatform={initialPlatform} onOpen={open} />,
|
||||
});
|
||||
}
|
||||
if (links.length > 0) {
|
||||
items.push({
|
||||
key: 'configs',
|
||||
icon: <UnorderedListOutlined />,
|
||||
label: (
|
||||
<>
|
||||
{t('subscription.tabConfigs')}
|
||||
<span className="sub-tab-count">{links.length}</span>
|
||||
</>
|
||||
),
|
||||
children: <SubConfigsTab links={links} onCopy={copy} />,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}, [t, copy, open]);
|
||||
|
||||
const direction = RTL_LANGUAGES.has(lang) ? 'rtl' : 'ltr';
|
||||
const pageClass = ['subscription-page', isDark && 'is-dark', isUltra && 'is-ultra']
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const themeConfig = useMemo(() => {
|
||||
const accent = isDark ? ACCENT.dark : ACCENT.light;
|
||||
const primary = {
|
||||
colorPrimary: accent.primary,
|
||||
colorPrimaryHover: accent.hover,
|
||||
colorPrimaryActive: accent.active,
|
||||
};
|
||||
return {
|
||||
...antdThemeConfig,
|
||||
token: {
|
||||
...antdThemeConfig.token,
|
||||
...primary,
|
||||
colorLink: accent.primary,
|
||||
colorInfo: accent.primary,
|
||||
},
|
||||
components: {
|
||||
...antdThemeConfig.components,
|
||||
Button: { ...antdThemeConfig.components?.Button, ...primary },
|
||||
Progress: { ...antdThemeConfig.components?.Progress, remainingColor: accent.rail },
|
||||
},
|
||||
};
|
||||
}, [antdThemeConfig, isDark]);
|
||||
|
||||
return (
|
||||
<ConfigProvider theme={antdThemeConfig}>
|
||||
<ConfigProvider theme={themeConfig} direction={direction}>
|
||||
{messageContextHolder}
|
||||
<Layout className={pageClass}>
|
||||
<Layout.Content className="content">
|
||||
<Row justify="center">
|
||||
<Col xs={24} sm={22} md={18} lg={14} xl={12}>
|
||||
<Card hoverable className="subscription-card" title={cardTitle} extra={cardExtra}>
|
||||
{announce && (
|
||||
<Alert type="info" showIcon title={announce} style={{ marginBottom: 16 }} />
|
||||
<Layout className={pageClass} dir={direction}>
|
||||
<div className="sub-aurora" aria-hidden="true">
|
||||
<span className="sub-aurora-grid" />
|
||||
</div>
|
||||
<Layout.Content className="sub-content">
|
||||
<Card className="sub-card">
|
||||
<SubHeader
|
||||
title={subTitle}
|
||||
sId={sId}
|
||||
email={clientEmail}
|
||||
lang={lang}
|
||||
onLangChange={onLangChange}
|
||||
/>
|
||||
{announce && <Alert type="info" showIcon title={announce} className="sub-announce" />}
|
||||
<SubHero {...heroData} lang={lang} />
|
||||
{tabs.length > 0 && <Tabs className="sub-tabs" tabBarGutter={24} items={tabs} />}
|
||||
{(updateHours > 0 || subSupportUrl) && (
|
||||
<footer className="sub-footer">
|
||||
{updateHours > 0 && (
|
||||
<span>
|
||||
<ClockCircleOutlined />
|
||||
{t('subscription.updateInterval', { hours: updateHours })}
|
||||
</span>
|
||||
)}
|
||||
<Descriptions
|
||||
bordered
|
||||
column={1}
|
||||
size="small"
|
||||
className="info-table"
|
||||
items={descriptionsItems}
|
||||
/>
|
||||
|
||||
<SubUsageSummary
|
||||
usedByte={
|
||||
Number(subData.usedByte || 0) ||
|
||||
Number(subData.downloadByte || 0) + Number(subData.uploadByte || 0)
|
||||
}
|
||||
totalByte={totalByte}
|
||||
usedLabel={used}
|
||||
totalLabel={total}
|
||||
remainedLabel={remained}
|
||||
expireMs={expireMs}
|
||||
isActive={isActive}
|
||||
/>
|
||||
|
||||
{(subUrl || subJsonUrl || subClashUrl) && (
|
||||
<>
|
||||
<Divider>{t('subscription.title')}</Divider>
|
||||
<div className="links-section">
|
||||
{subUrl && (
|
||||
<div className="sub-link-row">
|
||||
<Tag color="green" className="sub-link-tag">
|
||||
SUB
|
||||
</Tag>
|
||||
<a
|
||||
href={subUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sub-link-title sub-link-anchor"
|
||||
title={subUrl}
|
||||
>
|
||||
{sId}
|
||||
</a>
|
||||
<div className="sub-link-actions">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => copy(subUrl)}
|
||||
aria-label={t('copy')}
|
||||
title={t('copy')}
|
||||
/>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="left"
|
||||
destroyOnHidden
|
||||
content={
|
||||
<div className="sub-link-qr-popover">
|
||||
<Tag color="green" className="qr-tag">
|
||||
{t('pages.settings.subSettings')}
|
||||
</Tag>
|
||||
<QRCode
|
||||
value={subUrl}
|
||||
size={QR_SIZE}
|
||||
type="svg"
|
||||
bordered={false}
|
||||
color="#000000"
|
||||
bgColor="#ffffff"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<QrcodeOutlined />}
|
||||
aria-label="QR"
|
||||
title="QR"
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{subJsonUrl && (
|
||||
<div className="sub-link-row">
|
||||
<Tag color="purple" className="sub-link-tag">
|
||||
JSON
|
||||
</Tag>
|
||||
<a
|
||||
href={subJsonUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sub-link-title sub-link-anchor"
|
||||
title={subJsonUrl}
|
||||
>
|
||||
{sId}
|
||||
</a>
|
||||
<div className="sub-link-actions">
|
||||
<Button
|
||||
size="small"
|
||||
href={appendRawView(subJsonUrl)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
icon={<DownloadOutlined />}
|
||||
aria-label={t('download')}
|
||||
title={t('download')}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => copy(subJsonUrl)}
|
||||
aria-label={t('copy')}
|
||||
title={t('copy')}
|
||||
/>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="left"
|
||||
destroyOnHidden
|
||||
content={
|
||||
<div className="sub-link-qr-popover">
|
||||
<Tag color="purple" className="qr-tag">
|
||||
{t('pages.settings.subSettings')} JSON
|
||||
</Tag>
|
||||
<QRCode
|
||||
value={subJsonUrl}
|
||||
size={QR_SIZE}
|
||||
type="svg"
|
||||
bordered={false}
|
||||
color="#000000"
|
||||
bgColor="#ffffff"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<QrcodeOutlined />}
|
||||
aria-label="QR"
|
||||
title="QR"
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{subClashUrl && (
|
||||
<div className="sub-link-row">
|
||||
<Tooltip title="Clash / Mihomo">
|
||||
<Tag color="gold" className="sub-link-tag">
|
||||
CLASH
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
<a
|
||||
href={subClashUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="sub-link-title sub-link-anchor"
|
||||
title={subClashUrl}
|
||||
>
|
||||
{sId}
|
||||
</a>
|
||||
<div className="sub-link-actions">
|
||||
<Button
|
||||
size="small"
|
||||
href={appendRawView(subClashUrl)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
icon={<DownloadOutlined />}
|
||||
aria-label={t('download')}
|
||||
title={t('download')}
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => copy(subClashUrl)}
|
||||
aria-label={t('copy')}
|
||||
title={t('copy')}
|
||||
/>
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="left"
|
||||
destroyOnHidden
|
||||
content={
|
||||
<div className="sub-link-qr-popover">
|
||||
<Tag color="gold" className="qr-tag">
|
||||
Clash / Mihomo
|
||||
</Tag>
|
||||
<QRCode
|
||||
value={subClashUrl}
|
||||
size={QR_SIZE}
|
||||
type="svg"
|
||||
bordered={false}
|
||||
color="#000000"
|
||||
bgColor="#ffffff"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<QrcodeOutlined />}
|
||||
aria-label="QR"
|
||||
title="QR"
|
||||
/>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
{subSupportUrl && (
|
||||
<a href={subSupportUrl} target="_blank" rel="noopener noreferrer">
|
||||
<CustomerServiceOutlined />
|
||||
{t('subscription.support')}
|
||||
</a>
|
||||
)}
|
||||
|
||||
{links.length > 0 && (
|
||||
<>
|
||||
<Divider>{t('pages.inbounds.copyLink')}</Divider>
|
||||
<div className="links-section">
|
||||
<div className="sub-link-row">
|
||||
<span className="sub-link-title">{t('subscription.copyAllConfigs')}</span>
|
||||
<div className="sub-link-actions">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={copyAll}
|
||||
aria-label={t('subscription.copyAllConfigs')}
|
||||
title={t('subscription.copyAllConfigs')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{links.map((link, idx) => {
|
||||
const parts = parseLinkParts(link);
|
||||
const fallback = `Link ${idx + 1}`;
|
||||
const rowTitle = parts?.remark || fallback;
|
||||
const qrLabel = parts?.remark || rowTitle;
|
||||
const canQr = !isPostQuantumLink(link);
|
||||
const isWireguardLink =
|
||||
link.startsWith('wireguard://') || link.startsWith('wg://');
|
||||
const isAmneziawgLink = link.startsWith('vpn://');
|
||||
return (
|
||||
<Fragment key={link}>
|
||||
<div className="sub-link-row">
|
||||
{parts ? (
|
||||
<LinkTags parts={parts} />
|
||||
) : (
|
||||
<Tag className="sub-link-tag">LINK</Tag>
|
||||
)}
|
||||
<span className="sub-link-title" title={rowTitle}>
|
||||
{rowTitle}
|
||||
</span>
|
||||
<div className="sub-link-actions">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() => copy(link)}
|
||||
aria-label={t('copy')}
|
||||
title={t('copy')}
|
||||
/>
|
||||
{canQr && (
|
||||
<Popover
|
||||
trigger="click"
|
||||
placement="left"
|
||||
destroyOnHidden
|
||||
content={
|
||||
<div className="sub-link-qr-popover">
|
||||
<Tag className="qr-tag">{qrLabel}</Tag>
|
||||
<QRCode
|
||||
value={link}
|
||||
size={220}
|
||||
type="svg"
|
||||
bordered={false}
|
||||
color="#000000"
|
||||
bgColor="#ffffff"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<QrcodeOutlined />}
|
||||
aria-label="QR"
|
||||
title="QR"
|
||||
/>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{isWireguardLink && (
|
||||
<ConfigBlock
|
||||
label={t('pages.clients.wireguardConfig')}
|
||||
text={wireguardConfigFromLink(link, rowTitle)}
|
||||
fileName={`${rowTitle || 'peer'}.conf`}
|
||||
qrRemark={rowTitle}
|
||||
tagColor="cyan"
|
||||
/>
|
||||
)}
|
||||
{isAmneziawgLink && (
|
||||
<ConfigBlock
|
||||
label={t('pages.clients.amneziaWgConfig')}
|
||||
text={amneziawgConfigFromLink(link)}
|
||||
fileName={`${rowTitle || 'peer'}.conf`}
|
||||
qrRemark={rowTitle}
|
||||
tagColor="purple"
|
||||
/>
|
||||
)}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Row gutter={[8, 8]} justify="center" className="apps-row">
|
||||
<Col xs={24} sm={12} className="app-col">
|
||||
<Dropdown trigger={['click']} menu={{ items: androidMenuItems }}>
|
||||
<Button block={isMobile} size="large" type="primary">
|
||||
<AndroidOutlined /> Android <DownOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} className="app-col">
|
||||
<Dropdown trigger={['click']} menu={{ items: iosMenuItems }}>
|
||||
<Button block={isMobile} size="large" type="primary">
|
||||
<AppleOutlined /> iOS <DownOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</footer>
|
||||
)}
|
||||
</Card>
|
||||
</Layout.Content>
|
||||
</Layout>
|
||||
</ConfigProvider>
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Input, Modal, QRCode } from 'antd';
|
||||
import { CopyOutlined, DownloadOutlined, QrcodeOutlined } from '@ant-design/icons';
|
||||
|
||||
interface SubQrButtonProps {
|
||||
value: string;
|
||||
label: string;
|
||||
onCopy: (value: string) => void;
|
||||
}
|
||||
|
||||
export default function SubQrButton({ value, label, onCopy }: SubQrButtonProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const qrRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const saveQr = () => {
|
||||
const canvas = qrRef.current?.querySelector('canvas');
|
||||
if (!canvas) return;
|
||||
const link = document.createElement('a');
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.download = `${label || 'qrcode'}.png`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button icon={<QrcodeOutlined />} aria-label="QR" title="QR" onClick={() => setOpen(true)} />
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={() => setOpen(false)}
|
||||
footer={null}
|
||||
width={440}
|
||||
centered
|
||||
destroyOnHidden
|
||||
rootClassName="sub-qr-modal"
|
||||
title={t('subscription.qrTitle')}
|
||||
>
|
||||
<p className="sub-muted sub-qr-modal-hint">{t('subscription.qrHint')}</p>
|
||||
<div ref={qrRef} className="sub-qr-modal-code">
|
||||
<QRCode
|
||||
value={value}
|
||||
size={240}
|
||||
type="canvas"
|
||||
marginSize={2}
|
||||
bordered={false}
|
||||
color="#000000"
|
||||
bgColor="#ffffff"
|
||||
/>
|
||||
</div>
|
||||
<Input.TextArea
|
||||
className="sub-qr-modal-link"
|
||||
value={value}
|
||||
readOnly
|
||||
dir="ltr"
|
||||
autoSize={{ minRows: 2, maxRows: 5 }}
|
||||
/>
|
||||
<div className="sub-qr-modal-actions">
|
||||
<Button type="primary" size="large" icon={<CopyOutlined />} onClick={() => onCopy(value)}>
|
||||
{t('copy')}
|
||||
</Button>
|
||||
<Button size="large" icon={<DownloadOutlined />} onClick={saveQr}>
|
||||
{t('subscription.saveQr')}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
.usage-summary {
|
||||
margin-top: 12px;
|
||||
padding: 14px 16px;
|
||||
background: var(--ant-color-fill-alter);
|
||||
border: 1px solid var(--ant-color-border-secondary);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.usage-summary.is-inactive {
|
||||
opacity: 0.7;
|
||||
border-color: var(--ant-color-error-border);
|
||||
}
|
||||
|
||||
.usage-summary-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.usage-summary-labels {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 6px;
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.usage-summary-used {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
color: var(--ant-color-text);
|
||||
}
|
||||
|
||||
.usage-summary-sep {
|
||||
color: var(--ant-color-text-quaternary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.usage-summary-total {
|
||||
font-size: 14px;
|
||||
color: var(--ant-color-text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.usage-summary-chips {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.usage-summary-chips .ant-tag {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.usage-summary-bar.ant-progress {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.usage-summary-bar .ant-progress-outer {
|
||||
padding-inline-end: 0;
|
||||
}
|
||||
|
||||
.usage-summary-bar .ant-progress-inner {
|
||||
background: var(--ant-color-fill-secondary);
|
||||
}
|
||||
|
||||
.usage-summary-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: var(--ant-color-text-tertiary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.usage-summary-remained::before {
|
||||
content: '';
|
||||
}
|
||||
|
||||
.usage-summary-pct {
|
||||
font-weight: 600;
|
||||
color: var(--ant-color-text-secondary);
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Progress, Tag } from 'antd';
|
||||
import { ClockCircleOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||
|
||||
import './SubUsageSummary.css';
|
||||
|
||||
interface SubUsageSummaryProps {
|
||||
usedByte: number;
|
||||
totalByte: number;
|
||||
usedLabel: string;
|
||||
totalLabel: string;
|
||||
remainedLabel: string;
|
||||
expireMs: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
function pickStrokeColor(pct: number): { from: string; to: string } {
|
||||
if (pct >= 90) return { from: '#ff7875', to: '#ff4d4f' };
|
||||
if (pct >= 75) return { from: '#ffc53d', to: '#fa8c16' };
|
||||
return { from: '#5fc983', to: '#36b37e' };
|
||||
}
|
||||
|
||||
function formatExpiryChip(expireMs: number): { label: string; color: string } | null {
|
||||
if (expireMs <= 0) return null;
|
||||
const diff = expireMs - Date.now();
|
||||
if (diff <= 0) return { label: 'Expired', color: 'red' };
|
||||
const days = Math.floor(diff / 86400000);
|
||||
if (days >= 1) return { label: `${days}d`, color: days <= 3 ? 'orange' : 'blue' };
|
||||
const hours = Math.max(1, Math.floor(diff / 3600000));
|
||||
return { label: `${hours}h`, color: 'orange' };
|
||||
}
|
||||
|
||||
export default function SubUsageSummary({
|
||||
usedByte,
|
||||
totalByte,
|
||||
usedLabel,
|
||||
totalLabel,
|
||||
remainedLabel,
|
||||
expireMs,
|
||||
isActive,
|
||||
}: SubUsageSummaryProps) {
|
||||
const { t } = useTranslation();
|
||||
const pct = useMemo(() => {
|
||||
if (totalByte <= 0) return 0;
|
||||
const v = (usedByte / totalByte) * 100;
|
||||
if (!Number.isFinite(v)) return 0;
|
||||
return Math.max(0, Math.min(100, v));
|
||||
}, [usedByte, totalByte]);
|
||||
|
||||
const expiry = formatExpiryChip(expireMs);
|
||||
const isUnlimited = totalByte <= 0;
|
||||
const stroke = pickStrokeColor(pct);
|
||||
|
||||
return (
|
||||
<div className={`usage-summary ${!isActive ? 'is-inactive' : ''}`}>
|
||||
<div className="usage-summary-head">
|
||||
<div className="usage-summary-labels">
|
||||
<span className="usage-summary-used">{usedLabel}</span>
|
||||
<span className="usage-summary-sep">/</span>
|
||||
<span className="usage-summary-total">{isUnlimited ? '∞' : totalLabel}</span>
|
||||
</div>
|
||||
<div className="usage-summary-chips">
|
||||
{isUnlimited && (
|
||||
<Tag color="purple" icon={<ThunderboltOutlined />}>
|
||||
{t('subscription.unlimited')}
|
||||
</Tag>
|
||||
)}
|
||||
{expiry && (
|
||||
<Tag color={expiry.color} icon={<ClockCircleOutlined />}>
|
||||
{expiry.label}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{!isUnlimited && (
|
||||
<Progress
|
||||
percent={pct}
|
||||
showInfo={false}
|
||||
strokeColor={{ '0%': stroke.from, '100%': stroke.to }}
|
||||
railColor="var(--ant-color-fill-secondary)"
|
||||
strokeWidth={10}
|
||||
className="usage-summary-bar"
|
||||
/>
|
||||
)}
|
||||
<div className="usage-summary-foot">
|
||||
{!isUnlimited && (
|
||||
<>
|
||||
<span className="usage-summary-remained">{remainedLabel}</span>
|
||||
<span className="usage-summary-pct">{pct.toFixed(1)}%</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
# Subscription page app icons
|
||||
|
||||
## Line icons — Arcticons (CC BY-SA 4.0)
|
||||
|
||||
`happ.svg`, `sing-box.svg`, `v2rayng.svg` and `v2raytun.svg` come from
|
||||
[Arcticons](https://github.com/Arcticons-Team/Arcticons) by Donnnno and the
|
||||
Arcticons contributors (as listed on svgicons.com), licensed under
|
||||
[CC BY-SA 4.0](https://creativecommons.org/licenses/by-sa/4.0/).
|
||||
|
||||
Changes from the originals: the stroke colour is `currentColor` so the page can
|
||||
tint them to its theme, the stroke width is raised from 1 to 2 so they stay
|
||||
legible at tile size, and the style classes and ids were removed. These modified
|
||||
files are distributed under CC BY-SA 4.0 as well.
|
||||
|
||||
## App icons
|
||||
|
||||
The remaining files are each app's own icon, downscaled to 96 px, used only to
|
||||
identify the app a button opens. They remain the trademarks of their owners.
|
||||
|
||||
| File | Source |
|
||||
| ------------------- | ------------------------------------------------------------------------- |
|
||||
| `shadowrocket.webp` | App Store listing [id932747118](https://apps.apple.com/app/id932747118) |
|
||||
| `streisand.webp` | App Store listing [id6450534064](https://apps.apple.com/app/id6450534064) |
|
||||
| `v2box.webp` | App Store listing [id6446814690](https://apps.apple.com/app/id6446814690) |
|
||||
| `incy.webp` | App Store listing [id6756943388](https://apps.apple.com/app/id6756943388) |
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><polyline points="11.9387 18.636 11.2805 19.2322 13.2164 6.3148 21.6618 6.3148 21.082 10.1846"/><polyline points="20.2585 28.1999 18.113 42.5 9.6674 42.5 10.3198 38.1088"/><polyline points="28.1728 36.5585 27.2773 42.5 35.7228 42.5 37.9603 27.5876 36.0158 29.2999"/><polyline points="21.0144 27.3598 27.8555 27.3598 26.2122 38.3731 36.0158 29.2999 39.1998 8.0038 29.2593 17.99 28.8029 21.0714 27.2773 21.0714 26.4784 21.8658"/><polygon points="26.4784 21.8658 20.2243 28.1543 18.9689 28.1543 18.752 29.6379 8.8002 39.6355 11.9387 18.636 21.7423 9.5743 19.9047 21.8658 26.4784 21.8658"/><polyline points="38.0794 9.1294 38.6261 5.5 30.1808 5.5 28.1701 18.9112 29.2593 17.99"/></svg>
|
||||
|
After Width: | Height: | Size: 840 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user