feat(users): integrate user info and services APIs into 3-tab detail panel with proper padding
This commit is contained in:
@ -29,7 +29,7 @@ ddev exec php artisan tinker storage/app/e2e-admin-seed.php
|
|||||||
| `pnpm build` / `pnpm preview` | production build / preview |
|
| `pnpm build` / `pnpm preview` | production build / preview |
|
||||||
| `pnpm typecheck` | `tsc --noEmit` |
|
| `pnpm typecheck` | `tsc --noEmit` |
|
||||||
| `pnpm test` | vitest (API client envelope/refresh/error tests) |
|
| `pnpm test` | vitest (API client envelope/refresh/error tests) |
|
||||||
| `pnpm gen:api` | regenerate `src/api/types.gen.ts` from the backend's scribe-admin OpenAPI spec (`../gig-platform/public/api-docs/admin/openapi.yaml`; run `ddev exec php artisan scribe:generate --config scribe-admin` there first) |
|
| `pnpm gen:api` | regenerate `src/api/types.gen.ts` from the backend's private admin OpenAPI spec (`../gig-platform/storage/app/api-docs/admin/openapi.yaml`; run `ddev artisan api-docs:generate` there first) |
|
||||||
|
|
||||||
## Deploy
|
## Deploy
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hookform/resolvers": "^5.4.0",
|
"@hookform/resolvers": "^5.4.0",
|
||||||
|
"@scalar/api-reference-react": "^0.9.61",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/react-devtools": "latest",
|
"@tanstack/react-devtools": "latest",
|
||||||
"@tanstack/react-query": "^5.101.2",
|
"@tanstack/react-query": "^5.101.2",
|
||||||
|
|||||||
3160
pnpm-lock.yaml
generated
3160
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@ -2,5 +2,5 @@ allowBuilds:
|
|||||||
esbuild: true
|
esbuild: true
|
||||||
lightningcss: true
|
lightningcss: true
|
||||||
sharp: true
|
sharp: true
|
||||||
|
vue-demi: true
|
||||||
workerd: true
|
workerd: true
|
||||||
|
|
||||||
|
|||||||
@ -4,7 +4,8 @@
|
|||||||
import { execSync } from 'node:child_process'
|
import { execSync } from 'node:child_process'
|
||||||
import { readFileSync, writeFileSync } from 'node:fs'
|
import { readFileSync, writeFileSync } from 'node:fs'
|
||||||
|
|
||||||
const SPEC = process.env.OPENAPI_SPEC ?? '../gig-platform/public/api-docs/admin/openapi.yaml'
|
const SPEC =
|
||||||
|
process.env.OPENAPI_SPEC ?? '../gig-platform/storage/app/api-docs/admin/openapi.yaml'
|
||||||
const OUT = 'src/api/types.gen.ts'
|
const OUT = 'src/api/types.gen.ts'
|
||||||
|
|
||||||
execSync(`pnpm exec openapi-typescript ${SPEC} -o ${OUT}`, { stdio: 'inherit' })
|
execSync(`pnpm exec openapi-typescript ${SPEC} -o ${OUT}`, { stdio: 'inherit' })
|
||||||
|
|||||||
@ -62,6 +62,22 @@ describe('api client', () => {
|
|||||||
expect((error as ApiError).errors?.slug[0]).toBe('slug 已存在')
|
expect((error as ApiError).errors?.slug[0]).toBe('slug 已存在')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('returns authenticated non-JSON responses as text', async () => {
|
||||||
|
useAuthStore.getState().setTokens({
|
||||||
|
token_type: 'Bearer',
|
||||||
|
expires_in: 3600,
|
||||||
|
access_token: 'token-1',
|
||||||
|
})
|
||||||
|
fetchMock.mockResolvedValueOnce(
|
||||||
|
new Response('openapi: 3.0.0\n', {
|
||||||
|
headers: { 'Content-Type': 'application/yaml' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
await expect(api.text('/api/admin/v1/api-docs/public')).resolves.toBe('openapi: 3.0.0\n')
|
||||||
|
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe('Bearer token-1')
|
||||||
|
})
|
||||||
|
|
||||||
it('refreshes once on 401 and retries with the new token', async () => {
|
it('refreshes once on 401 and retries with the new token', async () => {
|
||||||
useAuthStore.getState().setTokens({
|
useAuthStore.getState().setTokens({
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
|
|||||||
@ -119,6 +119,12 @@ export async function request<T>(path: string, options: RequestOptions = {}): Pr
|
|||||||
return (await res.json()) as T
|
return (await res.json()) as T
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function requestText(path: string, signal?: AbortSignal): Promise<string> {
|
||||||
|
const res = await rawRequest(path, { signal })
|
||||||
|
if (!res.ok) throw await parseError(res)
|
||||||
|
return res.text()
|
||||||
|
}
|
||||||
|
|
||||||
export const api = {
|
export const api = {
|
||||||
get: <T>(path: string, params?: ListParams, signal?: AbortSignal) =>
|
get: <T>(path: string, params?: ListParams, signal?: AbortSignal) =>
|
||||||
request<T>(path, { params, signal }),
|
request<T>(path, { params, signal }),
|
||||||
@ -128,4 +134,5 @@ export const api = {
|
|||||||
delete: <T>(path: string, body?: unknown) => request<T>(path, { method: 'DELETE', body }),
|
delete: <T>(path: string, body?: unknown) => request<T>(path, { method: 'DELETE', body }),
|
||||||
anonymousPost: <T>(path: string, body?: unknown) =>
|
anonymousPost: <T>(path: string, body?: unknown) =>
|
||||||
request<T>(path, { method: 'POST', body, anonymous: true }),
|
request<T>(path, { method: 'POST', body, anonymous: true }),
|
||||||
|
text: (path: string, signal?: AbortSignal) => requestText(path, signal),
|
||||||
}
|
}
|
||||||
|
|||||||
7
src/api/modules/api-docs.ts
Normal file
7
src/api/modules/api-docs.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import { api } from '@/api/client'
|
||||||
|
|
||||||
|
export type ApiDocument = 'public' | 'admin'
|
||||||
|
|
||||||
|
export function fetchApiDocument(document: ApiDocument, signal?: AbortSignal) {
|
||||||
|
return api.text(`/api/admin/v1/api-docs/${document}`, signal)
|
||||||
|
}
|
||||||
@ -1,5 +1,12 @@
|
|||||||
import { api } from '@/api/client'
|
import { api } from '@/api/client'
|
||||||
import type { ApiResponse, PaginatedResponse, PlatformUser, UserListParams } from '@/api/types'
|
import type {
|
||||||
|
ApiResponse,
|
||||||
|
PaginatedResponse,
|
||||||
|
PlatformUser,
|
||||||
|
UserListParams,
|
||||||
|
UserProfileInfo,
|
||||||
|
UserServicesResponse,
|
||||||
|
} from '@/api/types'
|
||||||
|
|
||||||
const BASE = '/api/admin/v1/users'
|
const BASE = '/api/admin/v1/users'
|
||||||
|
|
||||||
@ -14,3 +21,11 @@ export function fetchUserDetail(uid: string) {
|
|||||||
export function updateUserStatus(uid: string, status: boolean) {
|
export function updateUserStatus(uid: string, status: boolean) {
|
||||||
return api.put<ApiResponse<PlatformUser>>(`${BASE}/${uid}/status`, { status })
|
return api.put<ApiResponse<PlatformUser>>(`${BASE}/${uid}/status`, { status })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchUserInfo(uid: string) {
|
||||||
|
return api.get<ApiResponse<UserProfileInfo>>(`${BASE}/${uid}/info`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchUserServices(uid: string) {
|
||||||
|
return api.get<ApiResponse<UserServicesResponse>>(`${BASE}/${uid}/services`)
|
||||||
|
}
|
||||||
|
|||||||
@ -50,7 +50,7 @@ export interface paths {
|
|||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
/**
|
/**
|
||||||
* @example all
|
* @example archived
|
||||||
* @enum {string|null}
|
* @enum {string|null}
|
||||||
*/
|
*/
|
||||||
state?: "active" | "archived" | "trashed" | "all" | null;
|
state?: "active" | "archived" | "trashed" | "all" | null;
|
||||||
@ -4371,7 +4371,7 @@ export interface paths {
|
|||||||
/** @example architecto */
|
/** @example architecto */
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
/**
|
/**
|
||||||
* @example scheduled_booking
|
* @example manual_selection
|
||||||
* @enum {string|null}
|
* @enum {string|null}
|
||||||
*/
|
*/
|
||||||
dispatch_mode?: "instant_accept" | "quote" | "scheduled_booking" | "manual_selection" | null;
|
dispatch_mode?: "instant_accept" | "quote" | "scheduled_booking" | "manual_selection" | null;
|
||||||
@ -4879,12 +4879,12 @@ export interface paths {
|
|||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
/**
|
/**
|
||||||
* @example consumed
|
* @example confirmed
|
||||||
* @enum {string|null}
|
* @enum {string|null}
|
||||||
*/
|
*/
|
||||||
status?: "created" | "confirmed" | "started" | "consumed" | "paid" | "fulfilled" | "canceled" | null;
|
status?: "created" | "confirmed" | "started" | "consumed" | "paid" | "fulfilled" | "canceled" | null;
|
||||||
/**
|
/**
|
||||||
* @example UNPAID
|
* @example PAID
|
||||||
* @enum {string|null}
|
* @enum {string|null}
|
||||||
*/
|
*/
|
||||||
payment_status?: "UNPAID" | "PAID" | null;
|
payment_status?: "UNPAID" | "PAID" | null;
|
||||||
@ -5167,7 +5167,7 @@ export interface paths {
|
|||||||
* @enum {string|null}
|
* @enum {string|null}
|
||||||
*/
|
*/
|
||||||
status?: "offline" | "online" | "inactive" | "busy" | null;
|
status?: "offline" | "online" | "inactive" | "busy" | null;
|
||||||
/** @example false */
|
/** @example true */
|
||||||
is_disabled?: boolean | null;
|
is_disabled?: boolean | null;
|
||||||
/** @example false */
|
/** @example false */
|
||||||
is_verified?: boolean | null;
|
is_verified?: boolean | null;
|
||||||
@ -5448,12 +5448,12 @@ export interface paths {
|
|||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
/**
|
/**
|
||||||
* @example public
|
* @example friends
|
||||||
* @enum {string|null}
|
* @enum {string|null}
|
||||||
*/
|
*/
|
||||||
visibility?: "public" | "friends" | "private" | null;
|
visibility?: "public" | "friends" | "private" | null;
|
||||||
/**
|
/**
|
||||||
* @example blocked
|
* @example all
|
||||||
* @enum {string|null}
|
* @enum {string|null}
|
||||||
*/
|
*/
|
||||||
status?: "blocked" | "visible" | "all" | null;
|
status?: "blocked" | "visible" | "all" | null;
|
||||||
@ -5779,7 +5779,7 @@ export interface paths {
|
|||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
/**
|
/**
|
||||||
* @example pending
|
* @example all
|
||||||
* @enum {string|null}
|
* @enum {string|null}
|
||||||
*/
|
*/
|
||||||
status?: "pending" | "blocked" | "all" | null;
|
status?: "pending" | "blocked" | "all" | null;
|
||||||
@ -6158,7 +6158,7 @@ export interface paths {
|
|||||||
path: {
|
path: {
|
||||||
/**
|
/**
|
||||||
* @description The ID of the category.
|
* @description The ID of the category.
|
||||||
* @example 5a37db21-5a9a-4438-8b4a-f0c80fc4f29e
|
* @example 2912b591-09f0-4c14-9190-866abd51cbee
|
||||||
*/
|
*/
|
||||||
category_id: string;
|
category_id: string;
|
||||||
};
|
};
|
||||||
@ -6181,7 +6181,7 @@ export interface paths {
|
|||||||
path: {
|
path: {
|
||||||
/**
|
/**
|
||||||
* @description The ID of the category.
|
* @description The ID of the category.
|
||||||
* @example 5a37db21-5a9a-4438-8b4a-f0c80fc4f29e
|
* @example 2912b591-09f0-4c14-9190-866abd51cbee
|
||||||
*/
|
*/
|
||||||
category_id: string;
|
category_id: string;
|
||||||
};
|
};
|
||||||
@ -6204,7 +6204,7 @@ export interface paths {
|
|||||||
path: {
|
path: {
|
||||||
/**
|
/**
|
||||||
* @description The ID of the category.
|
* @description The ID of the category.
|
||||||
* @example 5a37db21-5a9a-4438-8b4a-f0c80fc4f29e
|
* @example 2912b591-09f0-4c14-9190-866abd51cbee
|
||||||
*/
|
*/
|
||||||
category_id: string;
|
category_id: string;
|
||||||
};
|
};
|
||||||
@ -6281,7 +6281,7 @@ export interface paths {
|
|||||||
path: {
|
path: {
|
||||||
/**
|
/**
|
||||||
* @description The ID of the category.
|
* @description The ID of the category.
|
||||||
* @example 5a37db21-5a9a-4438-8b4a-f0c80fc4f29e
|
* @example 2912b591-09f0-4c14-9190-866abd51cbee
|
||||||
*/
|
*/
|
||||||
category_id: string;
|
category_id: string;
|
||||||
/**
|
/**
|
||||||
@ -6312,7 +6312,7 @@ export interface paths {
|
|||||||
path: {
|
path: {
|
||||||
/**
|
/**
|
||||||
* @description The ID of the category.
|
* @description The ID of the category.
|
||||||
* @example 5a37db21-5a9a-4438-8b4a-f0c80fc4f29e
|
* @example 2912b591-09f0-4c14-9190-866abd51cbee
|
||||||
*/
|
*/
|
||||||
category_id: string;
|
category_id: string;
|
||||||
/**
|
/**
|
||||||
@ -6678,6 +6678,281 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/admin/v1/users/{uid}/info": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @example architecto */
|
||||||
|
uid: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* [用户信息查看] 用户资料详情
|
||||||
|
* @description 返回用户账号信息、个人资料、工作经历、教育经历、荣誉与地址。
|
||||||
|
*/
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @example architecto */
|
||||||
|
uid: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
/** @example true */
|
||||||
|
success?: boolean;
|
||||||
|
data?: {
|
||||||
|
user?: {
|
||||||
|
/** @example user */
|
||||||
|
kind?: string;
|
||||||
|
/** @example 01HQ8M4Z2A3B5C6D7E8F9G0H1I2J3K4L */
|
||||||
|
uid?: string;
|
||||||
|
/** @example admin@example.com */
|
||||||
|
email?: string;
|
||||||
|
/** @example true */
|
||||||
|
status?: boolean;
|
||||||
|
};
|
||||||
|
profile?: {
|
||||||
|
/** @example user_profile */
|
||||||
|
kind?: string;
|
||||||
|
/** @example 01HQ8M4Z2A3B5C6D7E8F9G0H1I2J3K4L */
|
||||||
|
user_uid?: string;
|
||||||
|
/** @example 张三 */
|
||||||
|
computed_fullname?: string;
|
||||||
|
};
|
||||||
|
/** @example [] */
|
||||||
|
careers?: unknown[];
|
||||||
|
/** @example [] */
|
||||||
|
educations?: unknown[];
|
||||||
|
/** @example [] */
|
||||||
|
honors?: unknown[];
|
||||||
|
/**
|
||||||
|
* @example [
|
||||||
|
* {
|
||||||
|
* "kind": "user_address",
|
||||||
|
* "contact_name": "张三",
|
||||||
|
* "contact_phone_number": "13800000000"
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
addresses?: {
|
||||||
|
/** @example user_address */
|
||||||
|
kind?: string;
|
||||||
|
/** @example 张三 */
|
||||||
|
contact_name?: string;
|
||||||
|
/** @example 13800000000 */
|
||||||
|
contact_phone_number?: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
/** @example false */
|
||||||
|
success?: boolean;
|
||||||
|
/** @example FORBIDDEN */
|
||||||
|
code?: string;
|
||||||
|
/** @example 您无权访问此资源。 */
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
/** @example false */
|
||||||
|
success?: boolean;
|
||||||
|
/** @example NOT_FOUND */
|
||||||
|
code?: string;
|
||||||
|
/** @example 用户不存在 */
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/admin/v1/users/{uid}/services": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @example architecto */
|
||||||
|
uid: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* [提供服务查看] 用户提供的服务
|
||||||
|
* @description 返回用户全部专长及服务变体,包含已上架与未上架服务。
|
||||||
|
*/
|
||||||
|
get: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @example architecto */
|
||||||
|
uid: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
/** @example true */
|
||||||
|
success?: boolean;
|
||||||
|
data?: {
|
||||||
|
/**
|
||||||
|
* @example [
|
||||||
|
* {
|
||||||
|
* "id": 1,
|
||||||
|
* "kind": "expertise",
|
||||||
|
* "name": "清洗空调",
|
||||||
|
* "description": "空调深度清洗",
|
||||||
|
* "tags": [
|
||||||
|
* "家电"
|
||||||
|
* ],
|
||||||
|
* "user_uid": "01HQ8M4Z2A3B5C6D7E8F9G0H1I2J3K4L",
|
||||||
|
* "services": [
|
||||||
|
* {
|
||||||
|
* "id": 1,
|
||||||
|
* "kind": "expertise_service",
|
||||||
|
* "service_type": "onsite",
|
||||||
|
* "charge_type": "time",
|
||||||
|
* "price": 12900,
|
||||||
|
* "currency_code": "CNY",
|
||||||
|
* "duration": 90,
|
||||||
|
* "enabled": true
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
expertises?: {
|
||||||
|
/** @example 1 */
|
||||||
|
id?: number;
|
||||||
|
/** @example expertise */
|
||||||
|
kind?: string;
|
||||||
|
/** @example 清洗空调 */
|
||||||
|
name?: string;
|
||||||
|
/** @example 空调深度清洗 */
|
||||||
|
description?: string;
|
||||||
|
/**
|
||||||
|
* @example [
|
||||||
|
* "家电"
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
tags?: string[];
|
||||||
|
/** @example 01HQ8M4Z2A3B5C6D7E8F9G0H1I2J3K4L */
|
||||||
|
user_uid?: string;
|
||||||
|
/**
|
||||||
|
* @example [
|
||||||
|
* {
|
||||||
|
* "id": 1,
|
||||||
|
* "kind": "expertise_service",
|
||||||
|
* "service_type": "onsite",
|
||||||
|
* "charge_type": "time",
|
||||||
|
* "price": 12900,
|
||||||
|
* "currency_code": "CNY",
|
||||||
|
* "duration": 90,
|
||||||
|
* "enabled": true
|
||||||
|
* }
|
||||||
|
* ]
|
||||||
|
*/
|
||||||
|
services?: {
|
||||||
|
/** @example 1 */
|
||||||
|
id?: number;
|
||||||
|
/** @example expertise_service */
|
||||||
|
kind?: string;
|
||||||
|
/** @example onsite */
|
||||||
|
service_type?: string;
|
||||||
|
/** @example time */
|
||||||
|
charge_type?: string;
|
||||||
|
/** @example 12900 */
|
||||||
|
price?: number;
|
||||||
|
/** @example CNY */
|
||||||
|
currency_code?: string;
|
||||||
|
/** @example 90 */
|
||||||
|
duration?: number;
|
||||||
|
/** @example true */
|
||||||
|
enabled?: boolean;
|
||||||
|
}[];
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
/** @example false */
|
||||||
|
success?: boolean;
|
||||||
|
/** @example FORBIDDEN */
|
||||||
|
code?: string;
|
||||||
|
/** @example 您无权访问此资源。 */
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
/** @example false */
|
||||||
|
success?: boolean;
|
||||||
|
/** @example NOT_FOUND */
|
||||||
|
code?: string;
|
||||||
|
/** @example 用户不存在 */
|
||||||
|
message?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/admin/v1/sid/rules": {
|
"/api/admin/v1/sid/rules": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@ -7074,10 +7349,10 @@ export interface paths {
|
|||||||
get?: never;
|
get?: never;
|
||||||
put?: never;
|
put?: never;
|
||||||
/**
|
/**
|
||||||
* 分配用户 SID
|
* [用户SID] 分配用户 SID
|
||||||
* @description 管理员为用户分配或覆盖 SID。
|
* @description 管理员为用户分配或覆盖 SID。
|
||||||
*/
|
*/
|
||||||
post: operations["SID"];
|
post: operations["SIDSID"];
|
||||||
delete?: never;
|
delete?: never;
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
@ -7143,7 +7418,7 @@ export interface paths {
|
|||||||
q?: string | null;
|
q?: string | null;
|
||||||
/** @example true */
|
/** @example true */
|
||||||
status?: boolean | null;
|
status?: boolean | null;
|
||||||
/** @example false */
|
/** @example true */
|
||||||
is_robot?: boolean | null;
|
is_robot?: boolean | null;
|
||||||
/**
|
/**
|
||||||
* @description Must not be greater than 100 characters.
|
* @description Must not be greater than 100 characters.
|
||||||
@ -8302,7 +8577,7 @@ export interface paths {
|
|||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
/**
|
/**
|
||||||
* @example all
|
* @example pending
|
||||||
* @enum {string|null}
|
* @enum {string|null}
|
||||||
*/
|
*/
|
||||||
status?: "pending" | "blocked" | "all" | null;
|
status?: "pending" | "blocked" | "all" | null;
|
||||||
@ -8665,11 +8940,11 @@ export interface paths {
|
|||||||
content: {
|
content: {
|
||||||
"application/json": {
|
"application/json": {
|
||||||
/**
|
/**
|
||||||
* @example desktop
|
* @example android
|
||||||
* @enum {string|null}
|
* @enum {string|null}
|
||||||
*/
|
*/
|
||||||
platform?: "ios" | "android" | "web" | "desktop" | null;
|
platform?: "ios" | "android" | "web" | "desktop" | null;
|
||||||
/** @example false */
|
/** @example true */
|
||||||
is_online?: boolean | null;
|
is_online?: boolean | null;
|
||||||
/**
|
/**
|
||||||
* @description Must be at least 1.
|
* @description Must be at least 1.
|
||||||
@ -9039,7 +9314,7 @@ export interface operations {
|
|||||||
path: {
|
path: {
|
||||||
/**
|
/**
|
||||||
* @description The ID of the category.
|
* @description The ID of the category.
|
||||||
* @example 5a37db21-5a9a-4438-8b4a-f0c80fc4f29e
|
* @example 2912b591-09f0-4c14-9190-866abd51cbee
|
||||||
*/
|
*/
|
||||||
category_id: string;
|
category_id: string;
|
||||||
};
|
};
|
||||||
@ -9055,7 +9330,7 @@ export interface operations {
|
|||||||
path: {
|
path: {
|
||||||
/**
|
/**
|
||||||
* @description The ID of the category.
|
* @description The ID of the category.
|
||||||
* @example 5a37db21-5a9a-4438-8b4a-f0c80fc4f29e
|
* @example 2912b591-09f0-4c14-9190-866abd51cbee
|
||||||
*/
|
*/
|
||||||
category_id: string;
|
category_id: string;
|
||||||
};
|
};
|
||||||
@ -9071,7 +9346,7 @@ export interface operations {
|
|||||||
path: {
|
path: {
|
||||||
/**
|
/**
|
||||||
* @description The ID of the category.
|
* @description The ID of the category.
|
||||||
* @example 5a37db21-5a9a-4438-8b4a-f0c80fc4f29e
|
* @example 2912b591-09f0-4c14-9190-866abd51cbee
|
||||||
*/
|
*/
|
||||||
category_id: string;
|
category_id: string;
|
||||||
};
|
};
|
||||||
@ -9389,7 +9664,7 @@ export interface operations {
|
|||||||
path: {
|
path: {
|
||||||
/**
|
/**
|
||||||
* @description The ID of the category.
|
* @description The ID of the category.
|
||||||
* @example 5a37db21-5a9a-4438-8b4a-f0c80fc4f29e
|
* @example 2912b591-09f0-4c14-9190-866abd51cbee
|
||||||
*/
|
*/
|
||||||
category_id: string;
|
category_id: string;
|
||||||
/**
|
/**
|
||||||
@ -9452,7 +9727,7 @@ export interface operations {
|
|||||||
path: {
|
path: {
|
||||||
/**
|
/**
|
||||||
* @description The ID of the category.
|
* @description The ID of the category.
|
||||||
* @example 5a37db21-5a9a-4438-8b4a-f0c80fc4f29e
|
* @example 2912b591-09f0-4c14-9190-866abd51cbee
|
||||||
*/
|
*/
|
||||||
category_id: string;
|
category_id: string;
|
||||||
/**
|
/**
|
||||||
@ -9521,7 +9796,7 @@ export interface operations {
|
|||||||
path: {
|
path: {
|
||||||
/**
|
/**
|
||||||
* @description The ID of the category.
|
* @description The ID of the category.
|
||||||
* @example 5a37db21-5a9a-4438-8b4a-f0c80fc4f29e
|
* @example 2912b591-09f0-4c14-9190-866abd51cbee
|
||||||
*/
|
*/
|
||||||
category_id: string;
|
category_id: string;
|
||||||
/**
|
/**
|
||||||
@ -9617,7 +9892,7 @@ export interface operations {
|
|||||||
path: {
|
path: {
|
||||||
/**
|
/**
|
||||||
* @description The ID of the category.
|
* @description The ID of the category.
|
||||||
* @example 5a37db21-5a9a-4438-8b4a-f0c80fc4f29e
|
* @example 2912b591-09f0-4c14-9190-866abd51cbee
|
||||||
*/
|
*/
|
||||||
category_id: string;
|
category_id: string;
|
||||||
/**
|
/**
|
||||||
@ -9704,7 +9979,7 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
SID: {
|
SIDSID: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
header?: never;
|
header?: never;
|
||||||
|
|||||||
@ -95,6 +95,88 @@ export interface UserListParams extends ListParams {
|
|||||||
role?: string
|
role?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UserAddress {
|
||||||
|
kind?: string
|
||||||
|
contact_name?: string
|
||||||
|
contact_phone_number?: string
|
||||||
|
country_code?: string
|
||||||
|
province?: string
|
||||||
|
city?: string
|
||||||
|
district?: string
|
||||||
|
address_line?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserCareer {
|
||||||
|
id?: number | string
|
||||||
|
company_name?: string
|
||||||
|
position?: string
|
||||||
|
department?: string
|
||||||
|
start_date?: string
|
||||||
|
end_date?: string
|
||||||
|
is_current?: boolean
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserEducation {
|
||||||
|
id?: number | string
|
||||||
|
school_name?: string
|
||||||
|
major?: string
|
||||||
|
degree?: string
|
||||||
|
start_date?: string
|
||||||
|
end_date?: string
|
||||||
|
is_current?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserHonor {
|
||||||
|
id?: number | string
|
||||||
|
title?: string
|
||||||
|
issuer?: string
|
||||||
|
date?: string
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserProfileInfo {
|
||||||
|
user?: PlatformUser
|
||||||
|
profile?: {
|
||||||
|
kind?: string
|
||||||
|
user_uid?: string
|
||||||
|
computed_fullname?: string
|
||||||
|
headline?: string
|
||||||
|
bio?: string
|
||||||
|
gender?: string
|
||||||
|
}
|
||||||
|
careers?: UserCareer[]
|
||||||
|
educations?: UserEducation[]
|
||||||
|
honors?: UserHonor[]
|
||||||
|
addresses?: UserAddress[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserExpertiseService {
|
||||||
|
id?: number
|
||||||
|
kind?: string
|
||||||
|
service_type?: string
|
||||||
|
charge_type?: string
|
||||||
|
price?: number
|
||||||
|
currency_code?: string
|
||||||
|
duration?: number
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserExpertise {
|
||||||
|
id?: number
|
||||||
|
kind?: string
|
||||||
|
name?: string
|
||||||
|
description?: string
|
||||||
|
tags?: string[]
|
||||||
|
user_uid?: string
|
||||||
|
services?: UserExpertiseService[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserServicesResponse {
|
||||||
|
expertises?: UserExpertise[]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface MeResponse extends ApiResponse<AdminUser> {
|
export interface MeResponse extends ApiResponse<AdminUser> {
|
||||||
meta: {
|
meta: {
|
||||||
roles: string[]
|
roles: string[]
|
||||||
|
|||||||
@ -30,6 +30,7 @@ export const PERM = {
|
|||||||
SID_OVERRIDE: 'admin:sid:override',
|
SID_OVERRIDE: 'admin:sid:override',
|
||||||
NOTIFICATION_READ: 'admin:notification:read',
|
NOTIFICATION_READ: 'admin:notification:read',
|
||||||
ROBOT_TOKEN_ISSUE: 'auth:robot-token:issue',
|
ROBOT_TOKEN_ISSUE: 'auth:robot-token:issue',
|
||||||
|
API_DOCS_READ: 'auth:api-docs:read',
|
||||||
// role management — only super-admin holds these
|
// role management — only super-admin holds these
|
||||||
ROLE_READ: 'auth:role:read',
|
ROLE_READ: 'auth:role:read',
|
||||||
ROLE_CREATE: 'auth:role:create',
|
ROLE_CREATE: 'auth:role:create',
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
FileText,
|
FileText,
|
||||||
|
FileJson,
|
||||||
Flag,
|
Flag,
|
||||||
FolderTree,
|
FolderTree,
|
||||||
Hash,
|
Hash,
|
||||||
@ -40,6 +41,17 @@ export interface NavGroup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const NAV_GROUPS: NavGroup[] = [
|
export const NAV_GROUPS: NavGroup[] = [
|
||||||
|
{
|
||||||
|
label: '开发工具',
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
label: 'API 文档',
|
||||||
|
to: '/api-docs',
|
||||||
|
icon: FileJson,
|
||||||
|
permission: 'auth:api-docs:read',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: '分类管理',
|
label: '分类管理',
|
||||||
items: [
|
items: [
|
||||||
@ -112,4 +124,3 @@ export function getFirstAccessibleRoute(): string | null {
|
|||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -60,7 +60,7 @@ function SheetContent({
|
|||||||
<SheetPrimitive.Content
|
<SheetPrimitive.Content
|
||||||
data-slot="sheet-content"
|
data-slot="sheet-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
|
"fixed z-50 flex flex-col gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
|
||||||
side === "right" &&
|
side === "right" &&
|
||||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||||
side === "left" &&
|
side === "left" &&
|
||||||
@ -89,7 +89,7 @@ function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-slot="sheet-header"
|
data-slot="sheet-header"
|
||||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
className={cn("flex flex-col gap-1.5", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,6 +1,20 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { CheckCircle2, Shield, XCircle } from 'lucide-react'
|
import {
|
||||||
import { fetchUserDetail } from '@/api/modules/users'
|
Award,
|
||||||
|
Briefcase,
|
||||||
|
CheckCircle2,
|
||||||
|
FileText,
|
||||||
|
GraduationCap,
|
||||||
|
MapPin,
|
||||||
|
Shield,
|
||||||
|
Star,
|
||||||
|
Tag,
|
||||||
|
User,
|
||||||
|
Wrench,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { fetchUserDetail, fetchUserInfo, fetchUserServices } from '@/api/modules/users'
|
||||||
|
import { fetchExcProvider } from '@/api/modules/exc'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@ -11,6 +25,7 @@ import {
|
|||||||
SheetHeader,
|
SheetHeader,
|
||||||
SheetTitle,
|
SheetTitle,
|
||||||
} from '@/components/ui/sheet'
|
} from '@/components/ui/sheet'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
|
||||||
interface UserDetailSheetProps {
|
interface UserDetailSheetProps {
|
||||||
uid: string | null
|
uid: string | null
|
||||||
@ -19,17 +34,67 @@ interface UserDetailSheetProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function UserDetailSheet({ uid, onClose, onAssignRoles }: UserDetailSheetProps) {
|
export function UserDetailSheet({ uid, onClose, onAssignRoles }: UserDetailSheetProps) {
|
||||||
const query = useQuery({
|
// 1. Basic user account query (GET /api/admin/v1/users/{uid})
|
||||||
|
const userQuery = useQuery({
|
||||||
queryKey: ['users', uid],
|
queryKey: ['users', uid],
|
||||||
queryFn: () => (uid ? fetchUserDetail(uid) : null),
|
queryFn: () => (uid ? fetchUserDetail(uid) : null),
|
||||||
enabled: uid !== null,
|
enabled: uid !== null,
|
||||||
})
|
})
|
||||||
|
|
||||||
const user = query.data?.data
|
// 2. Full user profile, careers, educations, honors, addresses (GET /api/admin/v1/users/{uid}/info)
|
||||||
|
const userInfoQuery = useQuery({
|
||||||
|
queryKey: ['user-info', uid],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!uid) return null
|
||||||
|
try {
|
||||||
|
const res = await fetchUserInfo(uid)
|
||||||
|
return res.data
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: uid !== null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. User provided services (GET /api/admin/v1/users/{uid}/services)
|
||||||
|
const userServicesQuery = useQuery({
|
||||||
|
queryKey: ['user-services', uid],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!uid) return null
|
||||||
|
try {
|
||||||
|
const res = await fetchUserServices(uid)
|
||||||
|
return res.data
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: uid !== null,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. Provider profile query (GET /api/admin/v1/exc/providers/{uid})
|
||||||
|
const providerQuery = useQuery({
|
||||||
|
queryKey: ['exc-provider', uid],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!uid) return null
|
||||||
|
try {
|
||||||
|
const res = await fetchExcProvider(uid)
|
||||||
|
return res.data
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled: uid !== null,
|
||||||
|
retry: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const user = userQuery.data?.data
|
||||||
|
const userInfo = userInfoQuery.data
|
||||||
|
const userServices = userServicesQuery.data?.expertises ?? []
|
||||||
|
const provider = providerQuery.data
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={uid !== null} onOpenChange={(open) => !open && onClose()}>
|
<Sheet open={uid !== null} onOpenChange={(open) => !open && onClose()}>
|
||||||
<SheetContent className="sm:max-w-md overflow-y-auto">
|
<SheetContent className="sm:max-w-xl p-6 overflow-y-auto flex flex-col">
|
||||||
<SheetHeader className="border-b pb-4">
|
<SheetHeader className="border-b pb-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar className="size-12">
|
<Avatar className="size-12">
|
||||||
@ -39,8 +104,13 @@ export function UserDetailSheet({ uid, onClose, onAssignRoles }: UserDetailSheet
|
|||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<SheetTitle className="truncate text-base font-semibold">
|
<SheetTitle className="truncate text-base font-semibold flex items-center gap-2">
|
||||||
{user?.display_name ?? user?.username ?? '用户详情'}
|
<span>{userInfo?.profile?.computed_fullname ?? user?.display_name ?? user?.username ?? '用户详情'}</span>
|
||||||
|
{userInfo?.profile?.gender && (
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{userInfo.profile.gender}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</SheetTitle>
|
</SheetTitle>
|
||||||
<SheetDescription className="truncate text-xs font-mono">
|
<SheetDescription className="truncate text-xs font-mono">
|
||||||
{user?.uid}
|
{user?.uid}
|
||||||
@ -49,170 +119,537 @@ export function UserDetailSheet({ uid, onClose, onAssignRoles }: UserDetailSheet
|
|||||||
</div>
|
</div>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
{query.isPending ? (
|
{userQuery.isPending ? (
|
||||||
<div className="py-8 text-center text-sm text-muted-foreground">加载中…</div>
|
<div className="py-8 text-center text-sm text-muted-foreground">加载中…</div>
|
||||||
) : !user ? (
|
) : !user ? (
|
||||||
<div className="py-8 text-center text-sm text-muted-foreground">未找到用户信息</div>
|
<div className="py-8 text-center text-sm text-muted-foreground">未找到用户信息</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-6 py-4">
|
<Tabs defaultValue="account" className="mt-2 flex-1 flex flex-col">
|
||||||
{/* Status overview */}
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
<div className="flex flex-wrap gap-2">
|
<TabsTrigger value="account" className="gap-1.5 text-xs">
|
||||||
<Badge variant={user.status ? 'default' : 'destructive'}>
|
<User className="size-3.5" />
|
||||||
{user.status ? '正常启用' : '已禁用'}
|
账号基础
|
||||||
</Badge>
|
</TabsTrigger>
|
||||||
{user.is_robot ? (
|
<TabsTrigger value="info" className="gap-1.5 text-xs">
|
||||||
<Badge variant="secondary">机器人账号</Badge>
|
<FileText className="size-3.5" />
|
||||||
) : (
|
个人资料
|
||||||
<Badge variant="outline">真人账号</Badge>
|
</TabsTrigger>
|
||||||
)}
|
<TabsTrigger value="services" className="gap-1.5 text-xs">
|
||||||
{user.sid_display && (
|
<Wrench className="size-3.5" />
|
||||||
<Badge variant="outline" className="font-mono">
|
服务事项 ({userServices.length})
|
||||||
SID: {user.sid_display}
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* TAB 1: ACCOUNT BASIC */}
|
||||||
|
<TabsContent value="account" className="flex flex-col gap-6 py-4 outline-none">
|
||||||
|
{/* Status overview */}
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant={user.status ? 'default' : 'destructive'}>
|
||||||
|
{user.status ? '正常启用' : '已禁用'}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
{user.is_robot ? (
|
||||||
</div>
|
<Badge variant="secondary">机器人账号</Badge>
|
||||||
|
|
||||||
{/* Account Info */}
|
|
||||||
<div className="grid gap-3">
|
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
||||||
账号基本信息
|
|
||||||
</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-2 rounded-lg border p-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">用户名</div>
|
|
||||||
<div className="font-medium">{user.username ?? '—'}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">显示名称</div>
|
|
||||||
<div className="font-medium">{user.display_name ?? '—'}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">SID</div>
|
|
||||||
<div className="font-mono text-xs">{user.sid ?? '—'}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">专业领域</div>
|
|
||||||
<div className="font-medium">{user.expertise ?? '—'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Contact & Verification */}
|
|
||||||
<div className="grid gap-3">
|
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
||||||
联系方式与验证
|
|
||||||
</h4>
|
|
||||||
<div className="grid gap-2 rounded-lg border p-3 text-sm">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">邮箱</div>
|
|
||||||
<div className="font-medium">{user.email ?? '—'}</div>
|
|
||||||
</div>
|
|
||||||
{user.email && (
|
|
||||||
<Badge variant={user.email_verified_at ? 'secondary' : 'outline'} className="gap-1">
|
|
||||||
{user.email_verified_at ? (
|
|
||||||
<>
|
|
||||||
<CheckCircle2 className="size-3 text-emerald-500" />
|
|
||||||
已验证
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<XCircle className="size-3 text-amber-500" />
|
|
||||||
未验证
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between border-t pt-2">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">手机号</div>
|
|
||||||
<div className="font-medium">{user.phone ?? '—'}</div>
|
|
||||||
</div>
|
|
||||||
{user.phone && (
|
|
||||||
<Badge variant={user.phone_verified_at ? 'secondary' : 'outline'} className="gap-1">
|
|
||||||
{user.phone_verified_at ? (
|
|
||||||
<>
|
|
||||||
<CheckCircle2 className="size-3 text-emerald-500" />
|
|
||||||
已验证
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<XCircle className="size-3 text-amber-500" />
|
|
||||||
未验证
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Locale & Region */}
|
|
||||||
<div className="grid gap-3">
|
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
||||||
地区与时区
|
|
||||||
</h4>
|
|
||||||
<div className="grid grid-cols-2 gap-2 rounded-lg border p-3 text-sm">
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">国家/地区</div>
|
|
||||||
<div className="font-medium">{user.country_code ?? '—'}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">城市</div>
|
|
||||||
<div className="font-medium">{user.city ?? '—'}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">时区</div>
|
|
||||||
<div className="font-medium">{user.timezone ?? '—'}</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-muted-foreground">语言偏好</div>
|
|
||||||
<div className="font-medium">{user.locale ?? '—'}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Roles */}
|
|
||||||
<div className="grid gap-3">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
|
||||||
已分配角色
|
|
||||||
</h4>
|
|
||||||
{onAssignRoles && (
|
|
||||||
<Button variant="ghost" size="sm" onClick={onAssignRoles} className="h-6 text-xs gap-1">
|
|
||||||
<Shield className="size-3" />
|
|
||||||
分配角色
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-1.5 rounded-lg border p-3">
|
|
||||||
{user.roles && user.roles.length > 0 ? (
|
|
||||||
user.roles.map((role) => (
|
|
||||||
<Badge key={role} variant="secondary">
|
|
||||||
{role}
|
|
||||||
</Badge>
|
|
||||||
))
|
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">未分配任何角色</span>
|
<Badge variant="outline">真人账号</Badge>
|
||||||
|
)}
|
||||||
|
{user.sid_display && (
|
||||||
|
<Badge variant="outline" className="font-mono">
|
||||||
|
SID: {user.sid_display}
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Timestamps */}
|
{/* Account Basic Info */}
|
||||||
<div className="grid grid-cols-2 gap-2 rounded-lg border p-3 text-xs text-muted-foreground">
|
<div className="grid gap-3">
|
||||||
<div>
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
<span>注册时间:</span>
|
账号基础属性
|
||||||
<span>{new Date(user.created_at).toLocaleString('zh-CN')}</span>
|
</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-2 rounded-lg border p-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">用户名</div>
|
||||||
|
<div className="font-medium">{user.username ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">显示名称</div>
|
||||||
|
<div className="font-medium">{user.display_name ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">SID</div>
|
||||||
|
<div className="font-mono text-xs">{user.sid ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">专业领域</div>
|
||||||
|
<div className="font-medium">{user.expertise ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{user.updated_at && (
|
|
||||||
|
{/* Contact & Verification */}
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
联系方式与验证
|
||||||
|
</h4>
|
||||||
|
<div className="grid gap-2 rounded-lg border p-3 text-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">邮箱</div>
|
||||||
|
<div className="font-medium">{user.email ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
{user.email && (
|
||||||
|
<Badge variant={user.email_verified_at ? 'secondary' : 'outline'} className="gap-1">
|
||||||
|
{user.email_verified_at ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="size-3 text-emerald-500" />
|
||||||
|
已验证
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XCircle className="size-3 text-amber-500" />
|
||||||
|
未验证
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between border-t pt-2">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">手机号</div>
|
||||||
|
<div className="font-medium">{user.phone ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
{user.phone && (
|
||||||
|
<Badge variant={user.phone_verified_at ? 'secondary' : 'outline'} className="gap-1">
|
||||||
|
{user.phone_verified_at ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="size-3 text-emerald-500" />
|
||||||
|
已验证
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<XCircle className="size-3 text-amber-500" />
|
||||||
|
未验证
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Roles */}
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
已分配角色
|
||||||
|
</h4>
|
||||||
|
{onAssignRoles && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={onAssignRoles} className="h-6 text-xs gap-1">
|
||||||
|
<Shield className="size-3" />
|
||||||
|
分配角色
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5 rounded-lg border p-3">
|
||||||
|
{user.roles && user.roles.length > 0 ? (
|
||||||
|
user.roles.map((role) => (
|
||||||
|
<Badge key={role} variant="secondary">
|
||||||
|
{role}
|
||||||
|
</Badge>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground">未分配任何角色</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timestamps */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 rounded-lg border p-3 text-xs text-muted-foreground">
|
||||||
<div>
|
<div>
|
||||||
<span>更新时间:</span>
|
<span>注册时间:</span>
|
||||||
<span>{new Date(user.updated_at).toLocaleString('zh-CN')}</span>
|
<span>{new Date(user.created_at).toLocaleString('zh-CN')}</span>
|
||||||
|
</div>
|
||||||
|
{user.updated_at && (
|
||||||
|
<div>
|
||||||
|
<span>更新时间:</span>
|
||||||
|
<span>{new Date(user.updated_at).toLocaleString('zh-CN')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* TAB 2: DETAILED INFO */}
|
||||||
|
<TabsContent value="info" className="flex flex-col gap-6 py-4 outline-none">
|
||||||
|
{userInfoQuery.isPending ? (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">加载个人资料中…</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Profile Headline & Bio */}
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
个人名片与简介
|
||||||
|
</h4>
|
||||||
|
<div className="rounded-lg border bg-muted/30 p-3 text-xs space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-semibold text-sm text-foreground">
|
||||||
|
{userInfo?.profile?.computed_fullname ?? user.display_name ?? '—'}
|
||||||
|
</span>
|
||||||
|
{userInfo?.profile?.gender && (
|
||||||
|
<Badge variant="outline">{userInfo.profile.gender}</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{userInfo?.profile?.headline && (
|
||||||
|
<div className="font-medium text-foreground">{userInfo.profile.headline}</div>
|
||||||
|
)}
|
||||||
|
{userInfo?.profile?.bio ? (
|
||||||
|
<div className="text-muted-foreground leading-relaxed">{userInfo.profile.bio}</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-muted-foreground italic">暂无个人简介</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Region & Timezone */}
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
地区与语言偏好
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-2 rounded-lg border p-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">国家/地区</div>
|
||||||
|
<div className="font-medium">{user.country_code ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">城市</div>
|
||||||
|
<div className="font-medium">{user.city ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">时区</div>
|
||||||
|
<div className="font-medium">{user.timezone ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">语言偏好</div>
|
||||||
|
<div className="font-medium">{user.locale ?? '—'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Careers / Work Experience */}
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<Briefcase className="size-3.5" />
|
||||||
|
工作经历 ({(userInfo?.careers ?? []).length})
|
||||||
|
</h4>
|
||||||
|
{userInfo?.careers && userInfo.careers.length > 0 ? (
|
||||||
|
<div className="space-y-2 rounded-lg border p-3 text-xs">
|
||||||
|
{userInfo.careers.map((career, idx) => (
|
||||||
|
<div key={idx} className="border-b pb-2 last:border-b-0 last:pb-0">
|
||||||
|
<div className="flex items-center justify-between font-medium text-sm">
|
||||||
|
<span>{career.company_name ?? '未知公司'}</span>
|
||||||
|
{career.is_current && <Badge variant="secondary" className="text-[10px]">在职</Badge>}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground font-medium mt-0.5">
|
||||||
|
{career.position} {career.department ? `· ${career.department}` : ''}
|
||||||
|
</div>
|
||||||
|
{(career.start_date || career.end_date) && (
|
||||||
|
<div className="text-[11px] text-muted-foreground mt-0.5">
|
||||||
|
{career.start_date ?? '—'} ~ {career.is_current ? '至今' : career.end_date ?? '—'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{career.description && (
|
||||||
|
<div className="text-muted-foreground mt-1 text-[11px] leading-relaxed">
|
||||||
|
{career.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-dashed p-4 text-center text-xs text-muted-foreground">
|
||||||
|
暂无工作经历记录
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Education */}
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<GraduationCap className="size-3.5" />
|
||||||
|
教育经历 ({(userInfo?.educations ?? []).length})
|
||||||
|
</h4>
|
||||||
|
{userInfo?.educations && userInfo.educations.length > 0 ? (
|
||||||
|
<div className="space-y-2 rounded-lg border p-3 text-xs">
|
||||||
|
{userInfo.educations.map((edu, idx) => (
|
||||||
|
<div key={idx} className="border-b pb-2 last:border-b-0 last:pb-0">
|
||||||
|
<div className="flex items-center justify-between font-medium text-sm">
|
||||||
|
<span>{edu.school_name ?? '学校'}</span>
|
||||||
|
{edu.degree && <Badge variant="outline" className="text-[10px]">{edu.degree}</Badge>}
|
||||||
|
</div>
|
||||||
|
{edu.major && <div className="text-muted-foreground mt-0.5">{edu.major}</div>}
|
||||||
|
{(edu.start_date || edu.end_date) && (
|
||||||
|
<div className="text-[11px] text-muted-foreground mt-0.5">
|
||||||
|
{edu.start_date ?? '—'} ~ {edu.is_current ? '至今' : edu.end_date ?? '—'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-dashed p-4 text-center text-xs text-muted-foreground">
|
||||||
|
暂无教育经历记录
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Honors */}
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<Award className="size-3.5" />
|
||||||
|
荣誉与奖项 ({(userInfo?.honors ?? []).length})
|
||||||
|
</h4>
|
||||||
|
{userInfo?.honors && userInfo.honors.length > 0 ? (
|
||||||
|
<div className="space-y-2 rounded-lg border p-3 text-xs">
|
||||||
|
{userInfo.honors.map((honor, idx) => (
|
||||||
|
<div key={idx} className="border-b pb-2 last:border-b-0 last:pb-0">
|
||||||
|
<div className="flex items-center justify-between font-medium">
|
||||||
|
<span>{honor.title ?? '荣誉奖项'}</span>
|
||||||
|
{honor.date && <span className="text-[11px] text-muted-foreground">{honor.date}</span>}
|
||||||
|
</div>
|
||||||
|
{honor.issuer && <div className="text-muted-foreground text-[11px] mt-0.5">颁发单位:{honor.issuer}</div>}
|
||||||
|
{honor.description && <div className="text-muted-foreground text-[11px] mt-0.5">{honor.description}</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-dashed p-4 text-center text-xs text-muted-foreground">
|
||||||
|
暂无荣誉与奖项记录
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Addresses */}
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-1.5">
|
||||||
|
<MapPin className="size-3.5" />
|
||||||
|
常用服务地址 ({(userInfo?.addresses ?? []).length})
|
||||||
|
</h4>
|
||||||
|
{userInfo?.addresses && userInfo.addresses.length > 0 ? (
|
||||||
|
<div className="space-y-2 rounded-lg border p-3 text-xs">
|
||||||
|
{userInfo.addresses.map((addr, idx) => (
|
||||||
|
<div key={idx} className="border-b pb-2 last:border-b-0 last:pb-0 space-y-0.5">
|
||||||
|
<div className="flex items-center justify-between font-medium">
|
||||||
|
<span>{addr.contact_name ?? '联系人'}</span>
|
||||||
|
{addr.contact_phone_number && (
|
||||||
|
<span className="font-mono text-muted-foreground">{addr.contact_phone_number}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-[11px]">
|
||||||
|
{[addr.province, addr.city, addr.district, addr.address_line]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-dashed p-4 text-center text-xs text-muted-foreground">
|
||||||
|
暂无保存的服务地址
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* TAB 3: SERVICES */}
|
||||||
|
<TabsContent value="services" className="flex flex-col gap-6 py-4 outline-none">
|
||||||
|
{userServicesQuery.isPending ? (
|
||||||
|
<div className="py-8 text-center text-sm text-muted-foreground">加载服务事项中…</div>
|
||||||
|
) : userServices.length > 0 ? (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
专长项目与服务变体 ({userServices.length})
|
||||||
|
</h4>
|
||||||
|
{userServices.map((exp) => (
|
||||||
|
<div key={exp.id ?? exp.name} className="rounded-lg border p-3 shadow-xs space-y-3">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h5 className="font-semibold text-sm flex items-center gap-1.5">
|
||||||
|
<Wrench className="size-4 text-primary" />
|
||||||
|
{exp.name}
|
||||||
|
</h5>
|
||||||
|
{exp.tags && exp.tags.length > 0 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{exp.tags.map((tag) => (
|
||||||
|
<Badge key={tag} variant="secondary" className="text-[10px] gap-0.5">
|
||||||
|
<Tag className="size-2.5" />
|
||||||
|
{tag}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{exp.description && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{exp.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Service Variants */}
|
||||||
|
{exp.services && exp.services.length > 0 && (
|
||||||
|
<div className="space-y-2 border-t pt-2">
|
||||||
|
<div className="text-[11px] font-medium text-muted-foreground">包含的服务变体</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
{exp.services.map((svc) => (
|
||||||
|
<div
|
||||||
|
key={svc.id ?? svc.service_type}
|
||||||
|
className="flex items-center justify-between rounded-md bg-muted/40 p-2.5 text-xs"
|
||||||
|
>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center gap-2 font-medium">
|
||||||
|
<Badge variant="outline" className="text-[10px] uppercase">
|
||||||
|
{svc.service_type === 'onsite'
|
||||||
|
? '上门服务'
|
||||||
|
: svc.service_type === 'online'
|
||||||
|
? '在线服务'
|
||||||
|
: svc.service_type === 'remote'
|
||||||
|
? '远程协助'
|
||||||
|
: svc.service_type ?? '服务'}
|
||||||
|
</Badge>
|
||||||
|
<span>
|
||||||
|
{svc.charge_type === 'time'
|
||||||
|
? '按时计费'
|
||||||
|
: svc.charge_type === 'fixed'
|
||||||
|
? '固定价格'
|
||||||
|
: svc.charge_type === 'quote'
|
||||||
|
? '按需报价'
|
||||||
|
: svc.charge_type}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{svc.duration && (
|
||||||
|
<div className="text-[11px] text-muted-foreground">
|
||||||
|
预估服务时长: {svc.duration} 分钟
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="font-semibold text-sm text-foreground">
|
||||||
|
{svc.price != null
|
||||||
|
? `${svc.currency_code ?? '¥'} ${(svc.price / 100).toFixed(2)}`
|
||||||
|
: '按需报价'}
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant={svc.enabled ? 'secondary' : 'outline'}
|
||||||
|
className="text-[10px] mt-0.5"
|
||||||
|
>
|
||||||
|
{svc.enabled ? '已上架' : '已下架'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : provider ? (
|
||||||
|
/* Fallback to provider overview if userServices is empty */
|
||||||
|
<div className="grid gap-3">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
专长服务商资格
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-3 rounded-lg border p-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">在线状态</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-1.5 font-medium">
|
||||||
|
<span
|
||||||
|
className={`size-2 rounded-full ${
|
||||||
|
provider.status === 'online'
|
||||||
|
? 'bg-emerald-500'
|
||||||
|
: provider.status === 'busy'
|
||||||
|
? 'bg-amber-500'
|
||||||
|
: 'bg-muted-foreground'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{provider.status === 'online'
|
||||||
|
? '在线'
|
||||||
|
: provider.status === 'busy'
|
||||||
|
? '忙碌'
|
||||||
|
: provider.status === 'offline'
|
||||||
|
? '离线'
|
||||||
|
: provider.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">实名认证</div>
|
||||||
|
<div className="mt-0.5">
|
||||||
|
<Badge variant={provider.is_verified ? 'secondary' : 'outline'}>
|
||||||
|
{provider.is_verified ? '已认证' : '未认证'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">评分</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-1 font-medium">
|
||||||
|
<Star className="size-3.5 fill-amber-400 text-amber-400" />
|
||||||
|
<span>{provider.rating ? provider.rating.toFixed(1) : '暂无评分'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">已完结订单</div>
|
||||||
|
<div className="mt-0.5 font-medium">{provider.completed_jobs ?? 0} 单</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
|
||||||
|
<Wrench className="mx-auto mb-2 size-8 text-muted-foreground/50" />
|
||||||
|
<p className="font-medium text-foreground">暂无服务事项</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">
|
||||||
|
该用户尚未创建或发布任何专长项目与服务变体。
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
{/* Provider Profile Settings if available */}
|
||||||
|
{provider && (
|
||||||
|
<div className="grid gap-3 pt-2">
|
||||||
|
<h4 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||||
|
专长服务商抢单与派单参数
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-2 rounded-lg border p-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">自动抢单模式</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{provider.dispatch_setting?.auto_grab_enabled ? '已开启' : '已关闭'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">今日接单量</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{provider.dispatch_setting?.accepted_today ?? 0} /{' '}
|
||||||
|
{provider.dispatch_setting?.daily_accept_cap ?? '无限制'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{provider.dispatch_setting?.min_price != null && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">最低接单起价</div>
|
||||||
|
<div className="font-medium">¥{provider.dispatch_setting.min_price}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{provider.dispatch_setting?.max_radius_meters != null && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">服务接单半径</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{(provider.dispatch_setting.max_radius_meters / 1000).toFixed(1)} km
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
)}
|
)}
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import { Route as AuthedEventReportsRouteImport } from './routes/_authed/event-r
|
|||||||
import { Route as AuthedDimzouTranslationsRouteImport } from './routes/_authed/dimzou-translations'
|
import { Route as AuthedDimzouTranslationsRouteImport } from './routes/_authed/dimzou-translations'
|
||||||
import { Route as AuthedDevicesRouteImport } from './routes/_authed/devices'
|
import { Route as AuthedDevicesRouteImport } from './routes/_authed/devices'
|
||||||
import { Route as AuthedCommentReportsRouteImport } from './routes/_authed/comment-reports'
|
import { Route as AuthedCommentReportsRouteImport } from './routes/_authed/comment-reports'
|
||||||
|
import { Route as AuthedApiDocsRouteImport } from './routes/_authed/api-docs'
|
||||||
import { Route as AuthedAdminUsersRouteImport } from './routes/_authed/admin-users'
|
import { Route as AuthedAdminUsersRouteImport } from './routes/_authed/admin-users'
|
||||||
import { Route as AuthedUsersIndexRouteImport } from './routes/_authed/users/index'
|
import { Route as AuthedUsersIndexRouteImport } from './routes/_authed/users/index'
|
||||||
import { Route as AuthedCategoriesIndexRouteImport } from './routes/_authed/categories/index'
|
import { Route as AuthedCategoriesIndexRouteImport } from './routes/_authed/categories/index'
|
||||||
@ -93,6 +94,11 @@ const AuthedCommentReportsRoute = AuthedCommentReportsRouteImport.update({
|
|||||||
path: '/comment-reports',
|
path: '/comment-reports',
|
||||||
getParentRoute: () => AuthedRoute,
|
getParentRoute: () => AuthedRoute,
|
||||||
} as any)
|
} as any)
|
||||||
|
const AuthedApiDocsRoute = AuthedApiDocsRouteImport.update({
|
||||||
|
id: '/api-docs',
|
||||||
|
path: '/api-docs',
|
||||||
|
getParentRoute: () => AuthedRoute,
|
||||||
|
} as any)
|
||||||
const AuthedAdminUsersRoute = AuthedAdminUsersRouteImport.update({
|
const AuthedAdminUsersRoute = AuthedAdminUsersRouteImport.update({
|
||||||
id: '/admin-users',
|
id: '/admin-users',
|
||||||
path: '/admin-users',
|
path: '/admin-users',
|
||||||
@ -187,6 +193,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/': typeof AuthedIndexRoute
|
'/': typeof AuthedIndexRoute
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/admin-users': typeof AuthedAdminUsersRoute
|
'/admin-users': typeof AuthedAdminUsersRoute
|
||||||
|
'/api-docs': typeof AuthedApiDocsRoute
|
||||||
'/comment-reports': typeof AuthedCommentReportsRoute
|
'/comment-reports': typeof AuthedCommentReportsRoute
|
||||||
'/devices': typeof AuthedDevicesRoute
|
'/devices': typeof AuthedDevicesRoute
|
||||||
'/dimzou-translations': typeof AuthedDimzouTranslationsRoute
|
'/dimzou-translations': typeof AuthedDimzouTranslationsRoute
|
||||||
@ -215,6 +222,7 @@ export interface FileRoutesByFullPath {
|
|||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/admin-users': typeof AuthedAdminUsersRoute
|
'/admin-users': typeof AuthedAdminUsersRoute
|
||||||
|
'/api-docs': typeof AuthedApiDocsRoute
|
||||||
'/comment-reports': typeof AuthedCommentReportsRoute
|
'/comment-reports': typeof AuthedCommentReportsRoute
|
||||||
'/devices': typeof AuthedDevicesRoute
|
'/devices': typeof AuthedDevicesRoute
|
||||||
'/dimzou-translations': typeof AuthedDimzouTranslationsRoute
|
'/dimzou-translations': typeof AuthedDimzouTranslationsRoute
|
||||||
@ -246,6 +254,7 @@ export interface FileRoutesById {
|
|||||||
'/_authed': typeof AuthedRouteWithChildren
|
'/_authed': typeof AuthedRouteWithChildren
|
||||||
'/login': typeof LoginRoute
|
'/login': typeof LoginRoute
|
||||||
'/_authed/admin-users': typeof AuthedAdminUsersRoute
|
'/_authed/admin-users': typeof AuthedAdminUsersRoute
|
||||||
|
'/_authed/api-docs': typeof AuthedApiDocsRoute
|
||||||
'/_authed/comment-reports': typeof AuthedCommentReportsRoute
|
'/_authed/comment-reports': typeof AuthedCommentReportsRoute
|
||||||
'/_authed/devices': typeof AuthedDevicesRoute
|
'/_authed/devices': typeof AuthedDevicesRoute
|
||||||
'/_authed/dimzou-translations': typeof AuthedDimzouTranslationsRoute
|
'/_authed/dimzou-translations': typeof AuthedDimzouTranslationsRoute
|
||||||
@ -278,6 +287,7 @@ export interface FileRouteTypes {
|
|||||||
| '/'
|
| '/'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/admin-users'
|
| '/admin-users'
|
||||||
|
| '/api-docs'
|
||||||
| '/comment-reports'
|
| '/comment-reports'
|
||||||
| '/devices'
|
| '/devices'
|
||||||
| '/dimzou-translations'
|
| '/dimzou-translations'
|
||||||
@ -306,6 +316,7 @@ export interface FileRouteTypes {
|
|||||||
to:
|
to:
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/admin-users'
|
| '/admin-users'
|
||||||
|
| '/api-docs'
|
||||||
| '/comment-reports'
|
| '/comment-reports'
|
||||||
| '/devices'
|
| '/devices'
|
||||||
| '/dimzou-translations'
|
| '/dimzou-translations'
|
||||||
@ -336,6 +347,7 @@ export interface FileRouteTypes {
|
|||||||
| '/_authed'
|
| '/_authed'
|
||||||
| '/login'
|
| '/login'
|
||||||
| '/_authed/admin-users'
|
| '/_authed/admin-users'
|
||||||
|
| '/_authed/api-docs'
|
||||||
| '/_authed/comment-reports'
|
| '/_authed/comment-reports'
|
||||||
| '/_authed/devices'
|
| '/_authed/devices'
|
||||||
| '/_authed/dimzou-translations'
|
| '/_authed/dimzou-translations'
|
||||||
@ -447,6 +459,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof AuthedCommentReportsRouteImport
|
preLoaderRoute: typeof AuthedCommentReportsRouteImport
|
||||||
parentRoute: typeof AuthedRoute
|
parentRoute: typeof AuthedRoute
|
||||||
}
|
}
|
||||||
|
'/_authed/api-docs': {
|
||||||
|
id: '/_authed/api-docs'
|
||||||
|
path: '/api-docs'
|
||||||
|
fullPath: '/api-docs'
|
||||||
|
preLoaderRoute: typeof AuthedApiDocsRouteImport
|
||||||
|
parentRoute: typeof AuthedRoute
|
||||||
|
}
|
||||||
'/_authed/admin-users': {
|
'/_authed/admin-users': {
|
||||||
id: '/_authed/admin-users'
|
id: '/_authed/admin-users'
|
||||||
path: '/admin-users'
|
path: '/admin-users'
|
||||||
@ -571,6 +590,7 @@ declare module '@tanstack/react-router' {
|
|||||||
|
|
||||||
interface AuthedRouteChildren {
|
interface AuthedRouteChildren {
|
||||||
AuthedAdminUsersRoute: typeof AuthedAdminUsersRoute
|
AuthedAdminUsersRoute: typeof AuthedAdminUsersRoute
|
||||||
|
AuthedApiDocsRoute: typeof AuthedApiDocsRoute
|
||||||
AuthedCommentReportsRoute: typeof AuthedCommentReportsRoute
|
AuthedCommentReportsRoute: typeof AuthedCommentReportsRoute
|
||||||
AuthedDevicesRoute: typeof AuthedDevicesRoute
|
AuthedDevicesRoute: typeof AuthedDevicesRoute
|
||||||
AuthedDimzouTranslationsRoute: typeof AuthedDimzouTranslationsRoute
|
AuthedDimzouTranslationsRoute: typeof AuthedDimzouTranslationsRoute
|
||||||
@ -600,6 +620,7 @@ interface AuthedRouteChildren {
|
|||||||
|
|
||||||
const AuthedRouteChildren: AuthedRouteChildren = {
|
const AuthedRouteChildren: AuthedRouteChildren = {
|
||||||
AuthedAdminUsersRoute: AuthedAdminUsersRoute,
|
AuthedAdminUsersRoute: AuthedAdminUsersRoute,
|
||||||
|
AuthedApiDocsRoute: AuthedApiDocsRoute,
|
||||||
AuthedCommentReportsRoute: AuthedCommentReportsRoute,
|
AuthedCommentReportsRoute: AuthedCommentReportsRoute,
|
||||||
AuthedDevicesRoute: AuthedDevicesRoute,
|
AuthedDevicesRoute: AuthedDevicesRoute,
|
||||||
AuthedDimzouTranslationsRoute: AuthedDimzouTranslationsRoute,
|
AuthedDimzouTranslationsRoute: AuthedDimzouTranslationsRoute,
|
||||||
|
|||||||
91
src/routes/_authed/api-docs.tsx
Normal file
91
src/routes/_authed/api-docs.tsx
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import { ApiReferenceReact } from '@scalar/api-reference-react'
|
||||||
|
import '@scalar/api-reference-react/style.css'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { ApiError } from '@/api/client'
|
||||||
|
import { fetchApiDocument, type ApiDocument } from '@/api/modules/api-docs'
|
||||||
|
import { PERM, requirePermission } from '@/auth/permissions'
|
||||||
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/_authed/api-docs')({
|
||||||
|
beforeLoad: () => requirePermission(PERM.API_DOCS_READ),
|
||||||
|
component: ApiDocumentationPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
const DOCUMENTS: Array<{ value: ApiDocument; label: string }> = [
|
||||||
|
{ value: 'public', label: '公共 API' },
|
||||||
|
{ value: 'admin', label: '管理端 API' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function ApiDocumentationPage() {
|
||||||
|
const [document, setDocument] = useState<ApiDocument>('public')
|
||||||
|
const specification = useQuery({
|
||||||
|
queryKey: ['api-docs', document],
|
||||||
|
queryFn: ({ signal }) => fetchApiDocument(document, signal),
|
||||||
|
})
|
||||||
|
|
||||||
|
const notGenerated =
|
||||||
|
specification.error instanceof ApiError &&
|
||||||
|
specification.error.code === 'API_DOCUMENT_NOT_GENERATED'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="API 文档"
|
||||||
|
description="仅向拥有开发者文档权限的后台账号开放"
|
||||||
|
actions={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{DOCUMENTS.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.value}
|
||||||
|
variant={document === item.value ? 'default' : 'outline'}
|
||||||
|
onClick={() => setDocument(item.value)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{specification.isPending && (
|
||||||
|
<div className="rounded-lg border p-8 text-center text-sm text-muted-foreground">
|
||||||
|
正在加载 API 文档…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{notGenerated && (
|
||||||
|
<div className="rounded-lg border border-dashed p-8 text-center">
|
||||||
|
<p className="font-medium">文档尚未生成</p>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
请联系运维人员运行 <code className="font-mono">php artisan api-docs:generate</code>。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{specification.isError && !notGenerated && (
|
||||||
|
<div className="rounded-lg border border-destructive/40 p-8 text-center">
|
||||||
|
<p className="font-medium text-destructive">API 文档加载失败</p>
|
||||||
|
<Button className="mt-4" variant="outline" onClick={() => void specification.refetch()}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{specification.data && (
|
||||||
|
<div className="overflow-hidden rounded-lg border">
|
||||||
|
<ApiReferenceReact
|
||||||
|
key={document}
|
||||||
|
configuration={{
|
||||||
|
content: specification.data,
|
||||||
|
agent: { disabled: true },
|
||||||
|
hideModels: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user