Browse Source

Merge branch 'v1.1.0' into test

0es 1 month ago
parent
commit
852f33fd78

+ 298 - 81
src/app/(dashboard)/playmate/list/page.tsx

@@ -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>
 

+ 21 - 15
src/app/(dashboard)/user/list/page.tsx

@@ -741,19 +741,19 @@ const UserListPage: React.FC = () => {
                     钻石钱包
                   </div>
                 </Col>
-                <Col span={6}>
+                <Col span={8}>
                   <Statistic
                     title="总充值"
                     value={userDetail.walletDiamond?.totalRecharge || 0}
                   />
                 </Col>
-                <Col span={6}>
+                <Col span={8}>
                   <Statistic
                     title="总消费"
                     value={userDetail.walletDiamond?.totalConsume || 0}
                   />
                 </Col>
-                <Col span={6}>
+                <Col span={8}>
                   <Statistic
                     title="当前余额"
                     value={userDetail.walletDiamond?.amount || 0}
@@ -768,19 +768,19 @@ const UserListPage: React.FC = () => {
                     金币钱包
                   </div>
                 </Col>
-                <Col span={6}>
+                <Col span={8}>
                   <Statistic
                     title="总充值"
                     value={userDetail.walletGoldCoin?.totalRecharge || 0}
                   />
                 </Col>
-                <Col span={6}>
+                <Col span={8}>
                   <Statistic
                     title="总消费"
                     value={userDetail.walletGoldCoin?.totalConsume || 0}
                   />
                 </Col>
-                <Col span={6}>
+                <Col span={8}>
                   <Statistic
                     title="当前余额"
                     value={userDetail.walletGoldCoin?.amount || 0}
@@ -795,30 +795,36 @@ const UserListPage: React.FC = () => {
                     金豆钱包
                   </div>
                 </Col>
-                <Col span={6}>
-                  <Statistic
-                    title="总提现"
-                    value={userDetail.walletBean?.totalWithdraw || 0}
-                  />
-                </Col>
-                <Col span={6}>
+                <Col span={8}>
                   <Statistic
                     title="未结算余额"
                     value={userDetail.walletBean?.unsettledAmount || 0}
                   />
                 </Col>
-                <Col span={6}>
+                <Col span={8}>
                   <Statistic
                     title="可用余额"
                     value={userDetail.walletBean?.availableAmount || 0}
                   />
                 </Col>
-                <Col span={6}>
+                <Col span={8}>
                   <Statistic
                     title="总余额"
                     value={userDetail.walletBean?.amount || 0}
                   />
                 </Col>
+                <Col span={8}>
+                  <Statistic
+                    title="总提现"
+                    value={userDetail.walletBean?.totalWithdraw || 0}
+                  />
+                </Col>
+                <Col span={8}>
+                  <Statistic
+                    title="提现冻结"
+                    value={userDetail.walletBean?.withdrawFreeze || 0}
+                  />
+                </Col>
               </Row>
             </Card>
 

+ 16 - 0
src/app/(dashboard)/wallet/components/WalletRecordPage.tsx

@@ -400,6 +400,22 @@ const WalletRecordPage: React.FC<WalletRecordPageProps> = ({
         align: "right",
         render: (v: number) => (v === undefined || v === null ? "-" : v),
       },
+      {
+        title: "发生前提现冻结金额",
+        dataIndex: "beforeWithdrawFreezeAmount",
+        key: "beforeWithdrawFreezeAmount",
+        width: 160,
+        align: "right",
+        render: (v: number) => (v === undefined || v === null ? "-" : v),
+      },
+      {
+        title: "发生后提现冻结金额",
+        dataIndex: "afterWithdrawFreezeAmount",
+        key: "afterWithdrawFreezeAmount",
+        width: 160,
+        align: "right",
+        render: (v: number) => (v === undefined || v === null ? "-" : v),
+      },
     ];
 
     const tail: ColumnsType<WalletRecordAdminDTO> = [

+ 1 - 0
src/types/api/index.ts

@@ -149,6 +149,7 @@ export type {
   PlaymateAdminQuery,
   PlaymateUserAdminDto,
   SkillInfoAdminDto,
+  UserPlaymateSetSub,
 } from "./playmate";
 // Playmate apply (approval) types
 export type {

+ 87 - 0
src/types/api/playmate.ts

@@ -14,6 +14,30 @@ export interface SkillInfoAdminDto {
   bizCategoryCode: string;
   bizCategoryName: string;
   enabled: boolean;
+  /**
+   * Whether this skill is open for accepting orders
+   */
+  open: boolean;
+  /**
+   * Whether this is the main skill
+   */
+  mainSkill: boolean;
+}
+
+export interface UserPlaymateSetSub {
+  /**
+   * Time range format: 00:00-12:00 (24-hour)
+   */
+  timeRange: string;
+  /**
+   * 1: Sunday, 2: Monday, ..., 7: Saturday
+   */
+  weekNums: number[];
+  /**
+   * Weekly time ranges, key is week number ("1".."7"),
+   * value is array of ranges like "00:00:00-23:59:59"
+   */
+  weekTimeRange: Record<string, string[]>;
 }
 
 // Playmate user DTO
@@ -23,6 +47,14 @@ export interface PlaymateUserAdminDto {
   userNo: string;
   userNickname: string;
   skills: SkillInfoAdminDto[];
+  /**
+   * Playmate business switch
+   */
+  open: boolean;
+  /**
+   * Playmate business time configuration
+   */
+  acceptSet: UserPlaymateSetSub;
   createdAt: number;
   updatedAt: number;
 }
@@ -33,6 +65,10 @@ export interface PlaymateAdminQuery {
   pageIndex: number;
   sort?: QuerySort;
   enabled?: boolean;
+  /**
+   * Playmate business switch
+   */
+  open?: boolean;
   userNo?: string;
   bizCategoryCodes?: string[];
   skip?: number;
@@ -51,6 +87,41 @@ export interface PagerPlaymateUserAdminDto {
   items: PlaymateUserAdminDto[];
 }
 
+/**
+ * Dynamic (extend) field item for playmate skill detail.
+ * This is aligned with backend `PlaymateApplyFieldAdminSub`-like structure.
+ */
+export interface PlaymateSkillExtendFieldAdminSub {
+  /**
+   * 字段编码(不可重复)
+   */
+  fieldCode?: string;
+  /**
+   * 字段名称
+   */
+  fieldName?: string;
+  /**
+   * 字段值(可能是 string / number / array / object / url 等)
+   */
+  value?: unknown;
+  /**
+   * 字段值对应类型(BizCategoryFieldValueClassConsts)
+   */
+  valueClassType?: number;
+  /**
+   * 字段控件类型(BizCategoryFieldTypeConsts)
+   */
+  type?: number;
+  /**
+   * 时长秒,type 类型时语音/视频时有效
+   */
+  duration?: number;
+  /**
+   * 值是否数组
+   */
+  array?: boolean;
+}
+
 // Playmate skill create DTO
 export interface PlaymateSkillAdminDto {
   id?: string;
@@ -66,6 +137,22 @@ export interface PlaymateSkillAdminDto {
   summary?: string;
   images?: string[];
   voiceBar?: string;
+  /**
+   * Voice bar duration (seconds)
+   */
+  voiceBarDuration?: number;
   createdAt?: number;
   updatedAt?: number;
+  /**
+   * Whether this skill is open for accepting orders
+   */
+  open?: boolean;
+  /**
+   * Whether this is the main skill
+   */
+  mainSkill?: boolean;
+  /**
+   * Dynamic fields for skill detail
+   */
+  extendFields?: PlaymateSkillExtendFieldAdminSub[];
 }

+ 1 - 0
src/types/api/user.ts

@@ -102,6 +102,7 @@ export interface UserWalletBeanAdminDTO {
   amount: number;
   unsettledAmount: number;
   availableAmount: number;
+  withdrawFreeze: number;
 }
 
 // User full info (detail)