|
|
@@ -8,6 +8,7 @@ import {
|
|
|
} from "@ant-design/icons";
|
|
|
import {
|
|
|
App,
|
|
|
+ Badge,
|
|
|
Button,
|
|
|
Descriptions,
|
|
|
Form,
|
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
Switch,
|
|
|
Table,
|
|
|
Tag,
|
|
|
+ Typography,
|
|
|
} from "antd";
|
|
|
import type { ColumnsType, TablePaginationConfig } from "antd/es/table";
|
|
|
import type React from "react";
|
|
|
@@ -32,11 +34,141 @@ import {
|
|
|
import type {
|
|
|
PlaymateAdminQuery,
|
|
|
PlaymateSkillAdminDto,
|
|
|
+ PlaymateSkillExtendFieldAdminSub,
|
|
|
PlaymateUserAdminDto,
|
|
|
SkillInfoAdminDto,
|
|
|
} from "@/types/api/playmate";
|
|
|
import { formatTimestamp } from "@/utils/date";
|
|
|
|
|
|
+const { Text } = Typography;
|
|
|
+
|
|
|
+function normalizeToStringArray(value: unknown): string[] {
|
|
|
+ if (!value) return [];
|
|
|
+ if (typeof value === "string") return value ? [value] : [];
|
|
|
+ if (Array.isArray(value)) {
|
|
|
+ return value
|
|
|
+ .map((v) => (typeof v === "string" ? v : ""))
|
|
|
+ .filter((x) => !!x);
|
|
|
+ }
|
|
|
+ // Handle common shapes like { url: "..." } or { attachmentUrl: "..." }
|
|
|
+ if (typeof value === "object") {
|
|
|
+ const anyValue = value as Record<string, unknown>;
|
|
|
+ const url =
|
|
|
+ (typeof anyValue.url === "string" && anyValue.url) ||
|
|
|
+ (typeof anyValue.attachmentUrl === "string" && anyValue.attachmentUrl) ||
|
|
|
+ "";
|
|
|
+ return url ? [url] : [];
|
|
|
+ }
|
|
|
+ return [String(value)];
|
|
|
+}
|
|
|
+
|
|
|
+function safeToText(value: unknown): string {
|
|
|
+ if (value === undefined || value === null) return "-";
|
|
|
+ if (typeof value === "string") return value || "-";
|
|
|
+ if (typeof value === "number" || typeof value === "boolean") {
|
|
|
+ return String(value);
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ return JSON.stringify(value, null, 2);
|
|
|
+ } catch {
|
|
|
+ return String(value);
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function renderExtendFieldValue(field: PlaymateSkillExtendFieldAdminSub) {
|
|
|
+ const type = field.type;
|
|
|
+ const urls = normalizeToStringArray(field.value);
|
|
|
+
|
|
|
+ // 5: image
|
|
|
+ if (type === 5) {
|
|
|
+ if (urls.length === 0) return "-";
|
|
|
+ return (
|
|
|
+ <Image.PreviewGroup>
|
|
|
+ <Space wrap>
|
|
|
+ {urls.map((u) => (
|
|
|
+ <Image
|
|
|
+ key={u}
|
|
|
+ src={u}
|
|
|
+ alt={field.fieldName || "image"}
|
|
|
+ width={100}
|
|
|
+ height={100}
|
|
|
+ style={{ objectFit: "cover", borderRadius: 8 }}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ </Space>
|
|
|
+ </Image.PreviewGroup>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // 7: voice
|
|
|
+ if (type === 7) {
|
|
|
+ const url = urls[0];
|
|
|
+ if (!url) return "-";
|
|
|
+ return (
|
|
|
+ <div className="space-y-1">
|
|
|
+ <audio controls src={url}>
|
|
|
+ <track kind="captions" />
|
|
|
+ Your browser does not support audio playback.
|
|
|
+ </audio>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // 8: video
|
|
|
+ if (type === 8) {
|
|
|
+ const url = urls[0];
|
|
|
+ if (!url) return "-";
|
|
|
+ return (
|
|
|
+ <div className="space-y-1">
|
|
|
+ <video
|
|
|
+ controls
|
|
|
+ src={url}
|
|
|
+ style={{ width: 360, maxWidth: "100%", borderRadius: 8 }}
|
|
|
+ >
|
|
|
+ <track kind="captions" />
|
|
|
+ Your browser does not support the video tag.
|
|
|
+ </video>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // 3: multi select
|
|
|
+ if (type === 3) {
|
|
|
+ const vals = Array.isArray(field.value) ? field.value : urls;
|
|
|
+ const items = (Array.isArray(vals) ? vals : [])
|
|
|
+ .map((v) => (typeof v === "string" || typeof v === "number" ? v : ""))
|
|
|
+ .filter((x) => x !== "");
|
|
|
+ if (items.length === 0) return "-";
|
|
|
+ return (
|
|
|
+ <Space wrap>
|
|
|
+ {items.map((v) => (
|
|
|
+ <Tag key={String(v)} color="blue">
|
|
|
+ {String(v)}
|
|
|
+ </Tag>
|
|
|
+ ))}
|
|
|
+ </Space>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // 1: multiline text
|
|
|
+ if (type === 1) {
|
|
|
+ const text = safeToText(field.value);
|
|
|
+ if (text === "-") return "-";
|
|
|
+ return (
|
|
|
+ <pre className="whitespace-pre-wrap warp-break-words m-0 text-xs bg-gray-50 rounded p-2">
|
|
|
+ {text}
|
|
|
+ </pre>
|
|
|
+ );
|
|
|
+ }
|
|
|
+
|
|
|
+ // default: render as text (including single line, number, date, single select...)
|
|
|
+ return (
|
|
|
+ <Text className="whitespace-pre-wrap warp-break-words">
|
|
|
+ {safeToText(field.value)}
|
|
|
+ </Text>
|
|
|
+ );
|
|
|
+}
|
|
|
+
|
|
|
const PlaymateListPage: React.FC = () => {
|
|
|
const { message } = App.useApp();
|
|
|
const [form] = Form.useForm();
|
|
|
@@ -89,6 +221,7 @@ const PlaymateListPage: React.FC = () => {
|
|
|
pageIndex: 1,
|
|
|
userNo: values.userNo || undefined,
|
|
|
enabled: values.enabled,
|
|
|
+ open: values.open,
|
|
|
bizCategoryCodes: values.bizCategoryCodes || undefined,
|
|
|
});
|
|
|
};
|
|
|
@@ -122,6 +255,23 @@ const PlaymateListPage: React.FC = () => {
|
|
|
return formatTimestamp(timestamp);
|
|
|
};
|
|
|
|
|
|
+ const formatWeekNums = (weekNums?: number[]) => {
|
|
|
+ if (!weekNums || weekNums.length === 0) return "-";
|
|
|
+ const map: Record<number, string> = {
|
|
|
+ 1: "周日",
|
|
|
+ 2: "周一",
|
|
|
+ 3: "周二",
|
|
|
+ 4: "周三",
|
|
|
+ 5: "周四",
|
|
|
+ 6: "周五",
|
|
|
+ 7: "周六",
|
|
|
+ };
|
|
|
+ return weekNums
|
|
|
+ .filter((n) => typeof n === "number")
|
|
|
+ .sort((a, b) => a - b)
|
|
|
+ .map((n) => map[n] || `${n}`);
|
|
|
+ };
|
|
|
+
|
|
|
// Open skill detail modal
|
|
|
const handleOpenSkillDetail = async (skillId: string) => {
|
|
|
setDetailLoading(true);
|
|
|
@@ -209,7 +359,7 @@ const PlaymateListPage: React.FC = () => {
|
|
|
title: "用户编号",
|
|
|
dataIndex: "userNo",
|
|
|
key: "userNo",
|
|
|
- width: 150,
|
|
|
+ width: 120,
|
|
|
fixed: "left",
|
|
|
},
|
|
|
{
|
|
|
@@ -231,11 +381,24 @@ const PlaymateListPage: React.FC = () => {
|
|
|
);
|
|
|
},
|
|
|
},
|
|
|
+ {
|
|
|
+ title: "营业开关",
|
|
|
+ dataIndex: "open",
|
|
|
+ key: "open",
|
|
|
+ width: 100,
|
|
|
+ render: (enabled: boolean) => {
|
|
|
+ return enabled ? (
|
|
|
+ <Tag color="success">营业</Tag>
|
|
|
+ ) : (
|
|
|
+ <Tag color="error">休息</Tag>
|
|
|
+ );
|
|
|
+ },
|
|
|
+ },
|
|
|
{
|
|
|
title: "技能列表 (点击查看详情)",
|
|
|
dataIndex: "skills",
|
|
|
key: "skills",
|
|
|
- width: 300,
|
|
|
+ width: 350,
|
|
|
render: (skills: SkillInfoAdminDto[]) => {
|
|
|
if (!skills || skills.length === 0) return "-";
|
|
|
return (
|
|
|
@@ -246,6 +409,13 @@ const PlaymateListPage: React.FC = () => {
|
|
|
color={skill.enabled ? "blue" : "gray"}
|
|
|
className="cursor-pointer hover:opacity-80"
|
|
|
onClick={() => handleOpenSkillDetail(skill.id)}
|
|
|
+ icon={
|
|
|
+ <Badge
|
|
|
+ status={skill.open ? "processing" : "error"}
|
|
|
+ styles={{ root: { marginRight: 6 } }}
|
|
|
+ />
|
|
|
+ }
|
|
|
+ variant={skill.mainSkill ? "outlined" : "filled"}
|
|
|
>
|
|
|
{skill.bizCategoryName}
|
|
|
</Tag>
|
|
|
@@ -254,6 +424,34 @@ const PlaymateListPage: React.FC = () => {
|
|
|
);
|
|
|
},
|
|
|
},
|
|
|
+ {
|
|
|
+ title: "营业时间配置",
|
|
|
+ dataIndex: "acceptSet",
|
|
|
+ key: "acceptSet",
|
|
|
+ width: 330,
|
|
|
+ render: (acceptSet: PlaymateUserAdminDto["acceptSet"]) => {
|
|
|
+ if (!acceptSet) return "-";
|
|
|
+ const weekLabels = formatWeekNums(acceptSet.weekNums);
|
|
|
+ return (
|
|
|
+ <div className="flex flex-col gap-1">
|
|
|
+ <div className="text-xs text-gray-900">
|
|
|
+ {acceptSet.timeRange || "-"}
|
|
|
+ </div>
|
|
|
+ <div className="flex flex-wrap gap-1">
|
|
|
+ {weekLabels === "-" ? (
|
|
|
+ <span className="text-xs text-gray-500">-</span>
|
|
|
+ ) : (
|
|
|
+ weekLabels.map((label) => (
|
|
|
+ <Tag key={label} color="blue">
|
|
|
+ {label}
|
|
|
+ </Tag>
|
|
|
+ ))
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+ },
|
|
|
+ },
|
|
|
{
|
|
|
title: "创建时间",
|
|
|
dataIndex: "createdAt",
|
|
|
@@ -291,8 +489,14 @@ const PlaymateListPage: React.FC = () => {
|
|
|
</Form.Item>
|
|
|
<Form.Item label="启用状态" name="enabled">
|
|
|
<Select placeholder="请选择启用状态" style={{ width: 120 }}>
|
|
|
- <Select.Option value={true}>已启用</Select.Option>
|
|
|
- <Select.Option value={false}>已禁用</Select.Option>
|
|
|
+ <Select.Option value={true}>启用</Select.Option>
|
|
|
+ <Select.Option value={false}>禁用</Select.Option>
|
|
|
+ </Select>
|
|
|
+ </Form.Item>
|
|
|
+ <Form.Item label="营业开关" name="open">
|
|
|
+ <Select placeholder="营业状态" style={{ width: 120 }} allowClear>
|
|
|
+ <Select.Option value={true}>营业</Select.Option>
|
|
|
+ <Select.Option value={false}>休息</Select.Option>
|
|
|
</Select>
|
|
|
</Form.Item>
|
|
|
<Form.Item label="品类筛选" name="bizCategoryCodes">
|
|
|
@@ -344,7 +548,7 @@ const PlaymateListPage: React.FC = () => {
|
|
|
pageSizeOptions: ["10", "20", "30", "40", "50", "100", "200"],
|
|
|
}}
|
|
|
onChange={handleTableChange}
|
|
|
- scroll={{ x: 1200 }}
|
|
|
+ scroll={{ x: "max-content" }}
|
|
|
size="small"
|
|
|
bordered
|
|
|
/>
|
|
|
@@ -372,85 +576,98 @@ const PlaymateListPage: React.FC = () => {
|
|
|
loading={detailLoading}
|
|
|
>
|
|
|
{currentSkill && (
|
|
|
- <Descriptions bordered column={2} className="mt-4">
|
|
|
- <Descriptions.Item label="技能ID" span={2}>
|
|
|
- {currentSkill.id}
|
|
|
- </Descriptions.Item>
|
|
|
- <Descriptions.Item label="陪玩师编号">
|
|
|
- {currentSkill.userNo}
|
|
|
- </Descriptions.Item>
|
|
|
- <Descriptions.Item label="陪玩师昵称">
|
|
|
- {currentSkill.userNickname}
|
|
|
- </Descriptions.Item>
|
|
|
- <Descriptions.Item label="技能状态">
|
|
|
- {currentSkill.enabled ? (
|
|
|
- <Tag color="success">启用</Tag>
|
|
|
- ) : (
|
|
|
- <Tag color="error">禁用</Tag>
|
|
|
- )}
|
|
|
- </Descriptions.Item>
|
|
|
- <Descriptions.Item label="品类">
|
|
|
- {currentSkill.bizCategoryName || "-"}
|
|
|
- </Descriptions.Item>
|
|
|
- <Descriptions.Item label="价格 (金币)">
|
|
|
- {currentSkill.price !== undefined ? `${currentSkill.price}` : "-"}
|
|
|
- </Descriptions.Item>
|
|
|
- <Descriptions.Item label="单位">
|
|
|
- {currentSkill.unit || "-"}
|
|
|
- </Descriptions.Item>
|
|
|
- <Descriptions.Item label="标签" span={2}>
|
|
|
- {currentSkill.labels && currentSkill.labels.length > 0 ? (
|
|
|
- <Space wrap>
|
|
|
- {currentSkill.labels.map((label) => (
|
|
|
- <Tag key={label} color="blue">
|
|
|
- {label}
|
|
|
- </Tag>
|
|
|
- ))}
|
|
|
- </Space>
|
|
|
- ) : (
|
|
|
- "-"
|
|
|
- )}
|
|
|
- </Descriptions.Item>
|
|
|
- <Descriptions.Item label="简介" span={2}>
|
|
|
- {currentSkill.summary || "-"}
|
|
|
- </Descriptions.Item>
|
|
|
- <Descriptions.Item label="技能图片" span={2}>
|
|
|
- {currentSkill.images && currentSkill.images.length > 0 ? (
|
|
|
- <Image.PreviewGroup>
|
|
|
+ <div className="mt-4 space-y-4">
|
|
|
+ <Descriptions bordered column={2}>
|
|
|
+ <Descriptions.Item label="技能ID" span={2}>
|
|
|
+ {currentSkill.id}
|
|
|
+ </Descriptions.Item>
|
|
|
+ <Descriptions.Item label="陪玩师编号">
|
|
|
+ {currentSkill.userNo}
|
|
|
+ </Descriptions.Item>
|
|
|
+ <Descriptions.Item label="陪玩师昵称">
|
|
|
+ {currentSkill.userNickname}
|
|
|
+ </Descriptions.Item>
|
|
|
+ <Descriptions.Item label="技能状态">
|
|
|
+ {currentSkill.enabled ? (
|
|
|
+ <Tag color="success">启用</Tag>
|
|
|
+ ) : (
|
|
|
+ <Tag color="error">禁用</Tag>
|
|
|
+ )}
|
|
|
+ </Descriptions.Item>
|
|
|
+ <Descriptions.Item label="品类">
|
|
|
+ {currentSkill.bizCategoryName || "-"}
|
|
|
+ </Descriptions.Item>
|
|
|
+ <Descriptions.Item label="价格 (金币)">
|
|
|
+ {currentSkill.price !== undefined
|
|
|
+ ? `${currentSkill.price}`
|
|
|
+ : "-"}
|
|
|
+ </Descriptions.Item>
|
|
|
+ <Descriptions.Item label="单位">
|
|
|
+ {currentSkill.unit || "-"}
|
|
|
+ </Descriptions.Item>
|
|
|
+ <Descriptions.Item label="标签" span={2}>
|
|
|
+ {currentSkill.labels && currentSkill.labels.length > 0 ? (
|
|
|
<Space wrap>
|
|
|
- {currentSkill.images.map((img) => (
|
|
|
- <Image
|
|
|
- key={img}
|
|
|
- src={img}
|
|
|
- alt="技能图片"
|
|
|
- width={120}
|
|
|
- height={80}
|
|
|
- style={{ objectFit: "cover" }}
|
|
|
- />
|
|
|
+ {currentSkill.labels.map((label) => (
|
|
|
+ <Tag key={label} color="blue">
|
|
|
+ {label}
|
|
|
+ </Tag>
|
|
|
))}
|
|
|
</Space>
|
|
|
- </Image.PreviewGroup>
|
|
|
- ) : (
|
|
|
- "-"
|
|
|
- )}
|
|
|
- </Descriptions.Item>
|
|
|
- <Descriptions.Item label="语音介绍" span={2}>
|
|
|
- {currentSkill.voiceBar ? (
|
|
|
- <audio controls src={currentSkill.voiceBar}>
|
|
|
- <track kind="captions" />
|
|
|
- 您的浏览器不支持音频播放
|
|
|
- </audio>
|
|
|
- ) : (
|
|
|
- "-"
|
|
|
- )}
|
|
|
- </Descriptions.Item>
|
|
|
- <Descriptions.Item label="创建时间">
|
|
|
- {formatDate(currentSkill.createdAt || 0)}
|
|
|
- </Descriptions.Item>
|
|
|
- <Descriptions.Item label="更新时间">
|
|
|
- {formatDate(currentSkill.updatedAt || 0)}
|
|
|
- </Descriptions.Item>
|
|
|
- </Descriptions>
|
|
|
+ ) : (
|
|
|
+ "-"
|
|
|
+ )}
|
|
|
+ </Descriptions.Item>
|
|
|
+ <Descriptions.Item label="简介" span={2}>
|
|
|
+ {currentSkill.summary || "-"}
|
|
|
+ </Descriptions.Item>
|
|
|
+ <Descriptions.Item label="技能图片" span={2}>
|
|
|
+ {currentSkill.images && currentSkill.images.length > 0 ? (
|
|
|
+ <Image.PreviewGroup>
|
|
|
+ <Space wrap>
|
|
|
+ {currentSkill.images.map((img) => (
|
|
|
+ <Image
|
|
|
+ key={img}
|
|
|
+ src={img}
|
|
|
+ alt="技能图片"
|
|
|
+ width={120}
|
|
|
+ height={80}
|
|
|
+ style={{ objectFit: "cover" }}
|
|
|
+ />
|
|
|
+ ))}
|
|
|
+ </Space>
|
|
|
+ </Image.PreviewGroup>
|
|
|
+ ) : (
|
|
|
+ "-"
|
|
|
+ )}
|
|
|
+ </Descriptions.Item>
|
|
|
+ <Descriptions.Item label="语音介绍" span={2}>
|
|
|
+ {currentSkill.voiceBar ? (
|
|
|
+ <audio controls src={currentSkill.voiceBar}>
|
|
|
+ <track kind="captions" />
|
|
|
+ 您的浏览器不支持音频播放
|
|
|
+ </audio>
|
|
|
+ ) : (
|
|
|
+ "-"
|
|
|
+ )}
|
|
|
+ </Descriptions.Item>
|
|
|
+ {(currentSkill.extendFields || []).length > 0 &&
|
|
|
+ (currentSkill.extendFields || []).map((f, idx) => (
|
|
|
+ <Descriptions.Item
|
|
|
+ key={`${f.fieldCode || f.fieldName || "field"}-${idx}`}
|
|
|
+ label={f.fieldName || "-"}
|
|
|
+ >
|
|
|
+ {renderExtendFieldValue(f)}
|
|
|
+ </Descriptions.Item>
|
|
|
+ ))}
|
|
|
+ <Descriptions.Item label="创建时间">
|
|
|
+ {formatDate(currentSkill.createdAt || 0)}
|
|
|
+ </Descriptions.Item>
|
|
|
+ <Descriptions.Item label="更新时间">
|
|
|
+ {formatDate(currentSkill.updatedAt || 0)}
|
|
|
+ </Descriptions.Item>
|
|
|
+ </Descriptions>
|
|
|
+ </div>
|
|
|
)}
|
|
|
</Modal>
|
|
|
|