Add skill for gameplay entry type workflows
This commit is contained in:
@@ -2,16 +2,22 @@ import type {
|
||||
AdminDebugHttpRequest,
|
||||
AdminDebugHttpResponse,
|
||||
AdminDisableProfileRedeemCodeRequest,
|
||||
AdminDisableProfileTaskConfigRequest,
|
||||
AdminLoginResponse,
|
||||
AdminMeResponse,
|
||||
AdminOverviewResponse,
|
||||
AdminUpsertProfileInviteCodeRequest,
|
||||
AdminUpsertProfileRedeemCodeRequest,
|
||||
AdminUpsertProfileTaskConfigRequest,
|
||||
ApiErrorEnvelope,
|
||||
ApiMeta,
|
||||
ApiSuccessEnvelope,
|
||||
ProfileInviteCodeAdminListResponse,
|
||||
ProfileInviteCodeAdminResponse,
|
||||
ProfileRedeemCodeAdminListResponse,
|
||||
ProfileRedeemCodeAdminResponse,
|
||||
ProfileTaskConfigAdminListResponse,
|
||||
ProfileTaskConfigAdminResponse,
|
||||
} from './adminApiTypes';
|
||||
|
||||
const API_RESPONSE_ENVELOPE_HEADER = 'x-genarrative-response-envelope';
|
||||
@@ -129,6 +135,13 @@ export function debugAdminHttp(token: string, payload: AdminDebugHttpRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
export function listProfileRedeemCodes(token: string) {
|
||||
return request<ProfileRedeemCodeAdminListResponse>(
|
||||
'/admin/api/profile/redeem-codes',
|
||||
{token},
|
||||
);
|
||||
}
|
||||
|
||||
export function upsertProfileRedeemCode(
|
||||
token: string,
|
||||
payload: AdminUpsertProfileRedeemCodeRequest,
|
||||
@@ -143,6 +156,13 @@ export function upsertProfileRedeemCode(
|
||||
);
|
||||
}
|
||||
|
||||
export function listProfileInviteCodes(token: string) {
|
||||
return request<ProfileInviteCodeAdminListResponse>(
|
||||
'/admin/api/profile/invite-codes',
|
||||
{token},
|
||||
);
|
||||
}
|
||||
|
||||
export function upsertProfileInviteCode(
|
||||
token: string,
|
||||
payload: AdminUpsertProfileInviteCodeRequest,
|
||||
@@ -171,6 +191,38 @@ export function disableProfileRedeemCode(
|
||||
);
|
||||
}
|
||||
|
||||
export function listProfileTaskConfigs(token: string) {
|
||||
return request<ProfileTaskConfigAdminListResponse>(
|
||||
'/admin/api/profile/tasks',
|
||||
{token},
|
||||
);
|
||||
}
|
||||
|
||||
export function upsertProfileTaskConfig(
|
||||
token: string,
|
||||
payload: AdminUpsertProfileTaskConfigRequest,
|
||||
) {
|
||||
return request<ProfileTaskConfigAdminResponse>('/admin/api/profile/tasks', {
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function disableProfileTaskConfig(
|
||||
token: string,
|
||||
payload: AdminDisableProfileTaskConfigRequest,
|
||||
) {
|
||||
return request<ProfileTaskConfigAdminResponse>(
|
||||
'/admin/api/profile/tasks/disable',
|
||||
{
|
||||
method: 'POST',
|
||||
token,
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value: string) {
|
||||
return value.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
@@ -106,6 +106,8 @@ export interface AdminDebugHttpResponse {
|
||||
}
|
||||
|
||||
export type ProfileRedeemCodeMode = 'public' | 'unique' | 'private';
|
||||
export type ProfileTaskCycle = 'daily';
|
||||
export type TrackingScopeKind = 'site' | 'work' | 'module' | 'user';
|
||||
|
||||
export interface AdminUpsertProfileRedeemCodeRequest {
|
||||
code: string;
|
||||
@@ -126,6 +128,23 @@ export interface AdminDisableProfileRedeemCodeRequest {
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface AdminUpsertProfileTaskConfigRequest {
|
||||
taskId: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
eventKey: string;
|
||||
cycle: ProfileTaskCycle;
|
||||
scopeKind: TrackingScopeKind;
|
||||
threshold: number;
|
||||
rewardPoints: number;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
}
|
||||
|
||||
export interface AdminDisableProfileTaskConfigRequest {
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
export interface ProfileRedeemCodeAdminResponse {
|
||||
code: string;
|
||||
mode: ProfileRedeemCodeMode;
|
||||
@@ -139,6 +158,10 @@ export interface ProfileRedeemCodeAdminResponse {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProfileRedeemCodeAdminListResponse {
|
||||
entries: ProfileRedeemCodeAdminResponse[];
|
||||
}
|
||||
|
||||
export interface ProfileInviteCodeAdminResponse {
|
||||
userId: string;
|
||||
inviteCode: string;
|
||||
@@ -146,3 +169,28 @@ export interface ProfileInviteCodeAdminResponse {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProfileInviteCodeAdminListResponse {
|
||||
entries: ProfileInviteCodeAdminResponse[];
|
||||
}
|
||||
|
||||
export interface ProfileTaskConfigAdminResponse {
|
||||
taskId: string;
|
||||
title: string;
|
||||
description: string;
|
||||
eventKey: string;
|
||||
cycle: ProfileTaskCycle;
|
||||
scopeKind: TrackingScopeKind;
|
||||
threshold: number;
|
||||
rewardPoints: number;
|
||||
enabled: boolean;
|
||||
sortOrder: number;
|
||||
createdBy: string;
|
||||
createdAt: string;
|
||||
updatedBy: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ProfileTaskConfigAdminListResponse {
|
||||
entries: ProfileTaskConfigAdminResponse[];
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
AdminSessionPayload,
|
||||
ProfileInviteCodeAdminResponse,
|
||||
ProfileRedeemCodeAdminResponse,
|
||||
ProfileTaskConfigAdminResponse,
|
||||
} from '../api/adminApiTypes';
|
||||
import {
|
||||
clearStoredAdminToken,
|
||||
@@ -21,6 +22,7 @@ import {AdminInviteCodePage} from '../pages/AdminInviteCodePage';
|
||||
import {AdminLoginPage} from '../pages/AdminLoginPage';
|
||||
import {AdminOverviewPage} from '../pages/AdminOverviewPage';
|
||||
import {AdminRedeemCodePage} from '../pages/AdminRedeemCodePage';
|
||||
import {AdminTaskConfigPage} from '../pages/AdminTaskConfigPage';
|
||||
import {AdminShell} from './AdminShell';
|
||||
import type {AdminRouteId} from './adminRoutes';
|
||||
import {resolveAdminRoute, routeHash} from './adminRoutes';
|
||||
@@ -40,6 +42,8 @@ export function AdminApp() {
|
||||
useState<ProfileRedeemCodeAdminResponse | null>(null);
|
||||
const [inviteResult, setInviteResult] =
|
||||
useState<ProfileInviteCodeAdminResponse | null>(null);
|
||||
const [taskConfigResult, setTaskConfigResult] =
|
||||
useState<ProfileTaskConfigAdminResponse | null>(null);
|
||||
|
||||
const clearSession = useCallback((message = '') => {
|
||||
clearStoredAdminToken();
|
||||
@@ -47,6 +51,7 @@ export function AdminApp() {
|
||||
setAdmin(null);
|
||||
setRedeemResult(null);
|
||||
setInviteResult(null);
|
||||
setTaskConfigResult(null);
|
||||
setStatus('guest');
|
||||
setLoginNotice(message);
|
||||
}, []);
|
||||
@@ -115,6 +120,7 @@ export function AdminApp() {
|
||||
setAdmin(response.admin);
|
||||
setRedeemResult(null);
|
||||
setInviteResult(null);
|
||||
setTaskConfigResult(null);
|
||||
setLoginNotice('');
|
||||
setStatus('authenticated');
|
||||
}, []);
|
||||
@@ -172,6 +178,14 @@ export function AdminApp() {
|
||||
onResultChange={setInviteResult}
|
||||
/>
|
||||
) : null}
|
||||
{routeId === 'tasks' ? (
|
||||
<AdminTaskConfigPage
|
||||
result={taskConfigResult}
|
||||
token={token}
|
||||
onUnauthorized={handleUnauthorized}
|
||||
onResultChange={setTaskConfigResult}
|
||||
/>
|
||||
) : null}
|
||||
</AdminShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
ShieldCheck,
|
||||
ListChecks,
|
||||
TicketCheck,
|
||||
TicketPercent,
|
||||
} from 'lucide-react';
|
||||
@@ -25,6 +26,7 @@ const routeIcons = {
|
||||
debug: Bug,
|
||||
redeem: TicketPercent,
|
||||
invite: TicketCheck,
|
||||
tasks: ListChecks,
|
||||
} satisfies Record<AdminRouteId, typeof LayoutDashboard>;
|
||||
|
||||
export function AdminShell({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type AdminRouteId = 'overview' | 'debug' | 'redeem' | 'invite';
|
||||
export type AdminRouteId = 'overview' | 'debug' | 'redeem' | 'invite' | 'tasks';
|
||||
|
||||
export interface AdminRouteDefinition {
|
||||
id: AdminRouteId;
|
||||
@@ -11,6 +11,7 @@ export const adminRoutes: AdminRouteDefinition[] = [
|
||||
{id: 'debug', label: 'API 调试', hash: '#debug'},
|
||||
{id: 'redeem', label: '兑换码', hash: '#redeem'},
|
||||
{id: 'invite', label: '邀请码', hash: '#invite'},
|
||||
{id: 'tasks', label: '任务配置', hash: '#tasks'},
|
||||
];
|
||||
|
||||
export function resolveAdminRoute(hash: string): AdminRouteId {
|
||||
|
||||
45
apps/admin-web/src/config/trackingEventDefinitions.ts
Normal file
45
apps/admin-web/src/config/trackingEventDefinitions.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type {TrackingScopeKind} from '../api/adminApiTypes';
|
||||
|
||||
export interface AdminTrackingEventDefinition {
|
||||
key: string;
|
||||
title: string;
|
||||
scopeKind: TrackingScopeKind;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export const adminTrackingEventDefinitions: AdminTrackingEventDefinition[] = [
|
||||
{
|
||||
key: 'daily_login',
|
||||
title: '每日登录',
|
||||
scopeKind: 'user',
|
||||
remark: '用户打开任务中心时由后端幂等记录,用于每日登录任务进度校验。',
|
||||
},
|
||||
];
|
||||
|
||||
export function findAdminTrackingEventDefinition(eventKey: string) {
|
||||
const normalizedEventKey = eventKey.trim();
|
||||
return (
|
||||
adminTrackingEventDefinitions.find(
|
||||
(definition) => definition.key === normalizedEventKey,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function filterAdminTrackingEventDefinitions(query: string) {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) {
|
||||
return adminTrackingEventDefinitions;
|
||||
}
|
||||
|
||||
return adminTrackingEventDefinitions.filter((definition) => {
|
||||
const haystack = [
|
||||
definition.key,
|
||||
definition.title,
|
||||
definition.scopeKind,
|
||||
definition.remark,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return haystack.includes(normalizedQuery);
|
||||
});
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import {Save} from 'lucide-react';
|
||||
import {FormEvent, useState} from 'react';
|
||||
import {RefreshCcw, Save} from 'lucide-react';
|
||||
import {FormEvent, useEffect, useState} from 'react';
|
||||
|
||||
import {upsertProfileInviteCode} from '../api/adminApiClient';
|
||||
import {
|
||||
listProfileInviteCodes,
|
||||
upsertProfileInviteCode,
|
||||
} from '../api/adminApiClient';
|
||||
import type {ProfileInviteCodeAdminResponse} from '../api/adminApiTypes';
|
||||
import {handlePageError} from './pageUtils';
|
||||
|
||||
@@ -21,7 +24,28 @@ export function AdminInviteCodePage({
|
||||
const [inviteCode, setInviteCode] = useState('');
|
||||
const [metadataText, setMetadataText] = useState('{}');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [listErrorMessage, setListErrorMessage] = useState('');
|
||||
const [entries, setEntries] = useState<ProfileInviteCodeAdminResponse[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshInviteCodes();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
async function refreshInviteCodes() {
|
||||
setIsLoading(true);
|
||||
setListErrorMessage('');
|
||||
try {
|
||||
const response = await listProfileInviteCodes(token);
|
||||
setEntries(response.entries);
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setListErrorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
@@ -37,6 +61,8 @@ export function AdminInviteCodePage({
|
||||
metadata: parseMetadata(metadataText),
|
||||
});
|
||||
onResultChange(response);
|
||||
upsertEntry(response);
|
||||
fillForm(response);
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
@@ -44,6 +70,28 @@ export function AdminInviteCodePage({
|
||||
}
|
||||
}
|
||||
|
||||
function upsertEntry(next: ProfileInviteCodeAdminResponse) {
|
||||
setEntries((current) => {
|
||||
const rest = current.filter((entry) => entry.inviteCode !== next.inviteCode);
|
||||
return [...rest, next].sort((left, right) => {
|
||||
const leftUpdatedAt = Date.parse(left.updatedAt);
|
||||
const rightUpdatedAt = Date.parse(right.updatedAt);
|
||||
if (Number.isFinite(leftUpdatedAt) && Number.isFinite(rightUpdatedAt)) {
|
||||
const updatedCompare = rightUpdatedAt - leftUpdatedAt;
|
||||
if (updatedCompare !== 0) {
|
||||
return updatedCompare;
|
||||
}
|
||||
}
|
||||
return left.inviteCode.localeCompare(right.inviteCode);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function fillForm(entry: ProfileInviteCodeAdminResponse) {
|
||||
setInviteCode(entry.inviteCode);
|
||||
setMetadataText(JSON.stringify(entry.metadata, null, 2));
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-page">
|
||||
<div className="admin-page-heading">
|
||||
@@ -51,8 +99,23 @@ export function AdminInviteCodePage({
|
||||
<h2>邀请码</h2>
|
||||
<p>注册链路预置码</p>
|
||||
</div>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={refreshInviteCodes}
|
||||
>
|
||||
<RefreshCcw size={17} aria-hidden="true" />
|
||||
<span>{isLoading ? '刷新中' : '刷新'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{listErrorMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{listErrorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="admin-two-column admin-two-column-wide">
|
||||
<form className="admin-panel admin-form" onSubmit={handleSave}>
|
||||
<label className="admin-field">
|
||||
@@ -90,42 +153,81 @@ export function AdminInviteCodePage({
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<section className="admin-panel admin-result-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>记录</h3>
|
||||
<span>{result?.inviteCode ?? '-'}</span>
|
||||
</div>
|
||||
{result ? (
|
||||
<dl className="admin-info-list">
|
||||
<div>
|
||||
<dt>User ID</dt>
|
||||
<dd>{result.userId}</dd>
|
||||
<div className="admin-stack">
|
||||
<section className="admin-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>邀请码列表</h3>
|
||||
<span>{entries.length}</span>
|
||||
</div>
|
||||
{entries.length ? (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table admin-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>邀请码</th>
|
||||
<th>创建</th>
|
||||
<th>更新</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.inviteCode}>
|
||||
<td>
|
||||
<button
|
||||
className="admin-text-button"
|
||||
type="button"
|
||||
onClick={() => fillForm(entry)}
|
||||
>
|
||||
{entry.inviteCode}
|
||||
</button>
|
||||
</td>
|
||||
<td>{entry.createdAt}</td>
|
||||
<td>{entry.updatedAt}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div>
|
||||
<dt>邀请码</dt>
|
||||
<dd>{result.inviteCode}</dd>
|
||||
) : (
|
||||
<div className="admin-empty-state">
|
||||
{isLoading ? '加载中' : '暂无邀请码'}
|
||||
</div>
|
||||
<div>
|
||||
<dt>创建</dt>
|
||||
<dd>{result.createdAt}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>更新</dt>
|
||||
<dd>{result.updatedAt}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Metadata</dt>
|
||||
<dd>
|
||||
<pre className="admin-code-block">
|
||||
{JSON.stringify(result.metadata, null, 2)}
|
||||
</pre>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : (
|
||||
<div className="admin-empty-state">暂无记录</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="admin-panel admin-result-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>记录</h3>
|
||||
<span>{result?.inviteCode ?? '-'}</span>
|
||||
</div>
|
||||
{result ? (
|
||||
<dl className="admin-info-list">
|
||||
<div>
|
||||
<dt>邀请码</dt>
|
||||
<dd>{result.inviteCode}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>创建</dt>
|
||||
<dd>{result.createdAt}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>更新</dt>
|
||||
<dd>{result.updatedAt}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Metadata</dt>
|
||||
<dd>
|
||||
<pre className="admin-code-block">
|
||||
{JSON.stringify(result.metadata, null, 2)}
|
||||
</pre>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : (
|
||||
<div className="admin-empty-state">暂无记录</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {PowerOff, Save} from 'lucide-react';
|
||||
import {FormEvent, useState} from 'react';
|
||||
import {PowerOff, RefreshCcw, Save} from 'lucide-react';
|
||||
import {FormEvent, useEffect, useState} from 'react';
|
||||
|
||||
import {
|
||||
disableProfileRedeemCode,
|
||||
listProfileRedeemCodes,
|
||||
upsertProfileRedeemCode,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
@@ -40,8 +41,29 @@ export function AdminRedeemCodePage({
|
||||
const [disableCode, setDisableCode] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [disableErrorMessage, setDisableErrorMessage] = useState('');
|
||||
const [listErrorMessage, setListErrorMessage] = useState('');
|
||||
const [entries, setEntries] = useState<ProfileRedeemCodeAdminResponse[]>([]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDisabling, setIsDisabling] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshRedeemCodes();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
async function refreshRedeemCodes() {
|
||||
setIsLoading(true);
|
||||
setListErrorMessage('');
|
||||
try {
|
||||
const response = await listProfileRedeemCodes(token);
|
||||
setEntries(response.entries);
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setListErrorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
@@ -63,6 +85,8 @@ export function AdminRedeemCodePage({
|
||||
mode === 'private' ? splitLines(allowedPublicUserCodes) : [],
|
||||
});
|
||||
onResultChange(response);
|
||||
upsertEntry(response);
|
||||
fillForm(response);
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
@@ -83,6 +107,8 @@ export function AdminRedeemCodePage({
|
||||
code: disableCode.trim(),
|
||||
});
|
||||
onResultChange(response);
|
||||
upsertEntry(response);
|
||||
fillForm(response);
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setDisableErrorMessage);
|
||||
} finally {
|
||||
@@ -90,6 +116,34 @@ export function AdminRedeemCodePage({
|
||||
}
|
||||
}
|
||||
|
||||
function upsertEntry(next: ProfileRedeemCodeAdminResponse) {
|
||||
setEntries((current) => {
|
||||
const rest = current.filter((entry) => entry.code !== next.code);
|
||||
return [...rest, next].sort((left, right) => {
|
||||
const leftUpdatedAt = Date.parse(left.updatedAt);
|
||||
const rightUpdatedAt = Date.parse(right.updatedAt);
|
||||
if (Number.isFinite(leftUpdatedAt) && Number.isFinite(rightUpdatedAt)) {
|
||||
const updatedCompare = rightUpdatedAt - leftUpdatedAt;
|
||||
if (updatedCompare !== 0) {
|
||||
return updatedCompare;
|
||||
}
|
||||
}
|
||||
return left.code.localeCompare(right.code);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function fillForm(entry: ProfileRedeemCodeAdminResponse) {
|
||||
setCode(entry.code);
|
||||
setMode(entry.mode);
|
||||
setRewardPoints(String(entry.rewardPoints));
|
||||
setMaxUses(String(entry.maxUses));
|
||||
setEnabled(entry.enabled);
|
||||
setAllowedUserIds(entry.allowedUserIds.join('\n'));
|
||||
setAllowedPublicUserCodes('');
|
||||
setDisableCode(entry.code);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-page">
|
||||
<div className="admin-page-heading">
|
||||
@@ -97,8 +151,23 @@ export function AdminRedeemCodePage({
|
||||
<h2>兑换码</h2>
|
||||
<p>创建、更新与停用</p>
|
||||
</div>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={refreshRedeemCodes}
|
||||
>
|
||||
<RefreshCcw size={17} aria-hidden="true" />
|
||||
<span>{isLoading ? '刷新中' : '刷新'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{listErrorMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{listErrorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="admin-two-column admin-two-column-wide">
|
||||
<form className="admin-panel admin-form" onSubmit={handleSave}>
|
||||
<div className="admin-form-row">
|
||||
@@ -200,6 +269,48 @@ export function AdminRedeemCodePage({
|
||||
</form>
|
||||
|
||||
<div className="admin-stack">
|
||||
<section className="admin-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>兑换码列表</h3>
|
||||
<span>{entries.length}</span>
|
||||
</div>
|
||||
{entries.length ? (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table admin-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Code</th>
|
||||
<th>奖励</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.code}>
|
||||
<td>
|
||||
<button
|
||||
className="admin-text-button"
|
||||
type="button"
|
||||
onClick={() => fillForm(entry)}
|
||||
>
|
||||
{entry.code}
|
||||
</button>
|
||||
<small>{redeemModeLabel(entry.mode)}</small>
|
||||
</td>
|
||||
<td>{entry.rewardPoints}</td>
|
||||
<td>{entry.enabled ? '启用' : '停用'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-empty-state">
|
||||
{isLoading ? '加载中' : '暂无兑换码'}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<form className="admin-panel admin-form" onSubmit={handleDisable}>
|
||||
<label className="admin-field">
|
||||
<span>停用 Code</span>
|
||||
@@ -273,3 +384,7 @@ function parsePositiveInteger(value: string) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
function redeemModeLabel(value: ProfileRedeemCodeMode) {
|
||||
return redeemModes.find((item) => item.value === value)?.label ?? value;
|
||||
}
|
||||
|
||||
545
apps/admin-web/src/pages/AdminTaskConfigPage.tsx
Normal file
545
apps/admin-web/src/pages/AdminTaskConfigPage.tsx
Normal file
@@ -0,0 +1,545 @@
|
||||
import {ChevronDown, PowerOff, RefreshCcw, Save} from 'lucide-react';
|
||||
import {FormEvent, useEffect, useMemo, useState} from 'react';
|
||||
|
||||
import {
|
||||
disableProfileTaskConfig,
|
||||
listProfileTaskConfigs,
|
||||
upsertProfileTaskConfig,
|
||||
} from '../api/adminApiClient';
|
||||
import type {
|
||||
ProfileTaskConfigAdminResponse,
|
||||
ProfileTaskCycle,
|
||||
TrackingScopeKind,
|
||||
} from '../api/adminApiTypes';
|
||||
import {
|
||||
filterAdminTrackingEventDefinitions,
|
||||
findAdminTrackingEventDefinition,
|
||||
} from '../config/trackingEventDefinitions';
|
||||
import {handlePageError} from './pageUtils';
|
||||
|
||||
interface AdminTaskConfigPageProps {
|
||||
token: string;
|
||||
result: ProfileTaskConfigAdminResponse | null;
|
||||
onUnauthorized: (message?: string) => void;
|
||||
onResultChange: (result: ProfileTaskConfigAdminResponse) => void;
|
||||
}
|
||||
|
||||
const taskCycles: Array<{value: ProfileTaskCycle; label: string}> = [
|
||||
{value: 'daily', label: '每日'},
|
||||
];
|
||||
|
||||
const scopeKinds: Array<{value: TrackingScopeKind; label: string}> = [
|
||||
{value: 'user', label: '用户'},
|
||||
{value: 'site', label: '整站'},
|
||||
{value: 'work', label: '作品'},
|
||||
{value: 'module', label: '模块'},
|
||||
];
|
||||
|
||||
export function AdminTaskConfigPage({
|
||||
token,
|
||||
result,
|
||||
onUnauthorized,
|
||||
onResultChange,
|
||||
}: AdminTaskConfigPageProps) {
|
||||
const [entries, setEntries] = useState<ProfileTaskConfigAdminResponse[]>([]);
|
||||
const [taskId, setTaskId] = useState('daily_login');
|
||||
const [title, setTitle] = useState('每日登录');
|
||||
const [description, setDescription] = useState('');
|
||||
const [eventKey, setEventKey] = useState('daily_login');
|
||||
const [eventKeySearch, setEventKeySearch] = useState('每日登录');
|
||||
const [isEventKeyPickerOpen, setIsEventKeyPickerOpen] = useState(false);
|
||||
const [cycle, setCycle] = useState<ProfileTaskCycle>('daily');
|
||||
const [scopeKind, setScopeKind] = useState<TrackingScopeKind>('user');
|
||||
const [threshold, setThreshold] = useState('1');
|
||||
const [rewardPoints, setRewardPoints] = useState('10');
|
||||
const [sortOrder, setSortOrder] = useState('10');
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [disableTaskId, setDisableTaskId] = useState('daily_login');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [disableErrorMessage, setDisableErrorMessage] = useState('');
|
||||
const [listErrorMessage, setListErrorMessage] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDisabling, setIsDisabling] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshTaskConfigs();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [token]);
|
||||
|
||||
const selectedEventDefinition = useMemo(
|
||||
() => findAdminTrackingEventDefinition(eventKey),
|
||||
[eventKey],
|
||||
);
|
||||
const filteredEventDefinitions = useMemo(
|
||||
() => filterAdminTrackingEventDefinitions(eventKeySearch),
|
||||
[eventKeySearch],
|
||||
);
|
||||
|
||||
async function refreshTaskConfigs() {
|
||||
setIsLoading(true);
|
||||
setListErrorMessage('');
|
||||
try {
|
||||
const response = await listProfileTaskConfigs(token);
|
||||
setEntries(response.entries);
|
||||
const dailyLogin = response.entries.find(
|
||||
(entry) => entry.taskId === 'daily_login',
|
||||
);
|
||||
if (dailyLogin) {
|
||||
fillForm(dailyLogin);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setListErrorMessage);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
setErrorMessage('');
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const response = await upsertProfileTaskConfig(token, {
|
||||
taskId: taskId.trim(),
|
||||
title: title.trim(),
|
||||
description,
|
||||
eventKey: eventKey.trim(),
|
||||
cycle,
|
||||
scopeKind,
|
||||
threshold: parsePositiveInteger(threshold),
|
||||
rewardPoints: parsePositiveInteger(rewardPoints),
|
||||
enabled,
|
||||
sortOrder: parseInteger(sortOrder),
|
||||
});
|
||||
onResultChange(response);
|
||||
upsertEntry(response);
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setErrorMessage);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisable(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (isDisabling) {
|
||||
return;
|
||||
}
|
||||
|
||||
setDisableErrorMessage('');
|
||||
setIsDisabling(true);
|
||||
try {
|
||||
const response = await disableProfileTaskConfig(token, {
|
||||
taskId: disableTaskId.trim(),
|
||||
});
|
||||
onResultChange(response);
|
||||
upsertEntry(response);
|
||||
fillForm(response);
|
||||
} catch (error: unknown) {
|
||||
handlePageError(error, onUnauthorized, setDisableErrorMessage);
|
||||
} finally {
|
||||
setIsDisabling(false);
|
||||
}
|
||||
}
|
||||
|
||||
function upsertEntry(next: ProfileTaskConfigAdminResponse) {
|
||||
setEntries((current) => {
|
||||
const rest = current.filter((entry) => entry.taskId !== next.taskId);
|
||||
return [...rest, next].sort((left, right) => {
|
||||
if (left.sortOrder !== right.sortOrder) {
|
||||
return left.sortOrder - right.sortOrder;
|
||||
}
|
||||
return left.taskId.localeCompare(right.taskId);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function fillForm(entry: ProfileTaskConfigAdminResponse) {
|
||||
setTaskId(entry.taskId);
|
||||
setTitle(entry.title);
|
||||
setDescription(entry.description);
|
||||
setEventKey(entry.eventKey);
|
||||
setCycle(entry.cycle);
|
||||
setScopeKind(entry.scopeKind);
|
||||
setThreshold(String(entry.threshold));
|
||||
setRewardPoints(String(entry.rewardPoints));
|
||||
setSortOrder(String(entry.sortOrder));
|
||||
setEnabled(entry.enabled);
|
||||
setDisableTaskId(entry.taskId);
|
||||
const nextDefinition = findAdminTrackingEventDefinition(entry.eventKey);
|
||||
setEventKeySearch(nextDefinition?.title ?? entry.eventKey);
|
||||
setIsEventKeyPickerOpen(false);
|
||||
}
|
||||
|
||||
function selectEventKey(nextEventKey: string) {
|
||||
const nextDefinition = findAdminTrackingEventDefinition(nextEventKey);
|
||||
setEventKey(nextEventKey);
|
||||
if (nextDefinition) {
|
||||
setEventKeySearch(nextDefinition.title);
|
||||
setScopeKind(nextDefinition.scopeKind);
|
||||
} else {
|
||||
setEventKeySearch(nextEventKey);
|
||||
}
|
||||
setIsEventKeyPickerOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="admin-page">
|
||||
<div className="admin-page-heading">
|
||||
<div>
|
||||
<h2>任务配置</h2>
|
||||
<p>默认每日登录任务</p>
|
||||
</div>
|
||||
<button
|
||||
className="admin-secondary-button"
|
||||
disabled={isLoading}
|
||||
type="button"
|
||||
onClick={refreshTaskConfigs}
|
||||
>
|
||||
<RefreshCcw size={17} aria-hidden="true" />
|
||||
<span>{isLoading ? '刷新中' : '刷新'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{listErrorMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{listErrorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="admin-two-column admin-two-column-wide">
|
||||
<form className="admin-panel admin-form" onSubmit={handleSave}>
|
||||
<div className="admin-form-row">
|
||||
<label className="admin-field admin-field-fill">
|
||||
<span>Task ID</span>
|
||||
<input
|
||||
value={taskId}
|
||||
onChange={(event) => setTaskId(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-switch-field">
|
||||
<input
|
||||
checked={enabled}
|
||||
type="checkbox"
|
||||
onChange={(event) => setEnabled(event.target.checked)}
|
||||
/>
|
||||
<span>启用</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<label className="admin-field">
|
||||
<span>标题</span>
|
||||
<input
|
||||
value={title}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>Event Key</span>
|
||||
<div
|
||||
className="admin-combobox"
|
||||
onBlur={(event) => {
|
||||
const nextTarget = event.relatedTarget;
|
||||
if (
|
||||
!(nextTarget instanceof Node) ||
|
||||
!event.currentTarget.contains(nextTarget)
|
||||
) {
|
||||
setIsEventKeyPickerOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="admin-combobox-control">
|
||||
<input
|
||||
aria-label="Event Key"
|
||||
value={eventKeySearch || eventKey}
|
||||
onChange={(event) => {
|
||||
const nextValue = event.target.value;
|
||||
setEventKeySearch(nextValue);
|
||||
setIsEventKeyPickerOpen(true);
|
||||
}}
|
||||
onFocus={() => setIsEventKeyPickerOpen(true)}
|
||||
/>
|
||||
<button
|
||||
aria-label="展开 Event Key"
|
||||
className="admin-combobox-toggle"
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setIsEventKeyPickerOpen((current) => !current)
|
||||
}
|
||||
>
|
||||
<ChevronDown size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
{isEventKeyPickerOpen ? (
|
||||
<div className="admin-combobox-menu" role="listbox">
|
||||
{filteredEventDefinitions.length ? (
|
||||
filteredEventDefinitions.map((definition) => (
|
||||
<button
|
||||
key={definition.key}
|
||||
className="admin-combobox-option"
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => selectEventKey(definition.key)}
|
||||
>
|
||||
<strong>{definition.title}</strong>
|
||||
<span>{definition.key}</span>
|
||||
<small>{definition.remark}</small>
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="admin-combobox-empty">
|
||||
没有匹配项
|
||||
{eventKeySearch.trim() ? (
|
||||
<button
|
||||
className="admin-text-button"
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() =>
|
||||
selectEventKey(eventKeySearch.trim())
|
||||
}
|
||||
>
|
||||
使用自定义 key
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{selectedEventDefinition ? (
|
||||
<small className="admin-field-note">
|
||||
{selectedEventDefinition.remark}
|
||||
</small>
|
||||
) : (
|
||||
<small className="admin-field-note">
|
||||
自定义埋点 key,保存前请确认后端已有对应埋点写入。
|
||||
</small>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="admin-field">
|
||||
<span>描述</span>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<label className="admin-field">
|
||||
<span>周期</span>
|
||||
<select
|
||||
value={cycle}
|
||||
onChange={(event) =>
|
||||
setCycle(event.target.value as ProfileTaskCycle)
|
||||
}
|
||||
>
|
||||
{taskCycles.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>埋点范围</span>
|
||||
<select
|
||||
value={scopeKind}
|
||||
onChange={(event) =>
|
||||
setScopeKind(event.target.value as TrackingScopeKind)
|
||||
}
|
||||
>
|
||||
{scopeKinds.map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="admin-form-row">
|
||||
<label className="admin-field">
|
||||
<span>完成阈值</span>
|
||||
<input
|
||||
min={1}
|
||||
step={1}
|
||||
type="number"
|
||||
value={threshold}
|
||||
onChange={(event) => setThreshold(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="admin-field">
|
||||
<span>奖励光点</span>
|
||||
<input
|
||||
min={1}
|
||||
step={1}
|
||||
type="number"
|
||||
value={rewardPoints}
|
||||
onChange={(event) => setRewardPoints(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="admin-field admin-field-compact">
|
||||
<span>排序</span>
|
||||
<input
|
||||
step={1}
|
||||
type="number"
|
||||
value={sortOrder}
|
||||
onChange={(event) => setSortOrder(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{errorMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{errorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
className="admin-primary-button"
|
||||
disabled={
|
||||
isSaving ||
|
||||
!taskId.trim() ||
|
||||
!title.trim() ||
|
||||
!eventKey.trim() ||
|
||||
!parsePositiveInteger(threshold) ||
|
||||
!parsePositiveInteger(rewardPoints)
|
||||
}
|
||||
type="submit"
|
||||
>
|
||||
<Save size={17} aria-hidden="true" />
|
||||
<span>{isSaving ? '保存中' : '保存'}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="admin-stack">
|
||||
<section className="admin-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>配置列表</h3>
|
||||
<span>{entries.length}</span>
|
||||
</div>
|
||||
{entries.length ? (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table admin-table-compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>任务</th>
|
||||
<th>奖励</th>
|
||||
<th>状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.taskId}>
|
||||
<td>
|
||||
<button
|
||||
className="admin-text-button"
|
||||
type="button"
|
||||
onClick={() => fillForm(entry)}
|
||||
>
|
||||
{entry.title || entry.taskId}
|
||||
</button>
|
||||
<small>{entry.taskId}</small>
|
||||
</td>
|
||||
<td>{entry.rewardPoints}</td>
|
||||
<td>{entry.enabled ? '启用' : '停用'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-empty-state">
|
||||
{isLoading ? '加载中' : '暂无配置'}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<form className="admin-panel admin-form" onSubmit={handleDisable}>
|
||||
<label className="admin-field">
|
||||
<span>停用 Task ID</span>
|
||||
<input
|
||||
value={disableTaskId}
|
||||
onChange={(event) => setDisableTaskId(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{disableErrorMessage ? (
|
||||
<div className="admin-alert" role="status">
|
||||
{disableErrorMessage}
|
||||
</div>
|
||||
) : null}
|
||||
<button
|
||||
className="admin-danger-button"
|
||||
disabled={isDisabling || !disableTaskId.trim()}
|
||||
type="submit"
|
||||
>
|
||||
<PowerOff size={17} aria-hidden="true" />
|
||||
<span>{isDisabling ? '停用中' : '停用'}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<section className="admin-panel admin-result-panel">
|
||||
<div className="admin-panel-heading">
|
||||
<h3>记录</h3>
|
||||
<span>{result?.taskId ?? '-'}</span>
|
||||
</div>
|
||||
{result ? (
|
||||
<dl className="admin-info-list">
|
||||
<div>
|
||||
<dt>Task ID</dt>
|
||||
<dd>{result.taskId}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Event Key</dt>
|
||||
<dd>{result.eventKey}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>奖励</dt>
|
||||
<dd>{result.rewardPoints}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>阈值</dt>
|
||||
<dd>{result.threshold}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>状态</dt>
|
||||
<dd>{result.enabled ? '启用' : '停用'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>更新人</dt>
|
||||
<dd>{result.updatedBy}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>更新</dt>
|
||||
<dd>{result.updatedAt}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : (
|
||||
<div className="admin-empty-state">暂无记录</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: string) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
|
||||
function parseInteger(value: string) {
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
@@ -321,6 +321,13 @@ button:disabled {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.admin-field-note {
|
||||
color: #667682;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.admin-field textarea {
|
||||
min-height: 112px;
|
||||
resize: vertical;
|
||||
@@ -333,6 +340,96 @@ button:disabled {
|
||||
box-shadow: 0 0 0 3px rgba(18, 110, 130, 0.16);
|
||||
}
|
||||
|
||||
.admin-combobox {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-combobox-control {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 38px;
|
||||
}
|
||||
|
||||
.admin-combobox-control input {
|
||||
border-top-right-radius: 0;
|
||||
border-bottom-right-radius: 0;
|
||||
}
|
||||
|
||||
.admin-combobox-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 42px;
|
||||
border: 1px solid #cbd8e0;
|
||||
border-left: 0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
color: #52616d;
|
||||
background: #fbfdfe;
|
||||
}
|
||||
|
||||
.admin-combobox:focus-within .admin-combobox-toggle {
|
||||
border-color: #126e82;
|
||||
box-shadow: 0 0 0 3px rgba(18, 110, 130, 0.16);
|
||||
}
|
||||
|
||||
.admin-combobox-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
display: grid;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
border: 1px solid #cbd8e0;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 16px 40px rgba(23, 33, 43, 0.14);
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
.admin-combobox-option {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
color: #17212b;
|
||||
background: transparent;
|
||||
padding: 9px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.admin-combobox-option:hover,
|
||||
.admin-combobox-option:focus-visible {
|
||||
background: #e7f3f5;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.admin-combobox-option span {
|
||||
color: #0f5666;
|
||||
font-family:
|
||||
"SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-combobox-option small,
|
||||
.admin-combobox-empty {
|
||||
color: #667682;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.admin-combobox-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.admin-switch-field {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -384,6 +481,16 @@ button:disabled {
|
||||
background: #eef3f6;
|
||||
}
|
||||
|
||||
.admin-text-button {
|
||||
display: inline;
|
||||
border: 0;
|
||||
color: #0f5666;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-alert {
|
||||
border: 1px solid #efc0bd;
|
||||
border-radius: 8px;
|
||||
@@ -443,6 +550,17 @@ button:disabled {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-table td small {
|
||||
display: block;
|
||||
margin-top: 3px;
|
||||
color: #667682;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.admin-table-compact {
|
||||
min-width: 360px;
|
||||
}
|
||||
|
||||
.admin-status {
|
||||
display: inline-flex;
|
||||
max-width: 460px;
|
||||
@@ -608,7 +726,7 @@ button:disabled {
|
||||
left: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(64px, 1fr));
|
||||
border-top: 1px solid #d8e2e8;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
|
||||
|
||||
@@ -15,10 +15,12 @@ export default defineConfig(({mode}) => {
|
||||
env.ADMIN_API_TARGET ||
|
||||
env.GENARRATIVE_API_TARGET ||
|
||||
`http://127.0.0.1:${env.GENARRATIVE_API_PORT || '3100'}`;
|
||||
const base = env.ADMIN_WEB_BASE || '/admin/';
|
||||
|
||||
return {
|
||||
root: adminWebRoot,
|
||||
envDir: repoRoot,
|
||||
base,
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
|
||||
Reference in New Issue
Block a user