feat(ql3): add bounded panel bootstrap

This commit is contained in:
whyour
2026-09-02 11:30:42 +08:00
parent 83966a1287
commit 882ce07d7e
20 changed files with 1095 additions and 134 deletions
+64 -8
View File
@@ -18,6 +18,13 @@ import defaultProps from './defaultProps';
import './index.less';
import { init } from '../utils/init';
import WebSocketManager from '../utils/websocket';
import {
clearQingLong3Credential,
discoverQingLong3,
isQingLong3PanelSession,
qingLong3Capabilities,
qingLong3Credential,
} from '../utils/qinglong3';
export interface SharedContext {
headerStyle: React.CSSProperties;
@@ -38,6 +45,12 @@ interface TSystemInfo {
version: string;
changeLog: string;
changeLogLink: string;
ql3?: {
schemaVersion: 1;
mode: 'local';
profile: 'edge' | 'standalone';
capabilitiesPath: string;
};
}
export default function () {
@@ -48,7 +61,8 @@ export default function () {
const [loading, setLoading] = useState<boolean>(true);
const [systemInfo, setSystemInfo] = useState<TSystemInfo>();
const [siteTitle, setSiteTitle] = useState(
() => localStorage.getItem('qinglong_panel_title')?.trim() || intl.get('青龙'),
() =>
localStorage.getItem('qinglong_panel_title')?.trim() || intl.get('青龙'),
);
const [collapsed, setCollapsed] = useState(false);
const [initLoading, setInitLoading] = useState<boolean>(true);
@@ -61,6 +75,12 @@ export default function () {
} = DarkReader || {};
const logout = () => {
if (isQingLong3PanelSession()) {
clearQingLong3Credential();
setUser({});
history.push('/login');
return;
}
request.post(`${config.apiPrefix}user/logout`).then(() => {
localStorage.removeItem(config.authKey);
history.push('/login');
@@ -70,14 +90,27 @@ export default function () {
const getSystemInfo = () => {
request
.get(`${config.apiPrefix}system`)
.then(({ code, data }) => {
.then(async ({ code, data }) => {
if (code === 200) {
let qingLong3 = null;
if (data?.ql3?.capabilitiesPath === '/api/v3/capabilities') {
qingLong3 = await discoverQingLong3(
`${config.apiPrefix}v3/capabilities`,
);
}
setSystemInfo(data);
if (!data.isInitialized) {
history.push('/initialization');
} else {
init(data.version);
getUser();
if (!qingLong3 || qingLong3Credential()) {
getUser();
} else {
setLoading(false);
if (!['/login', '/error'].includes(location.pathname)) {
history.replace('/login');
}
}
}
}
})
@@ -94,7 +127,7 @@ export default function () {
if (code === 200 && data.username) {
setUser(data);
if (location.pathname === '/') {
history.push('/dashboard');
history.push(data?.ql3?.panelHome || '/dashboard');
}
}
needLoading && setLoading(false);
@@ -165,8 +198,10 @@ export default function () {
}, []);
useEffect(() => {
if (!systemInfo) return;
if (systemInfo.ql3 && !isQingLong3PanelSession()) return;
reloadSystemConfig();
}, []);
}, [systemInfo]);
useEffect(() => {
if (!['/login', '/initialization', '/error'].includes(location.pathname)) {
@@ -210,7 +245,7 @@ export default function () {
}, []);
useEffect(() => {
if (!user || !user.username) return;
if (!user || !user.username || isQingLong3PanelSession()) return;
const ws = WebSocketManager.getInstance(
`${window.location.origin}${
config.apiPrefix
@@ -222,6 +257,16 @@ export default function () {
};
}, [user]);
useEffect(() => {
if (
isQingLong3PanelSession() &&
user?.username &&
!['/login', '/error', '/crontab'].includes(location.pathname)
) {
history.replace('/crontab');
}
}, [location.pathname, user]);
useEffect(() => {
window.onload = () => {
const timing = performance.timing;
@@ -244,7 +289,7 @@ export default function () {
if (['/login', '/initialization', '/error'].includes(location.pathname)) {
if (systemInfo?.isInitialized && location.pathname === '/initialization') {
history.push('/dashboard');
history.push(qingLong3Capabilities() ? '/crontab' : '/dashboard');
}
if (systemInfo || location.pathname === '/error') {
@@ -282,6 +327,17 @@ export default function () {
},
],
};
const layoutProps = isQingLong3PanelSession()
? {
...defaultProps,
route: {
...defaultProps.route,
routes: defaultProps.route?.routes?.filter((item) =>
['/login', '/error', '/crontab'].includes(item.path || ''),
),
},
}
: defaultProps;
return loading ? (
<PageLoading />
) : (
@@ -393,7 +449,7 @@ export default function () {
</span>
</span>
)}
{...defaultProps}
{...layoutProps}
>
<Outlet
context={{
+146 -81
View File
@@ -4,6 +4,10 @@ import { getCommandScript, getCrontabsNextDate } from '@/utils';
import config from '@/utils/config';
import { diffTime } from '@/utils/date';
import { request } from '@/utils/http';
import {
isQingLong3PanelSession,
qingLong3Capabilities,
} from '@/utils/qinglong3';
import {
CheckCircleOutlined,
CheckOutlined,
@@ -66,6 +70,9 @@ const SHOW_TAB_COUNT = 10;
const Crontab = () => {
const { headerStyle, isPhone, theme } = useOutletContext<SharedContext>();
const qingLong3 = qingLong3Capabilities();
const qingLong3ReadOnly = qingLong3 !== null;
const qingLong3Authenticated = isQingLong3PanelSession();
const [allSubscriptions, setAllSubscriptions] = useState<any[]>([]);
const columns: ColumnProps<ICrontab>[] = [
{
@@ -79,16 +86,21 @@ const Crontab = () => {
style={{
wordBreak: 'break-all',
marginBottom: 0,
color: '#1890ff',
cursor: 'pointer',
color: qingLong3ReadOnly ? undefined : '#1890ff',
cursor: qingLong3ReadOnly ? 'default' : 'pointer',
}}
ellipsis={{ tooltip: text, rows: 2 }}
onClick={() => {
if (qingLong3ReadOnly) return;
setDetailCron(record);
setIsDetailModalVisible(true);
}}
>
<Link>{record.name || '-'}</Link>
{qingLong3ReadOnly ? (
record.name || '-'
) : (
<Link>{record.name || '-'}</Link>
)}
</Paragraph>
),
sorter: {
@@ -109,13 +121,17 @@ const Crontab = () => {
}}
ellipsis={{ tooltip: text, rows: 2 }}
>
<a
onClick={() => {
goToScriptManager(record);
}}
>
{text}
</a>
{qingLong3ReadOnly ? (
text
) : (
<a
onClick={() => {
goToScriptManager(record);
}}
>
{text}
</a>
)}
</Paragraph>
);
},
@@ -248,8 +264,8 @@ const Crontab = () => {
>
{record.last_execution_time
? dayjs(record.last_execution_time * 1000).format(
'YYYY-MM-DD HH:mm:ss',
)
'YYYY-MM-DD HH:mm:ss',
)
: '-'}
</span>
);
@@ -354,6 +370,19 @@ const Crontab = () => {
const tableRef = useRef<HTMLDivElement>(null);
const tableScrollHeight = useTableScrollHeight(tableRef);
const [activeKey, setActiveKey] = useState('');
const visibleColumns = qingLong3ReadOnly
? columns
.filter((column) =>
['name', 'command', 'status', 'schedule'].includes(
String(column.key || ''),
),
)
.map((column) => ({
...column,
sorter: undefined,
filters: undefined,
}))
: columns;
const goToScriptManager = (record: any) => {
const result = getCommandScript(record.command);
@@ -366,12 +395,20 @@ const Crontab = () => {
};
const getCrons = async (silent?: boolean) => {
if (qingLong3ReadOnly && !qingLong3Authenticated) {
if (!silent) setLoading(false);
return;
}
if (!silent) setLoading(true);
const { page = 1, size = 20, sorter, filters = {} } = pageConf;
let url = `${config.apiPrefix
}crons?searchValue=${searchText}&page=${page}&size=${size}&filters=${JSON.stringify(
filters,
)}`;
const effectiveSize = qingLong3ReadOnly
? Math.min(size, qingLong3?.limits.cronPageSize || 20)
: size;
const effectiveSearchText = qingLong3ReadOnly ? '' : searchText;
const serializedFilters = qingLong3ReadOnly
? encodeURIComponent('{}')
: JSON.stringify(filters);
let url = `${config.apiPrefix}crons?searchValue=${effectiveSearchText}&page=${page}&size=${effectiveSize}&filters=${serializedFilters}`;
if (sorter && sorter.column && sorter.order) {
url += `&sorter=${JSON.stringify({
field: sorter.column.key,
@@ -390,6 +427,11 @@ const Crontab = () => {
.then(async ({ code, data: _data }) => {
if (code === 200) {
const { data, total } = _data;
if (qingLong3ReadOnly) {
setValue(data);
setTotal(total);
return;
}
const subscriptions = await request.get(
`${config.apiPrefix}subscriptions?ids=${JSON.stringify([
...new Set(data.map((x) => x.sub_id).filter(Boolean)),
@@ -544,8 +586,9 @@ const Crontab = () => {
const enabledOrDisabledCron = (record: any, index: number) => {
Modal.confirm({
title: `确认${record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')
}`,
title: `确认${
record.isDisabled === 1 ? intl.get('启用') : intl.get('禁用')
}`,
content: (
<>
{intl.get('确认')}
@@ -560,7 +603,8 @@ const Crontab = () => {
onOk() {
request
.put(
`${config.apiPrefix}crons/${record.isDisabled === 1 ? 'enable' : 'disable'
`${config.apiPrefix}crons/${
record.isDisabled === 1 ? 'enable' : 'disable'
}`,
[record.id],
)
@@ -584,8 +628,9 @@ const Crontab = () => {
const pinOrUnPinCron = (record: any, index: number) => {
Modal.confirm({
title: `确认${record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
}`,
title: `确认${
record.isPinned === 1 ? intl.get('取消置顶') : intl.get('置顶')
}`,
content: (
<>
{intl.get('确认')}
@@ -600,7 +645,8 @@ const Crontab = () => {
onOk() {
request
.put(
`${config.apiPrefix}crons/${record.isPinned === 1 ? 'unpin' : 'pin'
`${config.apiPrefix}crons/${
record.isPinned === 1 ? 'unpin' : 'pin'
}`,
[record.id],
)
@@ -777,8 +823,8 @@ const Crontab = () => {
setPageConf({
page: current as number,
size: pageSize as number,
sorter,
filters,
sorter: qingLong3ReadOnly ? {} : sorter,
filters: qingLong3ReadOnly ? {} : filters,
});
localStorage.setItem('pageSize', String(pageSize));
};
@@ -824,10 +870,20 @@ const Crontab = () => {
setAllSubscriptions(data || []);
}
})
.catch(() => { });
.catch(() => {});
};
useEffect(() => {
if (qingLong3ReadOnly) {
setPageConf({
page: 1,
size: Math.min(20, qingLong3?.limits.cronPageSize || 20),
sorter: {},
filters: {},
});
setActiveKey('all');
return;
}
getCronViews();
getAllSubscriptions();
}, []);
@@ -931,56 +987,63 @@ const Crontab = () => {
<PageContainer
className="ql-container-wrapper crontab-wrapper ql-container-wrapper-has-tab"
title={intl.get('定时任务')}
extra={[
<Search
placeholder={intl.get('请输入名称或者关键词')}
style={{ width: 'auto' }}
enterButton
allowClear
loading={loading}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onSearch={onSearch}
/>,
<Button key="2" type="primary" onClick={() => addCron()}>
{intl.get('创建任务')}
</Button>,
]}
extra={
qingLong3ReadOnly
? [<Tag key="ql3-read-only">QingLong 3.0 · </Tag>]
: [
<Search
key="search"
placeholder={intl.get('请输入名称或者关键词')}
style={{ width: 'auto' }}
enterButton
allowClear
loading={loading}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
onSearch={onSearch}
/>,
<Button key="create" type="primary" onClick={() => addCron()}>
{intl.get('创建任务')}
</Button>,
]
}
header={{
style: headerStyle,
}}
>
<Tabs
defaultActiveKey="all"
size="small"
activeKey={activeKey}
tabPosition="top"
className={`crontab-view ${moreMenuActive ? 'more-active' : ''}`}
tabBarExtraContent={
<Dropdown
menu={menu}
trigger={['click']}
overlayStyle={{ minWidth: 200 }}
>
<div className={`view-more ${moreMenuActive ? 'active' : ''}`}>
<Space>
{intl.get('更多')}
<DownOutlined />
</Space>
<div className="ant-tabs-ink-bar ant-tabs-ink-bar-animated"></div>
</div>
</Dropdown>
}
onTabClick={tabClick}
items={[
...[...enabledCronViews].slice(0, SHOW_TAB_COUNT).map((x) => ({
key: x.id,
label: x.name,
})),
]}
/>
{!qingLong3ReadOnly && (
<Tabs
defaultActiveKey="all"
size="small"
activeKey={activeKey}
tabPosition="top"
className={`crontab-view ${moreMenuActive ? 'more-active' : ''}`}
tabBarExtraContent={
<Dropdown
menu={menu}
trigger={['click']}
overlayStyle={{ minWidth: 200 }}
>
<div className={`view-more ${moreMenuActive ? 'active' : ''}`}>
<Space>
{intl.get('更多')}
<DownOutlined />
</Space>
<div className="ant-tabs-ink-bar ant-tabs-ink-bar-animated"></div>
</div>
</Dropdown>
}
onTabClick={tabClick}
items={[
...[...enabledCronViews].slice(0, SHOW_TAB_COUNT).map((x) => ({
key: x.id,
label: x.name,
})),
]}
/>
)}
<div ref={tableRef}>
{selectedRowIds.length > 0 && (
{!qingLong3ReadOnly && selectedRowIds.length > 0 && (
<div style={{ marginBottom: 16 }}>
<Button
type="primary"
@@ -1042,7 +1105,7 @@ const Crontab = () => {
</div>
)}
<Table
columns={columns}
columns={visibleColumns}
sortDirections={['descend', 'ascend']}
pagination={{
current: pageConf.page,
@@ -1052,22 +1115,24 @@ const Crontab = () => {
total,
showTotal: (total: number, range: number[]) =>
`${range[0]}-${range[1]} 条/总共 ${total}`,
pageSizeOptions: [10, 20, 50, 100, 200, 500, total || 10000].sort(
(a, b) => a - b,
),
pageSizeOptions: qingLong3ReadOnly
? [10, 20, 50, qingLong3?.limits.cronPageSize || 64]
: [10, 20, 50, 100, 200, 500, total || 10000].sort(
(a, b) => a - b,
),
}}
dataSource={value}
rowKey="id"
size="middle"
scroll={{ x: 1200, y: tableScrollHeight }}
loading={loading}
rowSelection={rowSelection}
rowSelection={qingLong3ReadOnly ? undefined : rowSelection}
rowClassName={getRowClassName}
onChange={onPageChange}
components={isPhone || pageConf.size < 50 ? undefined : vt}
/>
</div>
{isLogModalVisible && (
{!qingLong3ReadOnly && isLogModalVisible && (
<CronLogModal
handleCancel={() => {
getCronDetail(logCron);
@@ -1076,10 +1141,10 @@ const Crontab = () => {
cron={logCron}
/>
)}
{isModalVisible && (
{!qingLong3ReadOnly && isModalVisible && (
<CronModal handleCancel={handleCancel} cron={editedCron} />
)}
{isLabelModalVisible && (
{!qingLong3ReadOnly && isLabelModalVisible && (
<CronLabelModal
handleCancel={(needUpdate?: boolean) => {
setIsLabelModalVisible(false);
@@ -1090,7 +1155,7 @@ const Crontab = () => {
ids={selectedRowIds}
/>
)}
{isDetailModalVisible && (
{!qingLong3ReadOnly && isDetailModalVisible && (
<CronDetailModal
handleCancel={(needUpdate?: boolean) => {
setIsDetailModalVisible(false);
@@ -1103,7 +1168,7 @@ const Crontab = () => {
isPhone={isPhone}
/>
)}
{isCreateViewModalVisible && (
{!qingLong3ReadOnly && isCreateViewModalVisible && (
<ViewCreateModal
handleCancel={(data) => {
setIsCreateViewModalVisible(false);
@@ -1111,7 +1176,7 @@ const Crontab = () => {
}}
/>
)}
{isViewManageModalVisible && (
{!qingLong3ReadOnly && isViewManageModalVisible && (
<ViewManageModal
cronViews={cronViews}
handleCancel={() => {
+84 -19
View File
@@ -17,24 +17,59 @@ import { useTheme } from '@/utils/hooks';
import { MobileOutlined } from '@ant-design/icons';
import { SharedContext } from '@/layouts';
import dayjs from 'dayjs';
import {
clearQingLong3Credential,
qingLong3Capabilities,
qingLong3Credential,
setQingLong3Credential,
} from '@/utils/qinglong3';
const FormItem = Form.Item;
const { Countdown } = Statistic;
const isDemoEnv = window.__ENV__DeployEnv === 'demo';
const Login = () => {
const { reloadUser } = useOutletContext<SharedContext>();
const { reloadSystemConfig, reloadUser } = useOutletContext<SharedContext>();
const [loading, setLoading] = useState(false);
const [waitTime, setWaitTime] = useState<any>();
const { theme } = useTheme();
const [twoFactor, setTwoFactor] = useState(false);
const [verifying, setVerifying] = useState(false);
const [loginInfo, setLoginInfo] = useState<any>();
const qingLong3 = qingLong3Capabilities();
const handleOk = (values: any) => {
setLoading(true);
setTwoFactor(false);
setWaitTime(null);
if (qingLong3) {
const token = String(values.credential || '').trim();
if (!setQingLong3Credential(token)) {
message.error('API Credential 格式无效');
setLoading(false);
return;
}
request
.get(`${config.apiPrefix}user`)
.then(({ code, data }: any) => {
if (code !== 200 || !data?.username) {
throw new TypeError('QL3 identity is unavailable');
}
notification.success({
message: 'QingLong 3.0 已连接',
description: `${data.username} · ${qingLong3.deployment.profile}`,
});
reloadSystemConfig();
reloadUser(true);
history.push('/crontab');
})
.catch(() => {
clearQingLong3Credential();
message.error('API Credential 验证失败');
})
.finally(() => setLoading(false));
return;
}
request
.post(`${config.apiPrefix}user/login`, {
username: values.username,
@@ -134,11 +169,13 @@ const Login = () => {
};
useEffect(() => {
const isAuth = localStorage.getItem(config.authKey);
const isAuth = qingLong3
? qingLong3Credential()
: localStorage.getItem(config.authKey);
if (isAuth) {
history.push('/dashboard');
history.push(qingLong3 ? '/crontab' : '/dashboard');
}
}, []);
}, [qingLong3]);
return (
<div className={styles.container}>
@@ -150,7 +187,11 @@ const Login = () => {
src="https://qn.whyour.cn/logo.png"
/>
<span className={styles.title}>
{twoFactor ? intl.get('两步验证') : config.siteName}
{twoFactor
? intl.get('两步验证')
: qingLong3
? 'QingLong 3.0'
: config.siteName}
</span>
</div>
</div>
@@ -186,20 +227,44 @@ const Login = () => {
</Form>
) : (
<Form layout="vertical" onFinish={handleOk}>
<FormItem name="username" label={intl.get('用户名')} hasFeedback>
<Input
placeholder={`${intl.get('用户名')}${
isDemoEnv ? ': admin' : ''
}`}
autoFocus
/>
</FormItem>
<FormItem name="password" label={intl.get('密码')} hasFeedback>
<Input
type="password"
placeholder={`${intl.get('密码')}${isDemoEnv ? ': 123' : ''}`}
/>
</FormItem>
{qingLong3 ? (
<FormItem
name="credential"
label="API Credential"
hasFeedback
rules={[{ required: true, message: '请输入 API Credential' }]}
extra="凭据只保存在当前页面内存;刷新或关闭页面后需要重新输入。"
>
<Input.Password
placeholder="ql3c_…"
autoComplete="off"
autoFocus
/>
</FormItem>
) : (
<>
<FormItem
name="username"
label={intl.get('用户名')}
hasFeedback
>
<Input
placeholder={`${intl.get('用户名')}${
isDemoEnv ? ': admin' : ''
}`}
autoFocus
/>
</FormItem>
<FormItem name="password" label={intl.get('密码')} hasFeedback>
<Input
type="password"
placeholder={`${intl.get('密码')}${
isDemoEnv ? ': 123' : ''
}`}
/>
</FormItem>
</>
)}
<Row>
{waitTime ? (
<Button type="primary" style={{ width: '100%' }} disabled>
+4 -1
View File
@@ -9,6 +9,7 @@ import axios, {
AxiosResponse,
InternalAxiosRequestConfig,
} from 'axios';
import { clearQingLong3Credential, qingLong3Credential } from './qinglong3';
export interface IResponseData {
code?: number;
@@ -49,6 +50,7 @@ const errorHandler = function (
} else if (responseStatus === 401) {
if (history.location.pathname !== '/login') {
message.error(intl.get('登录已过期,请重新登录'));
clearQingLong3Credential();
localStorage.removeItem(config.authKey);
history.push('/login');
}
@@ -93,7 +95,7 @@ const apiWhiteList = [
];
_request.interceptors.request.use((_config) => {
const token = localStorage.getItem(config.authKey);
const token = qingLong3Credential() || localStorage.getItem(config.authKey);
if (token && !apiWhiteList.includes(_config.url!)) {
_config.headers.Authorization = `Bearer ${token}`;
return _config;
@@ -107,6 +109,7 @@ _request.interceptors.response.use(async (response) => {
history.push('/error');
} else if (responseStatus === 401) {
if (history.location.pathname !== '/login') {
clearQingLong3Credential();
localStorage.removeItem(config.authKey);
history.push('/login');
}
+113
View File
@@ -0,0 +1,113 @@
export interface QingLong3Capabilities {
readonly schemaVersion: 1;
readonly product: 'qinglong3';
readonly version: string;
readonly deployment: Readonly<{
mode: 'local';
profile: 'edge' | 'standalone';
}>;
readonly authentication: Readonly<{
kind: 'api_credential';
transport: 'bearer';
persistence: 'memory_only';
loginEndpoint: null;
}>;
readonly panel: Readonly<{
bootstrap: true;
cronList: 'bounded_read_only';
legacyMutations: false;
legacyLogin: false;
subscriptions: false;
scripts: false;
environmentVariables: false;
webSocket: false;
}>;
readonly limits: Readonly<{
cronRows: number;
cronPageSize: number;
logChunkBytes: number;
}>;
}
const CREDENTIAL_PATTERN =
/^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/;
let capabilities: Readonly<QingLong3Capabilities> | null = null;
let credential: string | null = null;
function validCapabilities(value: any): value is QingLong3Capabilities {
const profile = value?.deployment?.profile;
return Boolean(
value?.schemaVersion === 1 &&
value?.product === 'qinglong3' &&
typeof value?.version === 'string' &&
value.version.startsWith('3.') &&
value?.deployment?.mode === 'local' &&
(profile === 'edge' || profile === 'standalone') &&
value?.authentication?.kind === 'api_credential' &&
value?.authentication?.transport === 'bearer' &&
value?.authentication?.persistence === 'memory_only' &&
value?.authentication?.loginEndpoint === null &&
value?.panel?.bootstrap === true &&
value?.panel?.cronList === 'bounded_read_only' &&
value?.panel?.legacyMutations === false &&
value?.panel?.legacyLogin === false &&
value?.panel?.subscriptions === false &&
value?.panel?.scripts === false &&
value?.panel?.environmentVariables === false &&
value?.panel?.webSocket === false &&
Number.isSafeInteger(value?.limits?.cronRows) &&
value.limits.cronRows >= 1 &&
value.limits.cronRows <= 256 &&
Number.isSafeInteger(value?.limits?.cronPageSize) &&
value.limits.cronPageSize >= 1 &&
value.limits.cronPageSize <= 64 &&
Number.isSafeInteger(value?.limits?.logChunkBytes) &&
value.limits.logChunkBytes >= 1 &&
value.limits.logChunkBytes <= 32 * 1024,
);
}
export async function discoverQingLong3(
endpoint: string,
): Promise<Readonly<QingLong3Capabilities> | null> {
try {
const response = await fetch(endpoint, {
method: 'GET',
cache: 'no-store',
credentials: 'omit',
redirect: 'error',
referrerPolicy: 'no-referrer',
headers: { accept: 'application/json' },
});
if (!response.ok) return null;
const value = await response.json();
if (!validCapabilities(value?.capabilities)) return null;
capabilities = Object.freeze(value.capabilities);
return capabilities;
} catch {
return null;
}
}
export function qingLong3Capabilities(): Readonly<QingLong3Capabilities> | null {
return capabilities;
}
export function setQingLong3Credential(value: string): boolean {
if (!CREDENTIAL_PATTERN.test(value)) return false;
credential = value;
return true;
}
export function qingLong3Credential(): string | null {
return credential;
}
export function clearQingLong3Credential(): void {
credential = null;
}
export function isQingLong3PanelSession(): boolean {
return capabilities !== null && credential !== null;
}