();
+ for (const raw of value) {
+ if (typeof raw !== "string") {
+ continue;
+ }
+ const header = normalizeForwardedClientIpHeader(raw);
+ const key = header.toLowerCase();
+ if (!header || seen.has(key) || headers.length >= maxForwardedClientIpHeaders) {
+ continue;
+ }
+ seen.add(key);
+ headers.push(header);
+ }
+ return headers;
+}
+
+function removeForwardedClientIpHeader(header: string) {
+ form.forwarded_client_ip_headers = form.forwarded_client_ip_headers.filter(
+ (item) => item !== header,
+ );
+}
+
+function addForwardedClientIpHeader(raw: string): ForwardedClientIpHeaderResult {
+ const header = normalizeForwardedClientIpHeader(raw);
+ if (!header) {
+ return "invalid";
+ }
+ if (
+ form.forwarded_client_ip_headers.some(
+ (item) => item.toLowerCase() === header.toLowerCase(),
+ )
+ ) {
+ return "duplicate";
+ }
+ if (form.forwarded_client_ip_headers.length >= maxForwardedClientIpHeaders) {
+ return "full";
+ }
+ form.forwarded_client_ip_headers = [
+ ...form.forwarded_client_ip_headers,
+ header,
+ ];
+ return "added";
+}
+
+function showForwardedClientIpHeaderError(result: ForwardedClientIpHeaderResult) {
+ if (result === "invalid") {
+ appStore.showError(t("admin.settings.apiKeyAcl.forwardedClientIpHeaderInvalid"));
+ } else if (result === "full") {
+ appStore.showError(
+ t("admin.settings.apiKeyAcl.forwardedClientIpHeadersLimit", {
+ max: maxForwardedClientIpHeaders,
+ }),
+ );
+ }
+}
+
+function commitForwardedClientIpHeaderDraft() {
+ const draft = forwardedClientIpHeaderDraft.value;
+ if (!draft) {
+ return;
+ }
+ const result = addForwardedClientIpHeader(draft);
+ showForwardedClientIpHeaderError(result);
+ forwardedClientIpHeaderDraft.value = "";
+}
+
+function handleForwardedClientIpHeaderKeydown(event: KeyboardEvent) {
+ if (event.isComposing) {
+ return;
+ }
+ if (forwardedClientIpHeaderSeparatorKeys.has(event.key)) {
+ event.preventDefault();
+ commitForwardedClientIpHeaderDraft();
+ return;
+ }
+ if (
+ event.key === "Backspace" &&
+ !forwardedClientIpHeaderDraft.value &&
+ form.forwarded_client_ip_headers.length > 0
+ ) {
+ form.forwarded_client_ip_headers.pop();
+ }
+}
+
+function handleForwardedClientIpHeaderPaste(event: ClipboardEvent) {
+ const text = event.clipboardData?.getData("text") || "";
+ if (!text.trim()) {
+ return;
+ }
+ event.preventDefault();
+
+ let error: ForwardedClientIpHeaderResult | undefined;
+ for (const token of text.split(/[,,;\r\n]+/)) {
+ if (!token.trim()) {
+ continue;
+ }
+ const result = addForwardedClientIpHeader(token);
+ if (result === "invalid" || result === "full") {
+ error = result;
+ }
+ }
+ if (error) {
+ showForwardedClientIpHeaderError(error);
+ }
+}
+
// Quota notify email helpers
const addQuotaNotifyEmail = () => {
if (!form.account_quota_notify_emails) {
@@ -9354,6 +9553,10 @@ async function loadSettings() {
normalizeRegistrationEmailSuffixDomains(
settings.registration_email_suffix_whitelist,
);
+ form.forwarded_client_ip_headers = normalizeForwardedClientIpHeaders(
+ settings.forwarded_client_ip_headers,
+ );
+ forwardedClientIpHeaderDraft.value = "";
tablePageSizeOptionsInput.value = formatTablePageSizeOptions(
Array.isArray(settings.table_page_size_options)
? settings.table_page_size_options
@@ -9595,6 +9798,9 @@ async function saveSettings() {
form.login_agreement_mode =
form.login_agreement_mode === "checkbox" ? "checkbox" : "modal";
form.login_agreement_documents = normalizedLoginAgreementDocuments;
+ form.forwarded_client_ip_headers = normalizeForwardedClientIpHeaders(
+ form.forwarded_client_ip_headers,
+ );
const normalizedDefaultSubscriptions = normalizeDefaultSubscriptionSettings(
form.default_subscriptions,
@@ -9728,6 +9934,7 @@ async function saveSettings() {
turnstile_site_key: form.turnstile_site_key,
turnstile_secret_key: form.turnstile_secret_key || undefined,
api_key_acl_trust_forwarded_ip: form.api_key_acl_trust_forwarded_ip,
+ forwarded_client_ip_headers: form.forwarded_client_ip_headers,
linuxdo_connect_enabled: form.linuxdo_connect_enabled,
linuxdo_connect_client_id: form.linuxdo_connect_client_id,
linuxdo_connect_client_secret:
@@ -9993,6 +10200,10 @@ async function saveSettings() {
normalizeRegistrationEmailSuffixDomains(
updated.registration_email_suffix_whitelist,
);
+ form.forwarded_client_ip_headers = normalizeForwardedClientIpHeaders(
+ updated.forwarded_client_ip_headers,
+ );
+ forwardedClientIpHeaderDraft.value = "";
tablePageSizeOptionsInput.value = formatTablePageSizeOptions(
Array.isArray(updated.table_page_size_options)
? updated.table_page_size_options
diff --git a/frontend/src/views/admin/SubscriptionsView.vue b/frontend/src/views/admin/SubscriptionsView.vue
index 3f6e73a8cd..85ae5f0a80 100644
--- a/frontend/src/views/admin/SubscriptionsView.vue
+++ b/frontend/src/views/admin/SubscriptionsView.vue
@@ -351,7 +351,7 @@
: 'text-gray-700 dark:text-gray-300'
"
>
- {{ formatDateOnly(value) }}
+ {{ formatDateTimeToMinute(value) }}
{{ getDaysRemaining(value) }} {{ t('admin.subscriptions.daysRemaining') }}
@@ -598,7 +598,7 @@
{{
extendingSubscription.expires_at
- ? formatDateOnly(extendingSubscription.expires_at)
+ ? formatDateTimeToMinute(extendingSubscription.expires_at)
: t('admin.subscriptions.noExpiration')
}}
@@ -764,7 +764,7 @@ import { adminAPI } from '@/api/admin'
import type { UserSubscription, Group, GroupPlatform, SubscriptionType } from '@/types'
import type { SimpleUser } from '@/api/admin/usage'
import type { Column } from '@/components/common/types'
-import { formatDateOnly } from '@/utils/format'
+import { formatDateTimeToMinute } from '@/utils/format'
import { getPersistedPageSize } from '@/composables/usePersistedPageSize'
import AppLayout from '@/components/layout/AppLayout.vue'
import TablePageLayout from '@/components/layout/TablePageLayout.vue'
diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts
index 8f73963362..e913a1249a 100644
--- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts
+++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts
@@ -367,6 +367,8 @@ const baseSettingsResponse = {
turnstile_enabled: false,
turnstile_site_key: "",
turnstile_secret_key_configured: false,
+ api_key_acl_trust_forwarded_ip: true,
+ forwarded_client_ip_headers: [],
linuxdo_connect_enabled: false,
linuxdo_connect_client_id: "",
linuxdo_connect_client_secret_configured: false,
@@ -652,6 +654,64 @@ describe("admin SettingsView payment visible method controls", () => {
expect(wrapper.text()).not.toContain("支付来源");
});
+ it("loads, edits, validates, and saves forwarded client-IP headers", async () => {
+ getSettings.mockResolvedValueOnce({
+ ...baseSettingsResponse,
+ api_key_acl_trust_forwarded_ip: false,
+ forwarded_client_ip_headers: ["cf-connecting-ip", "X-Real-IP"],
+ });
+ const wrapper = mountView();
+
+ await flushPromises();
+ await openSecurityTab(wrapper);
+
+ const card = wrapper
+ .findAll(".card")
+ .find((node) => node.text().includes("admin.settings.apiKeyAcl.title"));
+ expect(card).toBeDefined();
+ const toggle = card!.get('input[type="checkbox"]');
+ expect((toggle.element as HTMLInputElement).checked).toBe(false);
+ expect(card!.find('[data-testid="forwarded-client-ip-headers-input"]').exists()).toBe(false);
+
+ await toggle.setValue(true);
+ expect(card!.findAll('[data-testid="forwarded-client-ip-header-tag"]')).toHaveLength(2);
+ expect(card!.text()).toContain("Cf-Connecting-Ip");
+ expect(card!.text()).toContain("X-Real-Ip");
+ showError.mockClear();
+
+ const input = card!.get('[data-testid="forwarded-client-ip-headers-input"]');
+ await input.setValue("x-client-ip");
+ await input.trigger("keydown", { key: "Enter" });
+ await input.setValue("X-CLIENT-IP");
+ await input.trigger("keydown", { key: "Enter" });
+ await input.setValue("invalid header");
+ await input.trigger("keydown", { key: "Enter" });
+ expect(showError).toHaveBeenCalledTimes(1);
+ expect(card!.findAll('[data-testid="forwarded-client-ip-header-tag"]')).toHaveLength(3);
+
+ const realIpTag = card!
+ .findAll('[data-testid="forwarded-client-ip-header-tag"]')
+ .find((tag) => tag.text().includes("X-Real-Ip"));
+ expect(realIpTag).toBeDefined();
+ await realIpTag!.get("button").trigger("click");
+ expect(card!.text()).not.toContain("X-Real-Ip");
+
+ await toggle.setValue(false);
+ expect(card!.find('[data-testid="forwarded-client-ip-headers-input"]').exists()).toBe(false);
+ await toggle.setValue(true);
+ expect(card!.text()).toContain("X-Client-Ip");
+
+ await wrapper.find("form").trigger("submit.prevent");
+ await flushPromises();
+
+ expect(updateSettings).toHaveBeenCalledWith(
+ expect.objectContaining({
+ api_key_acl_trust_forwarded_ip: true,
+ forwarded_client_ip_headers: ["Cf-Connecting-Ip", "X-Client-Ip"],
+ }),
+ );
+ });
+
it("links payment guidance to README sections instead of removed payment docs", async () => {
const wrapper = mountView();
diff --git a/frontend/src/views/admin/settings/EmailTemplateEditor.vue b/frontend/src/views/admin/settings/EmailTemplateEditor.vue
index 5fcb371e6c..3e28ede484 100644
--- a/frontend/src/views/admin/settings/EmailTemplateEditor.vue
+++ b/frontend/src/views/admin/settings/EmailTemplateEditor.vue
@@ -286,6 +286,29 @@ const fallbackPlaceholders = [
"{{report_type}}",
"{{report_start_time}}",
"{{report_end_time}}",
+ "{{report_summary_display}}",
+ "{{report_detail_display}}",
+ "{{report_total_requests}}",
+ "{{report_success_count}}",
+ "{{report_sla_error_count}}",
+ "{{report_business_limited_count}}",
+ "{{report_sla}}",
+ "{{report_error_rate}}",
+ "{{report_upstream_error_rate}}",
+ "{{report_upstream_error_count_excl_429_529}}",
+ "{{report_upstream_429_count}}",
+ "{{report_upstream_529_count}}",
+ "{{report_latency_p50}}",
+ "{{report_latency_p99}}",
+ "{{report_ttft_p50}}",
+ "{{report_ttft_p99}}",
+ "{{report_tokens}}",
+ "{{report_qps_current}}",
+ "{{report_qps_peak}}",
+ "{{report_qps_avg}}",
+ "{{report_tps_current}}",
+ "{{report_tps_peak}}",
+ "{{report_tps_avg}}",
"{{report_html}}",
];
@@ -374,7 +397,7 @@ const eventDisplayMeta: Record = {
},
"ops.scheduled_report": {
label: "运维定时报表",
- timing: "运维日报、周报、错误摘要或账号健康报表到达配置的发送时间时发送。",
+ timing: "运维日报、周报、错误摘要或账号健康报表到达配置的发送时间时发送;日报和周报的完整指标均可在模板中编辑。",
categoryLabel: "运维",
},
};
@@ -437,7 +460,7 @@ const eventDisplayMetaEn: Record = {
},
"ops.scheduled_report": {
label: "Ops Scheduled Report",
- timing: "Sent when a configured daily, weekly, error digest, or account health report reaches its scheduled send time.",
+ timing: "Sent when a configured daily, weekly, error digest, or account health report reaches its scheduled send time. Every daily and weekly summary metric is editable in this template.",
categoryLabel: "Ops",
},
};
@@ -505,7 +528,9 @@ const selectedEventDescription = computed(() => {
});
const placeholderList = computed(() => {
- const combined = [...placeholders.value, ...fallbackPlaceholders];
+ const combined = placeholders.value.length
+ ? placeholders.value
+ : fallbackPlaceholders;
return Array.from(
new Set(
combined
diff --git a/frontend/src/views/user/SubscriptionsView.vue b/frontend/src/views/user/SubscriptionsView.vue
index b76f87582b..744529848a 100644
--- a/frontend/src/views/user/SubscriptionsView.vue
+++ b/frontend/src/views/user/SubscriptionsView.vue
@@ -256,7 +256,7 @@ import subscriptionsAPI from '@/api/subscriptions'
import type { UserSubscription } from '@/types'
import AppLayout from '@/components/layout/AppLayout.vue'
import Icon from '@/components/icons/Icon.vue'
-import { formatDateOnly } from '@/utils/format'
+import { formatDateTimeToMinute } from '@/utils/format'
import { hasPeakRate, formatPeakRateWindow, serverTimezoneLabel } from '@/utils/peak-rate'
import { platformBorderClass, platformBadgeClass, platformButtonClass, platformLabel } from '@/utils/platformColors'
import { getRemainingDurationParts, isOneTimeDailyQuota, type RemainingDurationParts } from '@/utils/subscriptionQuota'
@@ -322,7 +322,7 @@ function formatExpirationDate(expiresAt: string): string {
return t('userSubscriptions.status.expired')
}
- const dateStr = formatDateOnly(expires)
+ const dateStr = formatDateTimeToMinute(expires)
if (days === 0) {
return `${dateStr} (${t('common.today')})`