Просмотр исходного кода

Merge pull request #2497 from visuddhinanda/development

Development
visuddhinanda 6 дней назад
Родитель
Сommit
6d94e95f2c
38 измененных файлов с 3797 добавлено и 129 удалено
  1. 1 0
      CLAUDE.md
  2. 70 0
      api-test/cases/10_tipitaka_chapter_channels.php
  3. 537 0
      api-test/client/lib/Api/TipitakaChapterChannelsApi.php
  4. 443 0
      api-test/client/lib/Model/GetApiV3TipitakaChapterChannelsBookPara200Response.php
  5. 695 0
      api-test/client/lib/Model/GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInner.php
  6. 511 0
      api-test/client/lib/Model/GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerChannel.php
  7. 511 0
      api-test/client/lib/Model/GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerStudio.php
  8. 50 0
      api-v13/app/Http/Controllers/V3/TipitakaChapterChannelController.php
  9. 32 0
      api-v13/app/Http/Requests/V3/IndexTipitakaChapterChannelRequest.php
  10. 52 0
      api-v13/app/Http/Resources/V3/TipitakaChapterChannelResource.php
  11. 18 15
      api-v13/app/Services/TipitakaCatalogService.php
  12. 196 0
      api-v13/app/Services/V3/TipitakaChapterChannelService.php
  13. 3 2
      api-v13/resources/views/library/tipitaka/channels/_row.blade.php
  14. 5 0
      api-v13/routes/v3/tipitaka.php
  15. 22 0
      api-v13/tests/Feature/LibraryTipitakaChannelTest.php
  16. 155 0
      api-v13/tests/Feature/TipitakaChapterChannelV3Test.php
  17. 76 0
      dashboard-test/src/tests/tipitaka-chapter-channels.spec.ts
  18. 0 34
      dashboard-v6/src/api/progress.ts
  19. 87 0
      dashboard-v6/src/api/schema.d.ts
  20. 26 0
      dashboard-v6/src/api/tipitaka-chapter-channel.ts
  21. 1 1
      dashboard-v6/src/components/channel/ChannelListItem.tsx
  22. 31 24
      dashboard-v6/src/components/tipitaka/BookViewer.tsx
  23. 9 8
      dashboard-v6/src/components/tipitaka/ChapterInChannel.tsx
  24. 92 8
      dashboard-v6/src/components/tipitaka/PaliChapterCard.tsx
  25. 15 20
      dashboard-v6/src/components/tipitaka/PaliChapterChannelList.tsx
  26. 11 0
      dashboard-v6/src/components/tipitaka/PaliChapterList.module.css
  27. 30 14
      dashboard-v6/src/components/tipitaka/PaliChapterList.tsx
  28. 7 1
      dashboard-v6/src/components/tipitaka/PaliChapterListByPara.tsx
  29. 1 0
      dashboard-v6/src/locales/en-US/buttons.ts
  30. 5 0
      dashboard-v6/src/locales/en-US/label.ts
  31. 1 0
      dashboard-v6/src/locales/zh-Hans/buttons.ts
  32. 5 0
      dashboard-v6/src/locales/zh-Hans/label.ts
  33. 1 0
      dashboard-v6/src/locales/zh-Hant/buttons.ts
  34. 5 0
      dashboard-v6/src/locales/zh-Hant/label.ts
  35. 2 0
      openapi/public/assets/protocol/main.yaml
  36. 1 1
      openapi/public/assets/protocol/overrides/v2-progress.yaml
  37. 1 1
      openapi/public/assets/protocol/resources/auto/v2-progress.yaml
  38. 89 0
      openapi/public/assets/protocol/resources/auto/v3-tipitaka-chapter-channels-book-para.yaml

+ 1 - 0
CLAUDE.md

@@ -354,6 +354,7 @@ Resource / FormRequest / 测试同理;URL 只换版本前缀
 | `access-token`(agent 用途)/ `AccessTokenController@store` | `agent-grants` / `V3\AgentGrantController` | 人类为自己名下的 AI 模型签 channel 写入授权。载荷由服务端决定(v2 原样签客户端 payload)、绑定模型(act)与签发人(grantor)、带模型 token_version、独立密钥;写入时放 `X-Agent-Grant` 头而不是请求体 |
 | `sent-discussion-tree`(部分)/ `DiscussionController@discussion_tree` | `tipitaka-discussions/{channel}` / `V3\TipitakaDiscussionController` | 新能力为主:按经文范围(channel 必填;`book` + `chapter` 或 `para`/`to`,不给 book 即整个 channel)列挂在句子上的话题,每条带 `sentence` 坐标,按经文顺序排;`type` 可逗号多选,默认只列话题,可见性同 `/v3/discussions`。v2 那条要调用方逐句列出坐标、只回标题与回复数,未迁也不迁(只有 v4 在用)。与 `/v3/discussions`(单个对象)必填参数不同,按规范第 1 条分成两个端点 |
 | `sign-in` + `auth/current` / `AuthController@signIn` + `@getUserInfoByToken` | `sessions` + `me` / `V3\SessionController` + `V3\MeController` | 登录一次返回 `{token, user}`,不再二次查询;`login` 字段收用户名或邮箱(邮箱不区分大小写),失败统一 422 `errors.login`;按账号 + IP 限流。`me` 的字段沿用 v2 的驼峰用户摘要(`UserService::profile()`),`roles` 恒为数组,另加 `email`,不再回显 token。token 与 v2 完全相同、两边互认 |
+| `progress?view=chapter_channels` / `ProgressChapterController@index`(一个 view 分支) | `tipitaka-chapter-channels/{book}/{para}` / `V3\TipitakaChapterChannelController` | 某章有哪些 channel 及完成度(阅读页版本切换)。`par`→路径 `{para}`;**只列看得见的 channel**(公开 + 自己的 + 共享给自己或所在群组的;v2 连私有 channel 也对所有人列);字段重组:行 `uid`→`id`,`channel_id`/`name`/`type` 收进 `channel{id,name,type,lang}`;去掉 `likes`(前端没用);views/studio 整页批量查,消除 N+1;按 progress 降序 + channel_id 稳定排序,分页(per_page 最大 200)。v6、mobile、wikipali-plugins(`versions --chapter`)已切 |
 
 **注意**:`tipitaka-reading` **不是** v2 `paragraph-content` / `chapter-content` 的替代品。
 那两个 v2 端点支持多 channel(`channels=a,b,c`)与 edit 模式、返回 sentenceIds 供编辑器用;

+ 70 - 0
api-test/cases/10_tipitaka_chapter_channels.php

@@ -0,0 +1,70 @@
+<?php
+
+use \WikipaliApi\Api\TipitakaChapterChannelsApi;
+
+/*
+ * 一章有哪些 channel 及完成度:GET /v3/tipitaka-chapter-channels/{book}/{para}
+ *
+ * 只读、无写操作。章节坐标从 fixture channel 自己的进度里取(各服务器数据不同),
+ * 再核对这一章的列表里有它。fixture channel 是公开的,匿名就看得到。
+ * 可见性矩阵(私有 / 共享 / 群组)、排序、N+1 由 api-v13 的 TipitakaChapterChannelV3Test 覆盖。
+ */
+
+/** fixture channel 有进度的某一章的 [book, para] */
+function chapter_channel_coord(): array
+{
+    $res = raw('GET', '/v3/progress', ['channels' => fixtures()['channel'], 'per_page' => 1]);
+    assert_status(200, $res, '取 fixture channel 的章节进度');
+    $row = $res['body']['data'][0] ?? null;
+    if (! $row) {
+        skip('fixture channel 在这台服务器上没有章节进度');
+    }
+
+    return [(int) $row['book'], (int) $row['para']];
+}
+
+test('GET /v3/tipitaka-chapter-channels/{book}/{para} → 200,含 fixture channel', function () {
+    [$book, $para] = chapter_channel_coord();
+    $res = request(fn () => api(TipitakaChapterChannelsApi::class)
+        ->getApiV3TipitakaChapterChannelsBookParaWithHttpInfo($book, $para, 200));
+    assert_status(200, $res, 'tipitaka-chapter-channels');
+    assert_has_key('data', $res['body'], 'tipitaka-chapter-channels');
+    assert_has_key('meta', $res['body'], 'tipitaka-chapter-channels');
+
+    $hit = null;
+    foreach ($res['body']['data'] as $row) {
+        if (($row['channel']['id'] ?? null) === fixtures()['channel']) {
+            $hit = $row;
+        }
+    }
+    if (! $hit) {
+        fail('tipitaka-chapter-channels:应包含 fixture channel');
+    }
+    assert_eq($book, $hit['book'] ?? null, 'book');
+    assert_eq($para, $hit['para'] ?? null, 'para');
+    foreach (['id', 'progress', 'views', 'updated_at', 'studio'] as $key) {
+        assert_has_key($key, $hit, 'tipitaka-chapter-channels 行');
+    }
+
+    // 按完成度降序
+    $progress = array_column($res['body']['data'], 'progress');
+    $sorted = $progress;
+    rsort($sorted);
+    assert_eq($sorted, $progress, '按 progress 降序');
+});
+
+test('GET /v3/tipitaka-chapter-channels/{book}/{para}(没有进度的章节)→ 200 空列表', function () {
+    $res = raw('GET', '/v3/tipitaka-chapter-channels/1/999999');
+    assert_status(200, $res, '空章节');
+    assert_eq([], $res['body']['data'] ?? null, '空章节 data');
+});
+
+test('GET /v3/tipitaka-chapter-channels/{book}/{para}?per_page=500 → 422', function () {
+    $res = raw('GET', '/v3/tipitaka-chapter-channels/1/2', ['per_page' => 500]);
+    assert_status(422, $res, 'per_page 超限');
+    assert_field_error('per_page', $res, 'per_page 超限');
+});
+
+test('GET /v3/tipitaka-chapter-channels/{非数字}/{para} → 404', function () {
+    assert_status(404, raw('GET', '/v3/tipitaka-chapter-channels/abc/2'), '非数字 book');
+});

+ 537 - 0
api-test/client/lib/Api/TipitakaChapterChannelsApi.php

@@ -0,0 +1,537 @@
+<?php
+/**
+ * TipitakaChapterChannelsApi
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.11.9
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Api;
+
+use GuzzleHttp\Client;
+use GuzzleHttp\ClientInterface;
+use GuzzleHttp\Exception\ConnectException;
+use GuzzleHttp\Exception\RequestException;
+use GuzzleHttp\Psr7\MultipartStream;
+use GuzzleHttp\Psr7\Request;
+use GuzzleHttp\RequestOptions;
+use Psr\Http\Message\RequestInterface;
+use Psr\Http\Message\ResponseInterface;
+use WikipaliApi\ApiException;
+use WikipaliApi\Configuration;
+use WikipaliApi\FormDataProcessor;
+use WikipaliApi\HeaderSelector;
+use WikipaliApi\ObjectSerializer;
+
+/**
+ * TipitakaChapterChannelsApi Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+class TipitakaChapterChannelsApi
+{
+    /**
+     * @var ClientInterface
+     */
+    protected $client;
+
+    /**
+     * @var Configuration
+     */
+    protected $config;
+
+    /**
+     * @var HeaderSelector
+     */
+    protected $headerSelector;
+
+    /**
+     * @var int Host index
+     */
+    protected $hostIndex;
+
+    /** @var string[] $contentTypes **/
+    public const contentTypes = [
+        'getApiV3TipitakaChapterChannelsBookPara' => [
+            'application/json',
+        ],
+    ];
+
+    /**
+     * @param ClientInterface $client
+     * @param Configuration   $config
+     * @param HeaderSelector  $selector
+     * @param int             $hostIndex (Optional) host index to select the list of hosts if defined in the OpenAPI spec
+     */
+    public function __construct(
+        ?ClientInterface $client = null,
+        ?Configuration $config = null,
+        ?HeaderSelector $selector = null,
+        int $hostIndex = 0
+    ) {
+        $this->client = $client ?: new Client();
+        $this->config = $config ?: Configuration::getDefaultConfiguration();
+        $this->headerSelector = $selector ?: new HeaderSelector();
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Set the host index
+     *
+     * @param int $hostIndex Host index (required)
+     */
+    public function setHostIndex($hostIndex): void
+    {
+        $this->hostIndex = $hostIndex;
+    }
+
+    /**
+     * Get the host index
+     *
+     * @return int Host index
+     */
+    public function getHostIndex()
+    {
+        return $this->hostIndex;
+    }
+
+    /**
+     * @return Configuration
+     */
+    public function getConfig()
+    {
+        return $this->config;
+    }
+
+    /**
+     * Operation getApiV3TipitakaChapterChannelsBookPara
+     *
+     * 列出一章的 channel
+     *
+     * @param  int $book 书号 (required)
+     * @param  int $para 章节起始段落号 (required)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 100)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaChapterChannelsBookPara'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return \WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200Response|\WikipaliApi\Model\ProblemDetails
+     */
+    public function getApiV3TipitakaChapterChannelsBookPara($book, $para, $per_page = 100, $page = 1, string $contentType = self::contentTypes['getApiV3TipitakaChapterChannelsBookPara'][0])
+    {
+        list($response) = $this->getApiV3TipitakaChapterChannelsBookParaWithHttpInfo($book, $para, $per_page, $page, $contentType);
+        return $response;
+    }
+
+    /**
+     * Operation getApiV3TipitakaChapterChannelsBookParaWithHttpInfo
+     *
+     * 列出一章的 channel
+     *
+     * @param  int $book 书号 (required)
+     * @param  int $para 章节起始段落号 (required)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 100)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaChapterChannelsBookPara'] to see the possible values for this operation
+     *
+     * @throws \WikipaliApi\ApiException on non-2xx response or if the response body is not in the expected format
+     * @throws \InvalidArgumentException
+     * @return array of \WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200Response|\WikipaliApi\Model\ProblemDetails, HTTP status code, HTTP response headers (array of strings)
+     */
+    public function getApiV3TipitakaChapterChannelsBookParaWithHttpInfo($book, $para, $per_page = 100, $page = 1, string $contentType = self::contentTypes['getApiV3TipitakaChapterChannelsBookPara'][0])
+    {
+        $request = $this->getApiV3TipitakaChapterChannelsBookParaRequest($book, $para, $per_page, $page, $contentType);
+
+        try {
+            $options = $this->createHttpClientOption();
+            try {
+                $response = $this->client->send($request, $options);
+            } catch (RequestException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    $e->getResponse() ? $e->getResponse()->getHeaders() : null,
+                    $e->getResponse() ? (string) $e->getResponse()->getBody() : null
+                );
+            } catch (ConnectException $e) {
+                throw new ApiException(
+                    "[{$e->getCode()}] {$e->getMessage()}",
+                    (int) $e->getCode(),
+                    null,
+                    null
+                );
+            }
+
+            $statusCode = $response->getStatusCode();
+
+
+            switch($statusCode) {
+                case 200:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200Response',
+                        $request,
+                        $response,
+                    );
+                case 422:
+                    return $this->handleResponseWithDataType(
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $request,
+                        $response,
+                    );
+            }
+
+            
+
+            if ($statusCode < 200 || $statusCode > 299) {
+                throw new ApiException(
+                    sprintf(
+                        '[%d] Error connecting to the API (%s)',
+                        $statusCode,
+                        (string) $request->getUri()
+                    ),
+                    $statusCode,
+                    $response->getHeaders(),
+                    (string) $response->getBody()
+                );
+            }
+
+            return $this->handleResponseWithDataType(
+                '\WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200Response',
+                $request,
+                $response,
+            );
+        } catch (ApiException $e) {
+            switch ($e->getCode()) {
+                case 200:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200Response',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+                case 422:
+                    $data = ObjectSerializer::deserialize(
+                        $e->getResponseBody(),
+                        '\WikipaliApi\Model\ProblemDetails',
+                        $e->getResponseHeaders()
+                    );
+                    $e->setResponseObject($data);
+                    throw $e;
+            }
+        
+
+            throw $e;
+        }
+    }
+
+    /**
+     * Operation getApiV3TipitakaChapterChannelsBookParaAsync
+     *
+     * 列出一章的 channel
+     *
+     * @param  int $book 书号 (required)
+     * @param  int $para 章节起始段落号 (required)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 100)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaChapterChannelsBookPara'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3TipitakaChapterChannelsBookParaAsync($book, $para, $per_page = 100, $page = 1, string $contentType = self::contentTypes['getApiV3TipitakaChapterChannelsBookPara'][0])
+    {
+        return $this->getApiV3TipitakaChapterChannelsBookParaAsyncWithHttpInfo($book, $para, $per_page, $page, $contentType)
+            ->then(
+                function ($response) {
+                    return $response[0];
+                }
+            );
+    }
+
+    /**
+     * Operation getApiV3TipitakaChapterChannelsBookParaAsyncWithHttpInfo
+     *
+     * 列出一章的 channel
+     *
+     * @param  int $book 书号 (required)
+     * @param  int $para 章节起始段落号 (required)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 100)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaChapterChannelsBookPara'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Promise\PromiseInterface
+     */
+    public function getApiV3TipitakaChapterChannelsBookParaAsyncWithHttpInfo($book, $para, $per_page = 100, $page = 1, string $contentType = self::contentTypes['getApiV3TipitakaChapterChannelsBookPara'][0])
+    {
+        $returnType = '\WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200Response';
+        $request = $this->getApiV3TipitakaChapterChannelsBookParaRequest($book, $para, $per_page, $page, $contentType);
+
+        return $this->client
+            ->sendAsync($request, $this->createHttpClientOption())
+            ->then(
+                function ($response) use ($returnType) {
+                    if ($returnType === '\SplFileObject') {
+                        $content = $response->getBody(); //stream goes to serializer
+                    } else {
+                        $content = (string) $response->getBody();
+                        if ($returnType !== 'string') {
+                            $content = json_decode($content);
+                        }
+                    }
+
+                    return [
+                        ObjectSerializer::deserialize($content, $returnType, []),
+                        $response->getStatusCode(),
+                        $response->getHeaders()
+                    ];
+                },
+                function ($exception) {
+                    $response = $exception->getResponse();
+                    $statusCode = $response->getStatusCode();
+                    throw new ApiException(
+                        sprintf(
+                            '[%d] Error connecting to the API (%s)',
+                            $statusCode,
+                            $exception->getRequest()->getUri()
+                        ),
+                        $statusCode,
+                        $response->getHeaders(),
+                        (string) $response->getBody()
+                    );
+                }
+            );
+    }
+
+    /**
+     * Create request for operation 'getApiV3TipitakaChapterChannelsBookPara'
+     *
+     * @param  int $book 书号 (required)
+     * @param  int $para 章节起始段落号 (required)
+     * @param  int|null $per_page 每页数量,最大 200 (optional, default to 100)
+     * @param  int|null $page 页码 (optional, default to 1)
+     * @param  string $contentType The value for the Content-Type header. Check self::contentTypes['getApiV3TipitakaChapterChannelsBookPara'] to see the possible values for this operation
+     *
+     * @throws \InvalidArgumentException
+     * @return \GuzzleHttp\Psr7\Request
+     */
+    public function getApiV3TipitakaChapterChannelsBookParaRequest($book, $para, $per_page = 100, $page = 1, string $contentType = self::contentTypes['getApiV3TipitakaChapterChannelsBookPara'][0])
+    {
+
+        // verify the required parameter 'book' is set
+        if ($book === null || (is_array($book) && count($book) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $book when calling getApiV3TipitakaChapterChannelsBookPara'
+            );
+        }
+
+        // verify the required parameter 'para' is set
+        if ($para === null || (is_array($para) && count($para) === 0)) {
+            throw new \InvalidArgumentException(
+                'Missing the required parameter $para when calling getApiV3TipitakaChapterChannelsBookPara'
+            );
+        }
+
+
+
+
+        $resourcePath = '/v3/tipitaka-chapter-channels/{book}/{para}';
+        $formParams = [];
+        $queryParams = [];
+        $headerParams = [];
+        $httpBody = '';
+        $multipart = false;
+
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $per_page,
+            'per_page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+        // query params
+        $queryParams = array_merge($queryParams, ObjectSerializer::toQueryValue(
+            $page,
+            'page', // param base name
+            'integer', // openApiType
+            'form', // style
+            true, // explode
+            false // required
+        ) ?? []);
+
+
+        // path params
+        if ($book !== null) {
+            $resourcePath = str_replace(
+                '{book}',
+                ObjectSerializer::toPathValue($book),
+                $resourcePath
+            );
+        }
+        // path params
+        if ($para !== null) {
+            $resourcePath = str_replace(
+                '{para}',
+                ObjectSerializer::toPathValue($para),
+                $resourcePath
+            );
+        }
+
+
+        $headers = $this->headerSelector->selectHeaders(
+            ['application/json', 'application/problem+json', ],
+            $contentType,
+            $multipart
+        );
+
+        // for model (json/xml)
+        if (count($formParams) > 0) {
+            if ($multipart) {
+                $multipartContents = [];
+                foreach ($formParams as $formParamName => $formParamValue) {
+                    $formParamValueItems = is_array($formParamValue) ? $formParamValue : [$formParamValue];
+                    foreach ($formParamValueItems as $formParamValueItem) {
+                        $multipartContents[] = [
+                            'name' => $formParamName,
+                            'contents' => $formParamValueItem
+                        ];
+                    }
+                }
+                // for HTTP post (form)
+                $httpBody = new MultipartStream($multipartContents);
+
+            } elseif (stripos($headers['Content-Type'], 'application/json') !== false) {
+                # if Content-Type contains "application/json", json_encode the form parameters
+                try {
+                    $httpBody = json_encode($formParams, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $e) {
+                    throw new \InvalidArgumentException('json_encode error: ' . $e->getMessage(), 0, $e);
+                }
+            } else {
+                // for HTTP post (form)
+                $httpBody = ObjectSerializer::buildQuery($formParams);
+            }
+        }
+
+        // this endpoint requires Bearer authentication (access token)
+        if (!empty($this->config->getAccessToken())) {
+            $headers['Authorization'] = 'Bearer ' . $this->config->getAccessToken();
+        }
+
+        $defaultHeaders = [];
+        if ($this->config->getUserAgent()) {
+            $defaultHeaders['User-Agent'] = $this->config->getUserAgent();
+        }
+
+        $headers = array_merge(
+            $defaultHeaders,
+            $headerParams,
+            $headers
+        );
+
+        $operationHost = $this->config->getHost();
+        $query = ObjectSerializer::buildQuery($queryParams);
+        return new Request(
+            'GET',
+            $operationHost . $resourcePath . ($query ? "?{$query}" : ''),
+            $headers,
+            $httpBody
+        );
+    }
+
+    /**
+     * Create http client option
+     *
+     * @throws \RuntimeException on file opening failure
+     * @return array of http client options
+     */
+    protected function createHttpClientOption()
+    {
+        $options = [];
+        if ($this->config->getDebug()) {
+            $options[RequestOptions::DEBUG] = fopen($this->config->getDebugFile(), 'a');
+            if (!$options[RequestOptions::DEBUG]) {
+                throw new \RuntimeException('Failed to open the debug file: ' . $this->config->getDebugFile());
+            }
+        }
+
+        if ($this->config->getCertFile()) {
+            $options[RequestOptions::CERT] = $this->config->getCertFile();
+        }
+
+        if ($this->config->getKeyFile()) {
+            $options[RequestOptions::SSL_KEY] = $this->config->getKeyFile();
+        }
+
+        return $options;
+    }
+
+    private function handleResponseWithDataType(
+        string $dataType,
+        RequestInterface $request,
+        ResponseInterface $response
+    ): array {
+        if ($dataType === '\SplFileObject') {
+            $content = $response->getBody(); //stream goes to serializer
+        } else {
+            $content = (string) $response->getBody();
+            if ($dataType !== 'string') {
+                try {
+                    $content = json_decode($content, false, 512, JSON_THROW_ON_ERROR);
+                } catch (\JsonException $exception) {
+                    throw new ApiException(
+                        sprintf(
+                            'Error JSON decoding server response (%s)',
+                            $request->getUri()
+                        ),
+                        $response->getStatusCode(),
+                        $response->getHeaders(),
+                        $content
+                    );
+                }
+            }
+        }
+
+        return [
+            ObjectSerializer::deserialize($content, $dataType, []),
+            $response->getStatusCode(),
+            $response->getHeaders()
+        ];
+    }
+
+    private function responseWithinRangeCode(
+        string $rangeCode,
+        int $statusCode
+    ): bool {
+        $left = (int) ($rangeCode[0].'00');
+        $right = (int) ($rangeCode[0].'99');
+
+        return $statusCode >= $left && $statusCode <= $right;
+    }
+}

+ 443 - 0
api-test/client/lib/Model/GetApiV3TipitakaChapterChannelsBookPara200Response.php

@@ -0,0 +1,443 @@
+<?php
+/**
+ * GetApiV3TipitakaChapterChannelsBookPara200Response
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.11.9
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaChapterChannelsBookPara200Response Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaChapterChannelsBookPara200Response implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_chapter_channels_book_para__200_response';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'data' => '\WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInner[]',
+        'meta' => '\WikipaliApi\Model\PaginationMeta'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'data' => null,
+        'meta' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'data' => false,
+        'meta' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'data' => 'data',
+        'meta' => 'meta'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'data' => 'setData',
+        'meta' => 'setMeta'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'data' => 'getData',
+        'meta' => 'getMeta'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('data', $data ?? [], null);
+        $this->setIfExists('meta', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets data
+     *
+     * @return \WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInner[]|null
+     */
+    public function getData()
+    {
+        return $this->container['data'];
+    }
+
+    /**
+     * Sets data
+     *
+     * @param \WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInner[]|null $data data
+     *
+     * @return self
+     */
+    public function setData($data)
+    {
+        if (is_null($data)) {
+            throw new \InvalidArgumentException('non-nullable data cannot be null');
+        }
+        $this->container['data'] = $data;
+
+        return $this;
+    }
+
+    /**
+     * Gets meta
+     *
+     * @return \WikipaliApi\Model\PaginationMeta|null
+     */
+    public function getMeta()
+    {
+        return $this->container['meta'];
+    }
+
+    /**
+     * Sets meta
+     *
+     * @param \WikipaliApi\Model\PaginationMeta|null $meta meta
+     *
+     * @return self
+     */
+    public function setMeta($meta)
+    {
+        if (is_null($meta)) {
+            throw new \InvalidArgumentException('non-nullable meta cannot be null');
+        }
+        $this->container['meta'] = $meta;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 695 - 0
api-test/client/lib/Model/GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInner.php

@@ -0,0 +1,695 @@
+<?php
+/**
+ * GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInner
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.11.9
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInner Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInner implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_chapter_channels_book_para__200_response_data_inner';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'id' => 'string',
+        'book' => 'int',
+        'para' => 'int',
+        'progress' => 'float',
+        'progress_line' => 'int[]',
+        'views' => 'int',
+        'updated_at' => 'string',
+        'channel' => '\WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerChannel',
+        'studio' => '\WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerStudio'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'id' => null,
+        'book' => null,
+        'para' => null,
+        'progress' => null,
+        'progress_line' => null,
+        'views' => null,
+        'updated_at' => null,
+        'channel' => null,
+        'studio' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'id' => false,
+        'book' => false,
+        'para' => false,
+        'progress' => false,
+        'progress_line' => true,
+        'views' => false,
+        'updated_at' => false,
+        'channel' => false,
+        'studio' => true
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'id' => 'id',
+        'book' => 'book',
+        'para' => 'para',
+        'progress' => 'progress',
+        'progress_line' => 'progress_line',
+        'views' => 'views',
+        'updated_at' => 'updated_at',
+        'channel' => 'channel',
+        'studio' => 'studio'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'id' => 'setId',
+        'book' => 'setBook',
+        'para' => 'setPara',
+        'progress' => 'setProgress',
+        'progress_line' => 'setProgressLine',
+        'views' => 'setViews',
+        'updated_at' => 'setUpdatedAt',
+        'channel' => 'setChannel',
+        'studio' => 'setStudio'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'id' => 'getId',
+        'book' => 'getBook',
+        'para' => 'getPara',
+        'progress' => 'getProgress',
+        'progress_line' => 'getProgressLine',
+        'views' => 'getViews',
+        'updated_at' => 'getUpdatedAt',
+        'channel' => 'getChannel',
+        'studio' => 'getStudio'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('id', $data ?? [], null);
+        $this->setIfExists('book', $data ?? [], null);
+        $this->setIfExists('para', $data ?? [], null);
+        $this->setIfExists('progress', $data ?? [], null);
+        $this->setIfExists('progress_line', $data ?? [], null);
+        $this->setIfExists('views', $data ?? [], null);
+        $this->setIfExists('updated_at', $data ?? [], null);
+        $this->setIfExists('channel', $data ?? [], null);
+        $this->setIfExists('studio', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets id
+     *
+     * @return string|null
+     */
+    public function getId()
+    {
+        return $this->container['id'];
+    }
+
+    /**
+     * Sets id
+     *
+     * @param string|null $id id
+     *
+     * @return self
+     */
+    public function setId($id)
+    {
+        if (is_null($id)) {
+            throw new \InvalidArgumentException('non-nullable id cannot be null');
+        }
+        $this->container['id'] = $id;
+
+        return $this;
+    }
+
+    /**
+     * Gets book
+     *
+     * @return int|null
+     */
+    public function getBook()
+    {
+        return $this->container['book'];
+    }
+
+    /**
+     * Sets book
+     *
+     * @param int|null $book book
+     *
+     * @return self
+     */
+    public function setBook($book)
+    {
+        if (is_null($book)) {
+            throw new \InvalidArgumentException('non-nullable book cannot be null');
+        }
+        $this->container['book'] = $book;
+
+        return $this;
+    }
+
+    /**
+     * Gets para
+     *
+     * @return int|null
+     */
+    public function getPara()
+    {
+        return $this->container['para'];
+    }
+
+    /**
+     * Sets para
+     *
+     * @param int|null $para para
+     *
+     * @return self
+     */
+    public function setPara($para)
+    {
+        if (is_null($para)) {
+            throw new \InvalidArgumentException('non-nullable para cannot be null');
+        }
+        $this->container['para'] = $para;
+
+        return $this;
+    }
+
+    /**
+     * Gets progress
+     *
+     * @return float|null
+     */
+    public function getProgress()
+    {
+        return $this->container['progress'];
+    }
+
+    /**
+     * Sets progress
+     *
+     * @param float|null $progress progress
+     *
+     * @return self
+     */
+    public function setProgress($progress)
+    {
+        if (is_null($progress)) {
+            throw new \InvalidArgumentException('non-nullable progress cannot be null');
+        }
+        $this->container['progress'] = $progress;
+
+        return $this;
+    }
+
+    /**
+     * Gets progress_line
+     *
+     * @return int[]|null
+     */
+    public function getProgressLine()
+    {
+        return $this->container['progress_line'];
+    }
+
+    /**
+     * Sets progress_line
+     *
+     * @param int[]|null $progress_line progress_line
+     *
+     * @return self
+     */
+    public function setProgressLine($progress_line)
+    {
+        if (is_null($progress_line)) {
+            array_push($this->openAPINullablesSetToNull, 'progress_line');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('progress_line', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['progress_line'] = $progress_line;
+
+        return $this;
+    }
+
+    /**
+     * Gets views
+     *
+     * @return int|null
+     */
+    public function getViews()
+    {
+        return $this->container['views'];
+    }
+
+    /**
+     * Sets views
+     *
+     * @param int|null $views views
+     *
+     * @return self
+     */
+    public function setViews($views)
+    {
+        if (is_null($views)) {
+            throw new \InvalidArgumentException('non-nullable views cannot be null');
+        }
+        $this->container['views'] = $views;
+
+        return $this;
+    }
+
+    /**
+     * Gets updated_at
+     *
+     * @return string|null
+     */
+    public function getUpdatedAt()
+    {
+        return $this->container['updated_at'];
+    }
+
+    /**
+     * Sets updated_at
+     *
+     * @param string|null $updated_at updated_at
+     *
+     * @return self
+     */
+    public function setUpdatedAt($updated_at)
+    {
+        if (is_null($updated_at)) {
+            throw new \InvalidArgumentException('non-nullable updated_at cannot be null');
+        }
+        $this->container['updated_at'] = $updated_at;
+
+        return $this;
+    }
+
+    /**
+     * Gets channel
+     *
+     * @return \WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerChannel|null
+     */
+    public function getChannel()
+    {
+        return $this->container['channel'];
+    }
+
+    /**
+     * Sets channel
+     *
+     * @param \WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerChannel|null $channel channel
+     *
+     * @return self
+     */
+    public function setChannel($channel)
+    {
+        if (is_null($channel)) {
+            throw new \InvalidArgumentException('non-nullable channel cannot be null');
+        }
+        $this->container['channel'] = $channel;
+
+        return $this;
+    }
+
+    /**
+     * Gets studio
+     *
+     * @return \WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerStudio|null
+     */
+    public function getStudio()
+    {
+        return $this->container['studio'];
+    }
+
+    /**
+     * Sets studio
+     *
+     * @param \WikipaliApi\Model\GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerStudio|null $studio studio
+     *
+     * @return self
+     */
+    public function setStudio($studio)
+    {
+        if (is_null($studio)) {
+            array_push($this->openAPINullablesSetToNull, 'studio');
+        } else {
+            $nullablesSetToNull = $this->getOpenAPINullablesSetToNull();
+            $index = array_search('studio', $nullablesSetToNull);
+            if ($index !== FALSE) {
+                unset($nullablesSetToNull[$index]);
+                $this->setOpenAPINullablesSetToNull($nullablesSetToNull);
+            }
+        }
+        $this->container['studio'] = $studio;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 511 - 0
api-test/client/lib/Model/GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerChannel.php

@@ -0,0 +1,511 @@
+<?php
+/**
+ * GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerChannel
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.11.9
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerChannel Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerChannel implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_chapter_channels_book_para__200_response_data_inner_channel';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'id' => 'string',
+        'name' => 'string',
+        'type' => 'string',
+        'lang' => 'string'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'id' => null,
+        'name' => null,
+        'type' => null,
+        'lang' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'id' => false,
+        'name' => false,
+        'type' => false,
+        'lang' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'id' => 'id',
+        'name' => 'name',
+        'type' => 'type',
+        'lang' => 'lang'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'id' => 'setId',
+        'name' => 'setName',
+        'type' => 'setType',
+        'lang' => 'setLang'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'id' => 'getId',
+        'name' => 'getName',
+        'type' => 'getType',
+        'lang' => 'getLang'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('id', $data ?? [], null);
+        $this->setIfExists('name', $data ?? [], null);
+        $this->setIfExists('type', $data ?? [], null);
+        $this->setIfExists('lang', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets id
+     *
+     * @return string|null
+     */
+    public function getId()
+    {
+        return $this->container['id'];
+    }
+
+    /**
+     * Sets id
+     *
+     * @param string|null $id id
+     *
+     * @return self
+     */
+    public function setId($id)
+    {
+        if (is_null($id)) {
+            throw new \InvalidArgumentException('non-nullable id cannot be null');
+        }
+        $this->container['id'] = $id;
+
+        return $this;
+    }
+
+    /**
+     * Gets name
+     *
+     * @return string|null
+     */
+    public function getName()
+    {
+        return $this->container['name'];
+    }
+
+    /**
+     * Sets name
+     *
+     * @param string|null $name name
+     *
+     * @return self
+     */
+    public function setName($name)
+    {
+        if (is_null($name)) {
+            throw new \InvalidArgumentException('non-nullable name cannot be null');
+        }
+        $this->container['name'] = $name;
+
+        return $this;
+    }
+
+    /**
+     * Gets type
+     *
+     * @return string|null
+     */
+    public function getType()
+    {
+        return $this->container['type'];
+    }
+
+    /**
+     * Sets type
+     *
+     * @param string|null $type type
+     *
+     * @return self
+     */
+    public function setType($type)
+    {
+        if (is_null($type)) {
+            throw new \InvalidArgumentException('non-nullable type cannot be null');
+        }
+        $this->container['type'] = $type;
+
+        return $this;
+    }
+
+    /**
+     * Gets lang
+     *
+     * @return string|null
+     */
+    public function getLang()
+    {
+        return $this->container['lang'];
+    }
+
+    /**
+     * Sets lang
+     *
+     * @param string|null $lang lang
+     *
+     * @return self
+     */
+    public function setLang($lang)
+    {
+        if (is_null($lang)) {
+            throw new \InvalidArgumentException('non-nullable lang cannot be null');
+        }
+        $this->container['lang'] = $lang;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 511 - 0
api-test/client/lib/Model/GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerStudio.php

@@ -0,0 +1,511 @@
+<?php
+/**
+ * GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerStudio
+ *
+ * PHP version 8.1
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ */
+
+/**
+ * MINT API
+ *
+ * International Academy Of Pali Tipitaka(国际巴利三藏学院)  由 `php openapi/scripts/generate.php` 依据 api-v13 的路由与控制器源码生成。 响应统一封装为 `{ok, message, data}`;`ok=false` 时 `message` 为错误信息。
+ *
+ * The version of the OpenAPI document: 2026.11.9
+ * Generated by: https://openapi-generator.tech
+ * Generator version: 7.25.0
+ */
+
+/**
+ * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
+ * https://openapi-generator.tech
+ * Do not edit the class manually.
+ */
+
+namespace WikipaliApi\Model;
+
+use \ArrayAccess;
+use \WikipaliApi\ObjectSerializer;
+
+/**
+ * GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerStudio Class Doc Comment
+ *
+ * @category Class
+ * @package  WikipaliApi
+ * @author   OpenAPI Generator team
+ * @link     https://openapi-generator.tech
+ * @implements \ArrayAccess<string, mixed>
+ */
+class GetApiV3TipitakaChapterChannelsBookPara200ResponseDataInnerStudio implements ModelInterface, ArrayAccess, \JsonSerializable
+{
+    public const DISCRIMINATOR = null;
+
+    /**
+     * The original name of the model.
+     *
+     * @var string
+     */
+    protected static $openAPIModelName = 'get_api_v3_tipitaka_chapter_channels_book_para__200_response_data_inner_studio';
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @var string[]
+     */
+    protected static $openAPITypes = [
+        'id' => 'string',
+        'nick_name' => 'string',
+        'real_name' => 'string',
+        'avatar' => 'string'
+    ];
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @var string[]
+     * @phpstan-var array<string, string|null>
+     * @psalm-var array<string, string|null>
+     */
+    protected static $openAPIFormats = [
+        'id' => null,
+        'nick_name' => null,
+        'real_name' => null,
+        'avatar' => null
+    ];
+
+    /**
+     * Array of nullable properties. Used for (de)serialization
+     *
+     * @var boolean[]
+     */
+    protected static array $openAPINullables = [
+        'id' => false,
+        'nick_name' => false,
+        'real_name' => false,
+        'avatar' => false
+    ];
+
+    /**
+     * If a nullable field gets set to null, insert it here
+     *
+     * @var boolean[]
+     */
+    protected array $openAPINullablesSetToNull = [];
+
+    /**
+     * Array of property to type mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPITypes()
+    {
+        return self::$openAPITypes;
+    }
+
+    /**
+     * Array of property to format mappings. Used for (de)serialization
+     *
+     * @return array
+     */
+    public static function openAPIFormats()
+    {
+        return self::$openAPIFormats;
+    }
+
+    /**
+     * Array of nullable properties
+     *
+     * @return array
+     */
+    protected static function openAPINullables(): array
+    {
+        return self::$openAPINullables;
+    }
+
+    /**
+     * Array of nullable field names deliberately set to null
+     *
+     * @return boolean[]
+     */
+    private function getOpenAPINullablesSetToNull(): array
+    {
+        return $this->openAPINullablesSetToNull;
+    }
+
+    /**
+     * Setter - Array of nullable field names deliberately set to null
+     *
+     * @param boolean[] $openAPINullablesSetToNull
+     */
+    private function setOpenAPINullablesSetToNull(array $openAPINullablesSetToNull): void
+    {
+        $this->openAPINullablesSetToNull = $openAPINullablesSetToNull;
+    }
+
+    /**
+     * Checks if a property is nullable
+     *
+     * @param string $property
+     * @return bool
+     */
+    public static function isNullable(string $property): bool
+    {
+        return self::openAPINullables()[$property] ?? false;
+    }
+
+    /**
+     * Checks if a nullable property is set to null.
+     *
+     * @param string $property
+     * @return bool
+     */
+    public function isNullableSetToNull(string $property): bool
+    {
+        return in_array($property, $this->getOpenAPINullablesSetToNull(), true);
+    }
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @var string[]
+     */
+    protected static $attributeMap = [
+        'id' => 'id',
+        'nick_name' => 'nickName',
+        'real_name' => 'realName',
+        'avatar' => 'avatar'
+    ];
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @var string[]
+     */
+    protected static $setters = [
+        'id' => 'setId',
+        'nick_name' => 'setNickName',
+        'real_name' => 'setRealName',
+        'avatar' => 'setAvatar'
+    ];
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @var string[]
+     */
+    protected static $getters = [
+        'id' => 'getId',
+        'nick_name' => 'getNickName',
+        'real_name' => 'getRealName',
+        'avatar' => 'getAvatar'
+    ];
+
+    /**
+     * Array of attributes where the key is the local name,
+     * and the value is the original name
+     *
+     * @return array
+     */
+    public static function attributeMap()
+    {
+        return self::$attributeMap;
+    }
+
+    /**
+     * Array of attributes to setter functions (for deserialization of responses)
+     *
+     * @return array
+     */
+    public static function setters()
+    {
+        return self::$setters;
+    }
+
+    /**
+     * Array of attributes to getter functions (for serialization of requests)
+     *
+     * @return array
+     */
+    public static function getters()
+    {
+        return self::$getters;
+    }
+
+    /**
+     * The original name of the model.
+     *
+     * @return string
+     */
+    public function getModelName()
+    {
+        return self::$openAPIModelName;
+    }
+
+
+    /**
+     * Associative array for storing property values
+     *
+     * @var mixed[]
+     */
+    protected $container = [];
+
+    /**
+     * Constructor
+     *
+     * @param mixed[]|null $data Associated array of property values
+     *                      initializing the model
+     */
+    public function __construct(?array $data = null)
+    {
+        $this->setIfExists('id', $data ?? [], null);
+        $this->setIfExists('nick_name', $data ?? [], null);
+        $this->setIfExists('real_name', $data ?? [], null);
+        $this->setIfExists('avatar', $data ?? [], null);
+    }
+
+    /**
+     * Sets $this->container[$variableName] to the given data or to the given default Value; if $variableName
+     * is nullable and its value is set to null in the $fields array, then mark it as "set to null" in the
+     * $this->openAPINullablesSetToNull array
+     *
+     * @param string $variableName
+     * @param array  $fields
+     * @param mixed  $defaultValue
+     */
+    private function setIfExists(string $variableName, array $fields, $defaultValue): void
+    {
+        if (self::isNullable($variableName) && array_key_exists($variableName, $fields) && is_null($fields[$variableName])) {
+            $this->openAPINullablesSetToNull[] = $variableName;
+        }
+
+        $this->container[$variableName] = $fields[$variableName] ?? $defaultValue;
+    }
+
+    /**
+     * Show all the invalid properties with reasons.
+     *
+     * @return array invalid properties with reasons
+     */
+    public function listInvalidProperties()
+    {
+        $invalidProperties = [];
+
+        return $invalidProperties;
+    }
+
+    /**
+     * Validate all the properties in the model
+     * return true if all passed
+     *
+     * @return bool True if all properties are valid
+     */
+    public function valid()
+    {
+        return count($this->listInvalidProperties()) === 0;
+    }
+
+
+    /**
+     * Gets id
+     *
+     * @return string|null
+     */
+    public function getId()
+    {
+        return $this->container['id'];
+    }
+
+    /**
+     * Sets id
+     *
+     * @param string|null $id id
+     *
+     * @return self
+     */
+    public function setId($id)
+    {
+        if (is_null($id)) {
+            throw new \InvalidArgumentException('non-nullable id cannot be null');
+        }
+        $this->container['id'] = $id;
+
+        return $this;
+    }
+
+    /**
+     * Gets nick_name
+     *
+     * @return string|null
+     */
+    public function getNickName()
+    {
+        return $this->container['nick_name'];
+    }
+
+    /**
+     * Sets nick_name
+     *
+     * @param string|null $nick_name nick_name
+     *
+     * @return self
+     */
+    public function setNickName($nick_name)
+    {
+        if (is_null($nick_name)) {
+            throw new \InvalidArgumentException('non-nullable nick_name cannot be null');
+        }
+        $this->container['nick_name'] = $nick_name;
+
+        return $this;
+    }
+
+    /**
+     * Gets real_name
+     *
+     * @return string|null
+     */
+    public function getRealName()
+    {
+        return $this->container['real_name'];
+    }
+
+    /**
+     * Sets real_name
+     *
+     * @param string|null $real_name real_name
+     *
+     * @return self
+     */
+    public function setRealName($real_name)
+    {
+        if (is_null($real_name)) {
+            throw new \InvalidArgumentException('non-nullable real_name cannot be null');
+        }
+        $this->container['real_name'] = $real_name;
+
+        return $this;
+    }
+
+    /**
+     * Gets avatar
+     *
+     * @return string|null
+     */
+    public function getAvatar()
+    {
+        return $this->container['avatar'];
+    }
+
+    /**
+     * Sets avatar
+     *
+     * @param string|null $avatar 仅在部分查询口径下返回。
+     *
+     * @return self
+     */
+    public function setAvatar($avatar)
+    {
+        if (is_null($avatar)) {
+            throw new \InvalidArgumentException('non-nullable avatar cannot be null');
+        }
+        $this->container['avatar'] = $avatar;
+
+        return $this;
+    }
+    /**
+     * Returns true if offset exists. False otherwise.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return boolean
+     */
+    public function offsetExists(mixed $offset): bool
+    {
+        return isset($this->container[$offset]);
+    }
+
+    /**
+     * Gets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return mixed|null
+     */
+    #[\ReturnTypeWillChange]
+    public function offsetGet(mixed $offset)
+    {
+        return $this->container[$offset] ?? null;
+    }
+
+    /**
+     * Sets value based on offset.
+     *
+     * @param int|null $offset Offset
+     * @param mixed    $value  Value to be set
+     *
+     * @return void
+     */
+    public function offsetSet($offset, $value): void
+    {
+        if (is_null($offset)) {
+            $this->container[] = $value;
+        } else {
+            $this->container[$offset] = $value;
+        }
+    }
+
+    /**
+     * Unsets offset.
+     *
+     * @param integer|string $offset Offset
+     *
+     * @return void
+     */
+    public function offsetUnset(mixed $offset): void
+    {
+        unset($this->container[$offset]);
+    }
+
+    /**
+     * Serializes the object to a value that can be serialized natively by json_encode().
+     * @link https://www.php.net/manual/en/jsonserializable.jsonserialize.php
+     *
+     * @return mixed Returns data which can be serialized by json_encode(), which is a value
+     * of any type other than a resource.
+     */
+    #[\ReturnTypeWillChange]
+    public function jsonSerialize()
+    {
+       return ObjectSerializer::sanitizeForSerialization($this);
+    }
+
+    /**
+     * Gets the string presentation of the object
+     *
+     * @return string
+     */
+    public function __toString()
+    {
+        return json_encode(
+            ObjectSerializer::sanitizeForSerialization($this),
+            JSON_PRETTY_PRINT
+        );
+    }
+
+    /**
+     * Gets a header-safe presentation of the object
+     *
+     * @return string
+     */
+    public function toHeaderValue()
+    {
+        return json_encode(ObjectSerializer::sanitizeForSerialization($this));
+    }
+}
+
+

+ 50 - 0
api-v13/app/Http/Controllers/V3/TipitakaChapterChannelController.php

@@ -0,0 +1,50 @@
+<?php
+
+namespace App\Http\Controllers\V3;
+
+use App\Http\Controllers\Concerns\ResolvesCurrentUser;
+use App\Http\Controllers\Controller;
+use App\Http\Requests\V3\IndexTipitakaChapterChannelRequest;
+use App\Http\Resources\V3\TipitakaChapterChannelResource;
+use App\Services\V3\TipitakaChapterChannelService;
+
+/**
+ * 某一章有哪些 channel 及各自的完成度——阅读页的「版本切换」用。
+ *
+ * 取代 `GET /v2/progress?view=chapter_channels&book=&par=`(`ProgressChapterController@index`
+ * 的一个 view 分支)。v2 那份在 dashboard-v4 下线前原样保留,不要改动。
+ */
+class TipitakaChapterChannelController extends Controller
+{
+    use ResolvesCurrentUser;
+
+    public function __construct(private readonly TipitakaChapterChannelService $chapterChannels) {}
+
+    /**
+     * 列出一章的 channel
+     *
+     * 按完成度从高到低。只列看得见的 channel:公开的,加上登录用户自己的、共享给他(或他所在群组)的;
+     * 未登录只看得到公开的。章节不存在或没有任何 channel 时返回空列表。
+     *
+     * `progress` 是 0–1 的完成度;`progress_line` 是近几周每周的修改次数(从早到晚,由 `upgrade:chapter.dynamic.weekly` 每周算一次,
+     * 没算过就是 null),画活跃度小图用;
+     * `views` 是这一章在该 channel 下被浏览的次数。
+     *
+     * @unauthenticated
+     *
+     * @urlParam book integer required 书号。Example: 94
+     * @urlParam para integer required 章节起始段落号。Example: 385
+     *
+     * @queryParam page integer 页码。Default: 1
+     * @queryParam per_page integer 每页数量,最大 200。Default: 100
+     */
+    public function __invoke(IndexTipitakaChapterChannelRequest $request, int $book, int $para)
+    {
+        return TipitakaChapterChannelResource::collection($this->chapterChannels->list(
+            $book,
+            $para,
+            $this->currentUserUid($request),
+            $request->integer('per_page', 100),
+        ));
+    }
+}

+ 32 - 0
api-v13/app/Http/Requests/V3/IndexTipitakaChapterChannelRequest.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Http\Requests\V3;
+
+use Illuminate\Foundation\Http\FormRequest;
+
+/**
+ * `GET /v3/tipitaka-chapter-channels/{book}/{para}` 的入参。
+ *
+ * book / para 在路径上(路由约束保证是数字),这里只有分页。
+ */
+class IndexTipitakaChapterChannelRequest extends FormRequest
+{
+    /**
+     * 公开的只读接口;可见性在 Service 里按登录用户判。
+     */
+    public function authorize(): bool
+    {
+        return true;
+    }
+
+    /**
+     * @return array<string, array<int, mixed>>
+     */
+    public function rules(): array
+    {
+        return [
+            'page' => ['sometimes', 'integer', 'min:1'],
+            'per_page' => ['sometimes', 'integer', 'min:1', 'max:200'],
+        ];
+    }
+}

+ 52 - 0
api-v13/app/Http/Resources/V3/TipitakaChapterChannelResource.php

@@ -0,0 +1,52 @@
+<?php
+
+namespace App\Http\Resources\V3;
+
+use App\Services\V3\TipitakaChapterChannelService;
+use Illuminate\Http\Request;
+
+/**
+ * 某一章里的一个 channel 及其完成度。
+ *
+ * 底层是 progress_chapters 一行(join channels)。`views` / `studio` / `progress_line`
+ * 由 {@see TipitakaChapterChannelService} 整页批量取好挂在模型上,这里不查库。
+ *
+ * 类型核对自 information_schema:progress_chapters.uid / channel_id 是 uuid,
+ * progress 是 double,book / para 是 integer;channels.type / lang 是 varchar。
+ */
+class TipitakaChapterChannelResource extends BaseResource
+{
+    /**
+     * @param  Request  $request
+     * @return array{
+     *     id: string,
+     *     book: int,
+     *     para: int,
+     *     progress: float,
+     *     progress_line: array<int, int>|null,
+     *     views: int,
+     *     updated_at: string,
+     *     channel: array{id: string, name: string, type: string, lang: string},
+     *     studio: array{id: string, nickName: string, realName: string, avatar?: string}|null
+     * }
+     */
+    public function toArray($request): array
+    {
+        return [
+            'id' => (string) $this->uid,
+            'book' => (int) $this->book,
+            'para' => (int) $this->para,
+            'progress' => (float) $this->progress,
+            'progress_line' => is_array($this->progress_line) ? array_values($this->progress_line) : null,
+            'views' => (int) $this->views,
+            'updated_at' => $this->updated_at?->toJSON(),
+            'channel' => [
+                'id' => (string) $this->channel_id,
+                'name' => (string) $this->channel_name,
+                'type' => (string) $this->channel_type,
+                'lang' => (string) $this->channel_lang,
+            ],
+            'studio' => $this->studio,
+        ];
+    }
+}

+ 18 - 15
api-v13/app/Services/TipitakaCatalogService.php

@@ -8,6 +8,7 @@ use App\Models\Channel;
 use App\Models\PaliText;
 use App\Models\ProgressChapter;
 use App\Models\TagMap;
+use App\Services\V3\TipitakaChapterChannelService;
 use Illuminate\Contracts\Pagination\LengthAwarePaginator;
 use Illuminate\Database\Query\Builder;
 use Illuminate\Support\Facades\DB;
@@ -31,7 +32,8 @@ use Illuminate\Support\Facades\DB;
  * TODO: 给 progress_chapters 加 (book, para) 索引(只加索引,不改列;migration 由用户决定,2026-11-04 定为以后做)。
  *       现在按作品根节点查版本只能全表扫描(本地 12.6 万行)。2026-11-04 在事务里临时建索引实测(测完回滚),
  *       各页 SQL 合计:三藏首页 132→14ms、分类「经藏」292→19ms、分类末级 76→9ms、译本列表 67→13ms、
- *       作品详情 17→5ms。受影响的查询:本类的 publicChannelsQuery()、workChannels(),以及
+ *       作品详情 17→5ms。受影响的查询:本类的 publicChannelsQuery()、workChannels()(实际在
+ *       V3\TipitakaChapterChannelService,v3 tipitaka-chapter-channels 端点也用它),以及
  *       TipitakaController::versionsQuery()。
  *       顺带可考虑 pali_texts (level) 索引:buildWorks() 冷启动里 `where level = 1` 占 ~106ms(一天一次,不急)。
  */
@@ -79,7 +81,10 @@ class TipitakaCatalogService
      */
     private const AVATAR_COLORS = ['#206bc4', '#7a3fc4', '#2fb344', '#c77700', '#d63939', '#0ca678', '#4263eb', '#ae3ec9'];
 
-    public function __construct(protected TermService $termService) {}
+    public function __construct(
+        protected TermService $termService,
+        protected TipitakaChapterChannelService $chapterChannels,
+    ) {}
 
     public static function channelColor(string $uid): string
     {
@@ -401,28 +406,26 @@ class TipitakaCatalogService
     }
 
     /**
-     * 收录了该作品的其他公开 channel。
+     * 收录了该作品的公开 channel,按完成度从高到低。
      *
-     * @return list<array{uid: string, name: string, lang: string, type: string, owner: mixed, progress: int, updated_at: string|null}>
+     * 与 v3 `GET /v3/tipitaka-chapter-channels/{book}/{para}` 同一份查询与可见性
+     * ({@see TipitakaChapterChannelService::all()});library 不登录,所以只有公开的。
+     *
+     * @return list<array{uid: string, name: string, lang: string, type: string, owner_name: string|null, progress: int, updated_at: string|null}>
      */
     public function workChannels(array $work, ?string $exceptChannel = null): array
     {
-        return ProgressChapter::with('channel.owner')
-            ->where('book', $work['book'])
-            ->where('para', $work['para'])
-            ->when($exceptChannel, fn ($q) => $q->where('channel_id', '<>', $exceptChannel))
-            ->whereHas('channel', fn ($q) => $q->where('status', 30))
-            ->orderByDesc('progress')
-            ->get()
+        return $this->chapterChannels->all($work['book'], $work['para'], null, $exceptChannel)
             ->map(fn ($row) => [
                 'uid' => (string) $row->channel_id,
-                'name' => $row->channel->name,
-                'lang' => $row->channel->lang,
-                'type' => $row->channel->type,
-                'owner' => $row->channel->owner,
+                'name' => $row->channel_name,
+                'lang' => $row->channel_lang,
+                'type' => $row->channel_type,
+                'owner_name' => $row->studio['nickName'] ?? null,
                 'progress' => (int) round($row->progress * 100),
                 'updated_at' => $row->updated_at?->toDateString(),
             ])
+            ->values()
             ->all();
     }
 

+ 196 - 0
api-v13/app/Services/V3/TipitakaChapterChannelService.php

@@ -0,0 +1,196 @@
+<?php
+
+namespace App\Services\V3;
+
+use App\Facades\RedisCache;
+use App\Models\GroupInfo;
+use App\Models\GroupMember;
+use App\Models\ProgressChapter;
+use App\Models\Share;
+use App\Models\View;
+use App\Services\UserService;
+use Illuminate\Contracts\Pagination\LengthAwarePaginator;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Collection;
+use Illuminate\Support\Str;
+
+/**
+ * 某一章有哪些 channel(译本 / 逐词 / 原文……)及各自的完成度。
+ *
+ * 取代 v2 `ProgressChapterController@index` 的 `view=chapter_channels` 分支。v2 那份
+ * 原样保留、不共用(CLAUDE.md 铁律 2)。v3 端点用 {@see self::list()},Laravel library 的
+ * 作品版本页用 {@see self::all()}(经 `TipitakaCatalogService::workChannels()`)。与 v2 的差别:
+ *
+ * - **只列看得见的 channel**:公开的(status=30),加上登录用户自己的、共享给他(或他所在
+ *   群组)的。v2 不判断可见性,私有 channel 的名字与进度对所有人可见。
+ * - 不再返回 likes(前端没用);views、studio 改成整页批量取,查询数与行数无关。
+ */
+class TipitakaChapterChannelService
+{
+    /** channels.status:全网公开 */
+    private const STATUS_PUBLIC = 30;
+
+    /** shares.res_type:channel */
+    private const SHARE_CHANNEL = 2;
+
+    public function __construct(private readonly UserService $users) {}
+
+    /**
+     * 列出一章的 channel,按完成度从高到低(分页,给 v3 端点)。
+     *
+     * 返回的模型上已挂好 `views`、`studio`、`progress_line` 三个属性,Resource 直接读,不再查库。
+     *
+     * @param  string|null  $viewerUid  当前登录用户;未登录只看得到公开 channel
+     * @return LengthAwarePaginator<int, ProgressChapter>
+     */
+    public function list(int $book, int $para, ?string $viewerUid, int $perPage): LengthAwarePaginator
+    {
+        $page = $this->query($book, $para, $viewerUid)->paginate($perPage);
+        $this->enrich($page->getCollection());
+
+        return $page;
+    }
+
+    /**
+     * 同 {@see self::list()},但不分页、可排除一个 channel——给 Laravel library 的作品页用
+     * (一章最多几十个 channel,整页渲染)。
+     *
+     * @param  string|null  $exceptChannel  不列的 channel(译本详情页的「其他版本」要排除当前译本)
+     * @return Collection<int, ProgressChapter>
+     */
+    public function all(int $book, int $para, ?string $viewerUid, ?string $exceptChannel = null): Collection
+    {
+        $rows = $this->query($book, $para, $viewerUid)
+            ->when($exceptChannel, fn (Builder $q) => $q->where('progress_chapters.channel_id', '<>', $exceptChannel))
+            ->get();
+        $this->enrich($rows);
+
+        return $rows;
+    }
+
+    /**
+     * 一章的进度行 join channel,已套可见性,按完成度降序。
+     *
+     * @return Builder<ProgressChapter>
+     */
+    private function query(int $book, int $para, ?string $viewerUid): Builder
+    {
+        return ProgressChapter::query()
+            ->select([
+                'progress_chapters.uid',
+                'progress_chapters.book',
+                'progress_chapters.para',
+                'progress_chapters.channel_id',
+                'progress_chapters.progress',
+                'progress_chapters.updated_at',
+                'channels.name as channel_name',
+                'channels.type as channel_type',
+                'channels.lang as channel_lang',
+                'channels.owner_uid as channel_owner_uid',
+            ])
+            // inner join:channel 已删除的进度行没有意义,不列
+            ->join('channels', 'progress_chapters.channel_id', '=', 'channels.uid')
+            ->where('progress_chapters.book', $book)
+            ->where('progress_chapters.para', $para)
+            ->where(fn (Builder $q) => $this->readableBy($q, $viewerUid))
+            ->orderByDesc('progress_chapters.progress')
+            // 完成度相同时要稳定的 tiebreaker,否则跨页会漂移
+            ->orderBy('progress_chapters.channel_id');
+    }
+
+    /**
+     * 整批补上 views / studio / progress_line,查询数与行数无关。
+     *
+     * @param  Collection<int, ProgressChapter>  $rows
+     */
+    private function enrich(Collection $rows): void
+    {
+        $views = $this->viewCounts($rows->pluck('uid')->all());
+        $studios = $this->studios($rows->pluck('channel_owner_uid')->all());
+
+        foreach ($rows as $row) {
+            $row->setAttribute('views', $views[$row->uid] ?? 0);
+            $row->setAttribute('studio', $studios[$row->channel_owner_uid] ?? null);
+            $row->setAttribute('progress_line', RedisCache::get(
+                "/chapter_dynamic/{$row->book}/{$row->para}/ch_{$row->channel_id}"
+            ));
+        }
+    }
+
+    /**
+     * 可见性:公开的,或(登录时)自己的、共享给自己或自己所在群组的。
+     */
+    private function readableBy(Builder $query, ?string $viewerUid): void
+    {
+        $query->where('channels.status', self::STATUS_PUBLIC);
+        if ($viewerUid === null) {
+            return;
+        }
+
+        $query->orWhere('channels.owner_uid', $viewerUid);
+
+        $cooperators = GroupMember::where('user_id', $viewerUid)->pluck('group_id')->push($viewerUid);
+        $shared = Share::whereIn('cooperator_id', $cooperators)
+            ->where('res_type', self::SHARE_CHANNEL)
+            ->pluck('res_id')
+            // shares.res_id 是 varchar,channels.uid 是 uuid;非法值进了 where 会让 pgsql 报错
+            ->filter(fn ($id) => Str::isUuid($id))
+            ->unique()
+            ->values();
+        if ($shared->isNotEmpty()) {
+            $query->orWhereIn('channels.uid', $shared);
+        }
+    }
+
+    /**
+     * 每个章节进度行被浏览的次数(views 表一行一次)。
+     *
+     * @param  list<string>  $uids  progress_chapters.uid
+     * @return array<string, int>
+     */
+    private function viewCounts(array $uids): array
+    {
+        if (! $uids) {
+            return [];
+        }
+
+        return View::whereIn('target_id', $uids)
+            ->groupBy('target_id')
+            ->selectRaw('target_id, count(*) as n')
+            ->pluck('n', 'target_id')
+            ->map(fn ($n) => (int) $n)
+            ->all();
+    }
+
+    /**
+     * channel 所有者的摘要。所有者可能是用户,也可能是群组(v2 `StudioApi::getById` 的口径)。
+     *
+     * @param  list<string|null>  $ownerUids
+     * @return array<string, array{id: string, nickName: string, realName: string, avatar?: string}>
+     */
+    private function studios(array $ownerUids): array
+    {
+        $output = [];
+        foreach ($this->users->byUuids($ownerUids) as $uid => $profile) {
+            $output[$uid] = array_filter([
+                'id' => $uid,
+                'nickName' => (string) ($profile['nickName'] ?? ''),
+                'realName' => (string) ($profile['realName'] ?? ''),
+                'avatar' => $profile['avatar'] ?? null,
+            ], fn ($v) => $v !== null);
+        }
+
+        $groups = array_values(array_unique(array_diff(array_filter($ownerUids), array_keys($output))));
+        if ($groups) {
+            foreach (GroupInfo::whereIn('uid', $groups)->get(['uid', 'name']) as $group) {
+                $output[$group->uid] = [
+                    'id' => $group->uid,
+                    'nickName' => (string) $group->name,
+                    'realName' => (string) $group->uid,
+                ];
+            }
+        }
+
+        return $output;
+    }
+}

+ 3 - 2
api-v13/resources/views/library/tipitaka/channels/_row.blade.php

@@ -1,4 +1,5 @@
-{{-- channel 列表的一行。变量:$channel{uid, name, type, lang, owner?, updated_at?, url, works? | progress?}、$showType(可选,显示类型标签)
+{{-- channel 列表的一行。变量:$channel{uid, name, type, lang, owner?|owner_name?, updated_at?, url, works? | progress?}、$showType(可选,显示类型标签)
+     owner 是 UserInfo 模型;作品版本页的行来自 TipitakaChapterChannelService,只有 owner_name(所有者可能是群组)。
      有 progress(作品版本页)时右侧显示进度条,否则显示收录作品数。 --}}
 <a href="{{ $channel['url'] }}" class="tipitaka-channel-row">
     <x-ui.author-avatar
@@ -12,7 +13,7 @@
         </div>
         <div class="tipitaka-channel-row__meta">
             {{ __('language.'.$channel['lang']) }}
-            @if(!empty($channel['owner'])) · {{ $channel['owner']->nickname }} @endif
+            @if(!empty($channel['owner_name'])) · {{ $channel['owner_name'] }} @elseif(!empty($channel['owner'])) · {{ $channel['owner']->nickname }} @endif
             @if(!empty($channel['updated_at'])) · {{ __('library.updated') }} {{ $channel['updated_at'] }} @endif
         </div>
     </div>

+ 5 - 0
api-v13/routes/v3/tipitaka.php

@@ -18,6 +18,7 @@
 |
 */
 
+use App\Http\Controllers\V3\TipitakaChapterChannelController;
 use App\Http\Controllers\V3\TipitakaDiscussionController;
 use App\Http\Controllers\V3\TipitakaReadingController;
 use App\Http\Controllers\V3\TipitakaRelatedParagraphAggregateController;
@@ -34,6 +35,10 @@ Route::get('tipitaka-reading/{channel}', TipitakaReadingController::class)
 Route::get('tipitaka-discussions/{channel}', TipitakaDiscussionController::class)
     ->whereUuid('channel');
 
+// 一章有哪些 channel 及其完成度(原 v2 progress?view=chapter_channels)。book、para 都必填,进路径。
+Route::get('tipitaka-chapter-channels/{book}/{para}', TipitakaChapterChannelController::class)
+    ->whereNumber(['book', 'para']);
+
 // 段落关联关系:查询(原 v2 related-paragraph)+ 聚合(book_name 列表 / 某 book_name 的 cs_para)。
 Route::get('tipitaka-related-paragraphs/aggregate', [TipitakaRelatedParagraphAggregateController::class, 'index'])
     ->name('tipitaka-related-paragraphs.aggregate');

+ 22 - 0
api-v13/tests/Feature/LibraryTipitakaChannelTest.php

@@ -262,6 +262,28 @@ it('lists every public version of a work grouped by type', function () {
     $this->get('/library/tipitaka/work/424242')->assertNotFound();
 });
 
+it('lists the versions of a work through the chapter channel service', function () {
+    ['channel' => $channel] = libTipitakaFixture();
+    $secret = makeChannel(makeStudio('lib-secret-owner'), 'secret channel');
+    Channel::where('uid', $secret)->update(['status' => 10]);
+    libProgress($secret, 9100, 3, 0.9);
+
+    // 与 v3 tipitaka-chapter-channels 同一份查询:私有的不列,所有者名字照常显示
+    $this->get('/library/tipitaka/work/9100')
+        ->assertOk()
+        ->assertSee('lib channel')
+        ->assertSee('lib-owner')
+        ->assertDontSee('secret channel');
+
+    // 译本详情页的「其他版本」排除当前译本
+    $other = makeChannel(makeStudio('lib-other'), 'other channel');
+    libProgress($other, 9100, 3, 0.1);
+    $this->get("/library/tipitaka/channels/{$channel}/work/9100")
+        ->assertOk()
+        ->assertSee('other channel')
+        ->assertDontSee('secret channel');
+});
+
 it('redirects the legacy channel query to the channel pages', function () {
     ['channel' => $channel] = libTipitakaFixture();
 

+ 155 - 0
api-v13/tests/Feature/TipitakaChapterChannelV3Test.php

@@ -0,0 +1,155 @@
+<?php
+
+use App\Facades\RedisCache;
+use App\Models\Channel;
+use App\Models\GroupMember;
+use App\Models\ProgressChapter;
+use App\Models\Share;
+use App\Models\View;
+use Illuminate\Foundation\Testing\RefreshDatabase;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Str;
+
+uses(RefreshDatabase::class);
+
+/**
+ * GET /v3/tipitaka-chapter-channels/{book}/{para} —— 一章有哪些 channel 及完成度。
+ *
+ * 这里断言的是形状而非契约(spectator 未安装,见 v3-resource skill 第 3 步)。
+ */
+function makeChapterChannelRow(string $channelUid, float $progress, int $book = 9101, int $para = 5): ProgressChapter
+{
+    $row = new ProgressChapter;
+    $row->forceFill([
+        'id' => random_int(1, PHP_INT_MAX),
+        'uid' => (string) Str::uuid(),
+        'book' => $book,
+        'para' => $para,
+        'lang' => 'zh-Hans',
+        'channel_id' => $channelUid,
+        'progress' => $progress,
+        'all_trans' => 0,
+        'public' => 0,
+    ])->save();
+
+    return $row;
+}
+
+function makePrivateChannel(string $ownerUid, string $name): string
+{
+    $uid = makeChannel($ownerUid, $name);
+    Channel::where('uid', $uid)->update(['status' => 10]);
+
+    return $uid;
+}
+
+function shareChannelTo(string $channelUid, string $cooperatorUid): void
+{
+    (new Share)->forceFill([
+        'id' => random_int(1, PHP_INT_MAX),
+        'res_id' => $channelUid,
+        'res_type' => 2,
+        'cooperator_id' => $cooperatorUid,
+        'cooperator_type' => 0,
+        'power' => 10,
+        'create_time' => time() * 1000,
+        'modify_time' => time() * 1000,
+    ])->save();
+}
+
+it('lists channels of a chapter ordered by progress', function () {
+    $owner = makeStudio('cc-owner');
+    $low = makeChannel($owner, 'low');
+    $high = makeChannel($owner, 'high');
+    makeChapterChannelRow($low, 0.2);
+    $top = makeChapterChannelRow($high, 0.9);
+    makeChapterChannelRow($high, 0.5, 9101, 6);   // 别的章节
+
+    View::forceCreate(['id' => (string) Str::uuid(), 'target_id' => $top->uid, 'target_type' => 'chapter', 'user_ip' => '127.0.0.1']);
+    View::forceCreate(['id' => (string) Str::uuid(), 'target_id' => $top->uid, 'target_type' => 'chapter', 'user_ip' => '127.0.0.1']);
+    RedisCache::put("/chapter_dynamic/9101/5/ch_{$high}", [0, 3, 1]);
+
+    $json = $this->getJson('/api/v3/tipitaka-chapter-channels/9101/5')->assertOk()->json();
+
+    expect($json['data'])->toHaveCount(2)
+        ->and($json['meta']['total'])->toBe(2)
+        ->and($json['data'][0])->toHaveKeys(['id', 'book', 'para', 'progress', 'progress_line', 'views', 'updated_at', 'channel', 'studio'])
+        ->and($json['data'][0]['channel'])->toMatchArray(['id' => $high, 'name' => 'high', 'type' => 'translation'])
+        ->and($json['data'][0]['progress'])->toBe(0.9)
+        ->and($json['data'][0]['views'])->toBe(2)
+        ->and($json['data'][0]['progress_line'])->toBe([0, 3, 1])
+        ->and($json['data'][0]['studio'])->toMatchArray(['id' => $owner, 'nickName' => 'cc-owner'])
+        ->and($json['data'][1]['channel']['id'])->toBe($low)
+        ->and($json['data'][1]['progress_line'])->toBeNull();
+});
+
+it('hides private channels from anonymous and other users', function () {
+    $owner = makeStudio('cc-private-owner');
+    makeChapterChannelRow(makeChannel($owner, 'public'), 0.5);
+    makeChapterChannelRow(makePrivateChannel($owner, 'secret'), 0.9);
+
+    $anonymous = $this->getJson('/api/v3/tipitaka-chapter-channels/9101/5')->assertOk()->json('data');
+    $stranger = $this->getJson('/api/v3/tipitaka-chapter-channels/9101/5', authHeader(makeStudio('cc-stranger')))
+        ->assertOk()->json('data');
+
+    expect(array_column(array_column($anonymous, 'channel'), 'name'))->toBe(['public'])
+        ->and(array_column(array_column($stranger, 'channel'), 'name'))->toBe(['public']);
+});
+
+it('shows private channels to the owner and to users they are shared with', function () {
+    $owner = makeStudio('cc-share-owner');
+    $direct = makeStudio('cc-share-direct');
+    $viaGroup = makeStudio('cc-share-group');
+    $secret = makePrivateChannel($owner, 'secret');
+    makeChapterChannelRow($secret, 0.9);
+
+    shareChannelTo($secret, $direct);
+    $groupUid = (string) Str::uuid();
+    (new GroupMember)->forceFill([
+        'id' => random_int(1, PHP_INT_MAX),
+        'user_id' => $viaGroup,
+        'group_id' => $groupUid,
+        'power' => 10,
+        'level' => 0,
+        'status' => 0,
+    ])->save();
+    shareChannelTo($secret, $groupUid);
+
+    foreach ([$owner, $direct, $viaGroup] as $viewer) {
+        expect($this->getJson('/api/v3/tipitaka-chapter-channels/9101/5', authHeader($viewer))
+            ->assertOk()->json('data.0.channel.id'))->toBe($secret);
+    }
+});
+
+it('returns an empty list for a chapter without channels', function () {
+    $this->getJson('/api/v3/tipitaka-chapter-channels/9101/999')
+        ->assertOk()
+        ->assertJsonPath('data', [])
+        ->assertJsonPath('meta.total', 0);
+});
+
+it('rejects a non-numeric path and an oversized page', function () {
+    // 路径参数格式不对匹配不上路由 → 404;查询参数不对 → 422
+    $this->getJson('/api/v3/tipitaka-chapter-channels/abc/5')->assertNotFound();
+    $this->getJson('/api/v3/tipitaka-chapter-channels/9101/5?per_page=500')
+        ->assertStatus(422)
+        ->assertJsonStructure(['errors' => ['per_page']]);
+});
+
+it('keeps the query count independent of the row count', function () {
+    $owner = makeStudio('cc-n1');
+    $countQueriesFor = function (int $rows, int $para) use ($owner): int {
+        foreach (range(1, $rows) as $i) {
+            makeChapterChannelRow(makeChannel(makeStudio("cc-n1-{$para}-{$i}"), "c{$i}"), $i / 100, 9102, $para);
+        }
+        DB::flushQueryLog();
+        DB::enableQueryLog();
+        $this->getJson("/api/v3/tipitaka-chapter-channels/9102/{$para}", authHeader($owner))->assertOk();
+        $count = count(DB::getQueryLog());
+        DB::disableQueryLog();
+
+        return $count;
+    };
+
+    expect($countQueriesFor(12, 1))->toBe($countQueriesFor(2, 2));
+});

+ 76 - 0
dashboard-test/src/tests/tipitaka-chapter-channels.spec.ts

@@ -0,0 +1,76 @@
+import { test, expect, type Page } from '@playwright/test';
+import { collect } from '../lib/collect.js';
+import { shotDir } from '../lib/env.js';
+
+/**
+ * 三藏书目页 → 点开一章 → 抽屉里的「资源」页签(GET /v3/tipitaka-chapter-channels/{book}/{para})
+ *
+ * 只读,不建数据。断言页面上列出的 channel 与接口返回的一致(名字、完成度),
+ * 且请求走的是 v3、不再调 v2 的 progress?view=chapter_channels。
+ */
+const SHOT = shotDir('tipitaka-chapter-channels');
+const shot = (page: Page, name: string) => page.screenshot({ path: `${SHOT}/${name}.png` });
+
+interface Row {
+  progress: number;
+  channel: { id: string; name: string; type: string };
+}
+
+test.describe('三藏书目 · 章节资源列表', () => {
+  test('点开一章,资源页签按完成度列出看得见的 channel', async ({ page }) => {
+    const log = collect(page);
+    const v2Calls: string[] = [];
+    page.on('request', (r) => {
+      if (r.url().includes('view=chapter_channels')) v2Calls.push(r.url());
+    });
+
+    await test.step('1. 打开书目页,选长部,点开戒蕴品', async () => {
+      await page.goto('workspace/tipitaka/lib/default', { waitUntil: 'networkidle' });
+      // 书目页初始没有章节,选一个分类(长部)才列出章节卡片
+      await page.locator('.ant-tree-title', { hasText: /^长部$/ }).first().click();
+      await page.waitForURL(/\/lib\/default\/.+/);
+      // 长部戒蕴品:本机与线上都有多个译本
+      const firstChapter = page.getByRole('heading', { level: 5, name: '(DN) Sīlakkhandhavaggapāḷi' });
+      await expect(firstChapter).toBeVisible({ timeout: 20_000 });
+      await firstChapter.click();
+      await expect(page.locator('.ant-drawer-open')).toBeVisible();
+      await shot(page, '01-drawer');
+    });
+
+    let rows: Row[] = [];
+    await test.step('2. 切到「资源」页签:请求 v3,列表与接口一致', async () => {
+      const listed = page.waitForResponse((r) => r.url().includes('/api/v3/tipitaka-chapter-channels/'));
+      await page.locator('.ant-drawer-open').getByRole('tab', { name: '资源' }).click();
+      const res = await listed;
+      expect(res.status()).toBe(200);
+      rows = ((await res.json()) as { data: Row[] }).data;
+
+      // 按完成度降序
+      const progress = rows.map((r) => r.progress);
+      expect(progress).toEqual([...progress].sort((a, b) => b - a));
+
+      if (rows.length === 0) {
+        test.info().annotations.push({ type: 'note', description: '这一章没有任何 channel,只验证了请求' });
+        return;
+      }
+      // 默认停在「译文」页签,每页 5 条:按接口顺序核对第一页的链接与完成度
+      const translations = rows.filter((r) => r.channel.type === 'translation').slice(0, 5);
+      const items = page.getByRole('tabpanel', { name: '译文' }).locator('.ant-list-item');
+      await expect(items).toHaveCount(translations.length);
+      for (const [i, row] of translations.entries()) {
+        const item = items.nth(i);
+        await expect(item.locator(`a[href*="channel=${row.channel.id}"]`)).toBeVisible();
+        await expect(item).toContainText(row.channel.name);
+        await expect(item).toContainText(`${Math.ceil(row.progress * 100)}%`);
+      }
+      await shot(page, '02-resources');
+    });
+
+    await test.step('3. 没有调 v2 的 chapter_channels,也没有页面异常与 v3 报错', async () => {
+      expect(v2Calls).toEqual([]);
+      const bad = log.all.filter((l) => l.startsWith('[PAGEERROR]') || l.includes('/api/v3/tipitaka-chapter-channels/'));
+      log.dump('章节资源列表');
+      expect(bad).toEqual([]);
+    });
+  });
+});

+ 0 - 34
dashboard-v6/src/api/progress.ts

@@ -65,37 +65,3 @@ export interface IChapterLangListResponse {
   message: string;
   data: { rows: ILangList[]; count: number };
 }
-
-/**
- * progress?view=chapter_channels&book=98&par=22
- */
-export interface IChapterChannelData {
-  book: number;
-  para: number;
-  uid: string;
-  channel_id: string;
-  progress: number;
-  progress_line?: number[];
-  updated_at: string;
-  views: number;
-  likes: number[];
-  channel: {
-    type: TChannelType;
-    owner_uid: string;
-    editor_id: number;
-    name: string;
-    summary: string;
-    lang: string;
-    status: number;
-    created_at: string;
-    updated_at: string;
-    uid: string;
-  };
-  studio: IStudio;
-}
-
-export interface IChapterChannelListResponse {
-  ok: boolean;
-  message: string;
-  data: { rows: IChapterChannelData[]; count: number };
-}

+ 87 - 0
dashboard-v6/src/api/schema.d.ts

@@ -8283,6 +8283,32 @@ export interface paths {
         patch?: never;
         trace?: never;
     };
+    "/v3/tipitaka-chapter-channels/{book}/{para}": {
+        parameters: {
+            query?: never;
+            header?: never;
+            path?: never;
+            cookie?: never;
+        };
+        /**
+         * 列出一章的 channel
+         * @description 按完成度从高到低。只列看得见的 channel:公开的,加上登录用户自己的、共享给他(或他所在群组)的;
+         *     未登录只看得到公开的。章节不存在或没有任何 channel 时返回空列表。
+         *     `progress` 是 0–1 的完成度;`progress_line` 是近几周每周的修改次数(从早到晚,由 `upgrade:chapter.dynamic.weekly` 每周算一次,
+         *     没算过就是 null),画活跃度小图用;
+         *     `views` 是这一章在该 channel 下被浏览的次数。
+         *
+         *     实现:`V3\TipitakaChapterChannelController@__invoke`
+         */
+        get: operations["get_api_v3_tipitaka_chapter_channels_book_para_"];
+        put?: never;
+        post?: never;
+        delete?: never;
+        options?: never;
+        head?: never;
+        patch?: never;
+        trace?: never;
+    };
     "/v3/tipitaka-discussions/{channel}": {
         parameters: {
             query?: never;
@@ -36429,6 +36455,67 @@ export interface operations {
             };
         };
     };
+    get_api_v3_tipitaka_chapter_channels_book_para_: {
+        parameters: {
+            query?: {
+                /** @description 每页数量,最大 200 */
+                per_page?: number;
+                /** @description 页码 */
+                page?: number;
+            };
+            header?: never;
+            path: {
+                /**
+                 * @description 书号
+                 * @example 94
+                 */
+                book: number;
+                /**
+                 * @description 章节起始段落号
+                 * @example 385
+                 */
+                para: number;
+            };
+            cookie?: never;
+        };
+        requestBody?: never;
+        responses: {
+            /** @description 成功 */
+            200: {
+                headers: {
+                    [name: string]: unknown;
+                };
+                content: {
+                    "application/json": {
+                        data?: {
+                            id?: string;
+                            book?: number;
+                            para?: number;
+                            progress?: number;
+                            progress_line?: number[] | null;
+                            views?: number;
+                            updated_at?: string;
+                            channel?: {
+                                id?: string;
+                                name?: string;
+                                type?: string;
+                                lang?: string;
+                            };
+                            studio?: {
+                                id?: string;
+                                nickName?: string;
+                                realName?: string;
+                                /** @description 仅在部分查询口径下返回。 */
+                                avatar?: string;
+                            } | null;
+                        }[];
+                        meta?: components["schemas"]["PaginationMeta"];
+                    };
+                };
+            };
+            422: components["responses"]["ProblemValidation"];
+        };
+    };
     get_api_v3_tipitaka_discussions_channel_: {
         parameters: {
             query?: {

+ 26 - 0
dashboard-v6/src/api/tipitaka-chapter-channel.ts

@@ -0,0 +1,26 @@
+/**
+ * 一章有哪些 channel 及各自的完成度(阅读页的版本列表)。
+ *
+ * `GET /v3/tipitaka-chapter-channels/{book}/{para}`,取代 v2 的
+ * `progress?view=chapter_channels`。只列当前用户看得见的 channel。
+ */
+import { api, unwrap } from "./client";
+import type { paths } from "./schema";
+
+type ChapterChannelList =
+  paths["/v3/tipitaka-chapter-channels/{book}/{para}"]["get"]["responses"][200]["content"]["application/json"];
+
+export type ChapterChannel = NonNullable<ChapterChannelList["data"]>[number];
+
+/** 单章最多几十个 channel,一页 200 足够取全。 */
+const PAGE_SIZE = 200;
+
+export const fetchChapterChannels = async (
+  book: number,
+  para: number
+): Promise<ChapterChannel[]> =>
+  unwrap(
+    await api.GET("/v3/tipitaka-chapter-channels/{book}/{para}", {
+      params: { path: { book, para }, query: { per_page: PAGE_SIZE } },
+    })
+  ).data ?? [];

+ 1 - 1
dashboard-v6/src/components/channel/ChannelListItem.tsx

@@ -6,7 +6,7 @@ import type { IStudio } from "../../api/Auth";
 
 interface IWidget {
   channel: IChannelApiData;
-  studio: IStudio;
+  studio?: IStudio;
   showProgress?: boolean;
   showLike?: boolean;
 }

+ 31 - 24
dashboard-v6/src/components/tipitaka/BookViewer.tsx

@@ -4,7 +4,9 @@ import PaliChapterChannelList from "./PaliChapterChannelList";
 import PaliChapterListByPara from "./PaliChapterListByPara";
 import PaliChapterHead from "./PaliChapterHead";
 import type { IChapterClickEvent } from "./PaliChapterList";
-import { Tabs } from "antd";
+import { Button, Typography } from "antd";
+import { useIntl } from "react-intl";
+import { Link } from "react-router";
 
 export interface IChapter {
   book: number;
@@ -17,6 +19,7 @@ interface IWidget {
   onChange?: (currChapter: IChapter) => void;
 }
 const BookViewerWidget = ({ chapter, onChange }: IWidget) => {
+  const intl = useIntl();
   const [currChapter, setCurrChapter] = useState(chapter);
   useEffect(() => {
     if (typeof onChange !== "undefined") {
@@ -35,29 +38,33 @@ const BookViewerWidget = ({ chapter, onChange }: IWidget) => {
         }}
         para={currChapter}
       />
-      <Tabs
-        size="small"
-        items={[
-          {
-            label: `目录`,
-            key: "toc",
-            children: (
-              <PaliChapterListByPara
-                chapter={currChapter}
-                onChapterClick={(e: IChapterClickEvent) => {
-                  setCurrChapter({ book: e.para.Book, para: e.para.Paragraph });
-                  console.log("PaliChapterListByPara", "onchange", e);
-                }}
-              />
-            ),
-          },
-          {
-            label: `资源`,
-            key: "res",
-            children: <PaliChapterChannelList para={currChapter} />,
-          },
-        ]}
-      />
+      {/* 不带 channel 打开阅读页,只看巴利原文;与下面资源链接一样新标签页打开 */}
+      <Link
+        to={`/workspace/tipitaka/chapter/${currChapter.book}-${currChapter.para}`}
+        target="_blank"
+      >
+        <Button type="primary">
+          {intl.formatMessage({ id: "buttons.open.original" })}
+        </Button>
+      </Link>
+      <div style={{ marginTop: 8 }}>
+        <Typography.Title level={5}>
+          {intl.formatMessage({ id: "labels.resources" })}
+        </Typography.Title>
+        <PaliChapterChannelList para={currChapter} />
+      </div>
+      <div style={{ marginTop: 16 }}>
+        <Typography.Title level={5}>
+          {intl.formatMessage({ id: "labels.table-of-content" })}
+        </Typography.Title>
+        <PaliChapterListByPara
+          compact
+          chapter={currChapter}
+          onChapterClick={(e: IChapterClickEvent) => {
+            setCurrChapter({ book: e.para.Book, para: e.para.Paragraph });
+          }}
+        />
+      </div>
     </>
   );
 };

+ 9 - 8
dashboard-v6/src/components/tipitaka/ChapterInChannel.tsx

@@ -1,6 +1,6 @@
 import { Button, Col, List, Modal, Row, Space, Tabs } from "antd";
 import { Typography } from "antd";
-import { LikeOutlined, EyeOutlined } from "@ant-design/icons";
+import { EyeOutlined } from "@ant-design/icons";
 import { Tiny } from "@ant-design/plots";
 
 import type { IChannelApiData } from "../../api/channel";
@@ -17,11 +17,10 @@ const { Text } = Typography;
 
 export interface IChapterChannelData {
   channel: IChannelApiData;
-  studio: IStudio;
+  studio?: IStudio;
   progress: number;
   progressLine?: number[];
   hit: number;
-  like: number;
   updatedAt: string;
 }
 interface IWidgetChapterInChannel {
@@ -58,11 +57,14 @@ const ChapterInChannelWidget = ({
             : {
                 showQuickJumper: false,
                 showSizeChanger: false,
-                pageSize: 5,
+                pageSize: 3,
                 total: channels.length,
                 position: "bottom",
                 showTotal: (total) => {
-                  return `结果: ${total}`;
+                  return intl.formatMessage(
+                    { id: "labels.pagination.total" },
+                    { total }
+                  );
                 },
               }
         }
@@ -100,8 +102,7 @@ const ChapterInChannelWidget = ({
                 <Text type="secondary">
                   <Space style={{ paddingLeft: "2em" }}>
                     <EyeOutlined />
-                    {item.hit} | <LikeOutlined />
-                    {item.like} |
+                    {item.hit} |
                     <TimeShow updatedAt={item.updatedAt} /> |
                     <ProgressOutlinedIcon />
                     {`${item.progress}%`}
@@ -139,7 +140,7 @@ const ChapterInChannelWidget = ({
           </Button>
         </div>
         <Modal
-          title="版本选择"
+          title={intl.formatMessage({ id: "labels.channel.select" })}
           open={open}
           onCancel={handleCancel}
           onOk={handleCancel}

+ 92 - 8
dashboard-v6/src/components/tipitaka/PaliChapterCard.tsx

@@ -2,6 +2,7 @@ import { Row, Col, Space } from "antd";
 import { Typography } from "antd";
 import { Tiny } from "@ant-design/plots";
 import TocPath from "./TocPath";
+import { useIntl } from "react-intl";
 
 const { Title, Text, Link } = Typography;
 
@@ -12,17 +13,104 @@ export interface IPaliChapterData {
   Path: string;
   Book: number;
   Paragraph: number;
-  chapterStrLen: number;
-  paragraphCount: number;
+  chapterStrLen?: number;
+  /** v2 `view=chapter` 不返回 chapter_len,此时为 undefined */
+  paragraphCount?: number;
   progressLine?: number[];
 }
 
+/** 1234 → 1.2K,1234567 → 1.2M;小于 1000 原样。固定用 en,避免中文环境出「万」 */
+const countFormatter = new Intl.NumberFormat("en", {
+  notation: "compact",
+  maximumFractionDigits: 1,
+});
+const formatCount = (n: number): string => countFormatter.format(n);
+
+/** 统计行:标签灰色、数字正文色加粗,两组之间留空,不用竖线 */
+const ChapterStats = ({
+  data,
+  fontSize,
+}: {
+  data: IPaliChapterData;
+  fontSize?: number;
+}) => {
+  const intl = useIntl();
+  // 接口不一定两项都给(v2 view=chapter 没有段落数),缺的那项不显示
+  const items = [
+    data.chapterStrLen != null && {
+      label: intl.formatMessage({ id: "labels.char-count" }),
+      value: formatCount(data.chapterStrLen),
+    },
+    data.paragraphCount != null && {
+      label: intl.formatMessage({ id: "labels.paragraph-count" }),
+      value: data.paragraphCount.toLocaleString("en"),
+    },
+  ].filter((it): it is { label: string; value: string } => Boolean(it));
+  if (items.length === 0) return null;
+  return (
+    <Space size={12} style={{ fontSize }}>
+      {items.map((it) => (
+        <span key={it.label}>
+          <Text type="secondary" style={{ fontSize }}>
+            {it.label}
+          </Text>{" "}
+          <Text strong style={{ fontSize }}>
+            {it.value}
+          </Text>
+        </span>
+      ))}
+    </Space>
+  );
+};
+
 interface IWidget {
   data: IPaliChapterData;
+  /** 紧凑模式:标题一行、统计一行,不显示路径,进度图缩小 */
+  compact?: boolean;
   onTitleClick?: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void;
 }
 
-const PaliChapterCardWidget = ({ data, onTitleClick }: IWidget) => {
+const PaliChapterCardWidget = ({
+  data,
+  compact = false,
+  onTitleClick,
+}: IWidget) => {
+  if (compact) {
+    return (
+      <div style={{ display: "flex", alignItems: "center", gap: 8 }}>
+        <div style={{ flex: "auto", minWidth: 0 }}>
+          <Link
+            strong
+            onClick={(e) => {
+              if (typeof onTitleClick !== "undefined") {
+                onTitleClick(e);
+              }
+            }}
+          >
+            {data.Title}
+          </Link>
+          {data.PaliTitle !== data.Title ? (
+            <Text type="secondary"> {data.PaliTitle}</Text>
+          ) : null}
+          <div>
+            <ChapterStats data={data} fontSize={12} />
+          </div>
+        </div>
+        {data.progressLine ? (
+          <div style={{ flex: "none" }}>
+            <Tiny.Line
+              height={32}
+              width={120}
+              autoFit={false}
+              data={data.progressLine}
+              smooth={true}
+            />
+          </div>
+        ) : null}
+      </div>
+    );
+  }
+
   const path = JSON.parse(data.Path);
 
   return (
@@ -71,11 +159,7 @@ const PaliChapterCardWidget = ({ data, onTitleClick }: IWidget) => {
           </Row>
           <Row>
             <Col>
-              <Text type="secondary">
-                <Space>
-                  字符数{data.chapterStrLen} | 段落数{data.paragraphCount}
-                </Space>
-              </Text>
+              <ChapterStats data={data} />
             </Col>
           </Row>
           <Row>

+ 15 - 20
dashboard-v6/src/components/tipitaka/PaliChapterChannelList.tsx

@@ -1,10 +1,9 @@
 import { useState, useEffect } from "react";
 
-import { get } from "../../request";
-
 import type { IChapter } from "./BookViewer";
 import ChapterInChannel, { type IChapterChannelData } from "./ChapterInChannel";
-import type { IChapterChannelListResponse } from "../../api/progress";
+import { fetchChapterChannels } from "../../api/tipitaka-chapter-channel";
+import type { TChannelType } from "../../api/channel";
 
 interface IWidget {
   para: IChapter;
@@ -20,25 +19,21 @@ const PaliChapterChannelList = ({
   const [tableData, setTableData] = useState<IChapterChannelData[]>([]);
 
   useEffect(() => {
-    const url = `/api/v2/progress?view=chapter_channels&book=${para.book}&par=${para.para}`;
-    get<IChapterChannelListResponse>(url).then(function (json) {
-      const newData: IChapterChannelData[] = json.data.rows.map((item) => {
-        return {
+    fetchChapterChannels(para.book, para.para).then((rows) => {
+      setTableData(
+        rows.map((item) => ({
           channel: {
-            name: item.channel.name,
-            id: item.channel.uid,
-            type: item.channel.type,
+            name: item.channel?.name ?? "",
+            id: item.channel?.id ?? "",
+            type: item.channel?.type as TChannelType | undefined,
           },
-          studio: item.studio,
-          progress: Math.ceil(item.progress * 100),
-          progressLine: item.progress_line,
-          hit: item.views,
-          like: 0,
-          updatedAt: item.updated_at,
-        };
-      });
-      setTableData(newData);
-      console.log("chapter", newData);
+          studio: item.studio?.id ? { ...item.studio, id: item.studio.id } : undefined,
+          progress: Math.ceil((item.progress ?? 0) * 100),
+          progressLine: item.progress_line ?? undefined,
+          hit: item.views ?? 0,
+          updatedAt: item.updated_at ?? "",
+        }))
+      );
     });
   }, [para]);
 

+ 11 - 0
dashboard-v6/src/components/tipitaka/PaliChapterList.module.css

@@ -0,0 +1,11 @@
+/* 紧凑模式(右侧抽屉目录)的列表项:整行可点,hover 高亮 */
+.compactItem {
+  padding: 4px 8px !important;
+  border-radius: var(--ant-border-radius, 6px);
+  cursor: pointer;
+  transition: background-color 0.2s;
+}
+
+.compactItem:hover {
+  background-color: var(--ant-color-fill-tertiary, rgba(0, 0, 0, 0.04));
+}

+ 30 - 14
dashboard-v6/src/components/tipitaka/PaliChapterList.tsx

@@ -1,6 +1,7 @@
 import { List } from "antd";
 
 import PaliChapterCard, { type IPaliChapterData } from "./PaliChapterCard";
+import styles from "./PaliChapterList.module.css";
 
 export interface IChapterClickEvent {
   para: IPaliChapterData;
@@ -10,33 +11,48 @@ export interface IChapterClickEvent {
 interface IWidgetPaliChapterList {
   data: IPaliChapterData[];
   maxLevel?: number;
+  /** 紧凑模式:小号列表 + 单行卡片(右侧抽屉里用) */
+  compact?: boolean;
   onChapterClick?: (v: IChapterClickEvent) => void;
 }
 const PaliChapterListWidget = ({
   data,
   maxLevel = 8,
+  compact = false,
   onChapterClick,
 }: IWidgetPaliChapterList) => {
+  const emitClick = (
+    item: IPaliChapterData,
+    e: React.MouseEvent<HTMLElement, MouseEvent>
+  ) => {
+    if (typeof onChapterClick !== "undefined") {
+      onChapterClick({ para: item, event: e });
+    }
+  };
+
   return (
     <List
       itemLayout="vertical"
-      size="large"
+      size={compact ? "small" : "large"}
       dataSource={data}
       renderItem={(item) =>
         item.level <= maxLevel ? (
-          <List.Item>
-            <PaliChapterCard
-              onTitleClick={(e) => {
-                if (typeof onChapterClick !== "undefined") {
-                  onChapterClick({
-                    para: item,
-                    event: e,
-                  });
-                }
-              }}
-              data={item}
-            />
-          </List.Item>
+          compact ? (
+            // 紧凑模式整行可点,点击交给 List.Item,卡片里的标题不再单独绑定
+            <List.Item
+              className={styles.compactItem}
+              onClick={(e) => emitClick(item, e)}
+            >
+              <PaliChapterCard compact data={item} />
+            </List.Item>
+          ) : (
+            <List.Item>
+              <PaliChapterCard
+                onTitleClick={(e) => emitClick(item, e)}
+                data={item}
+              />
+            </List.Item>
+          )
         ) : (
           <></>
         )

+ 7 - 1
dashboard-v6/src/components/tipitaka/PaliChapterListByPara.tsx

@@ -8,9 +8,14 @@ import PaliChapterList, { type IChapterClickEvent } from "./PaliChapterList";
 
 interface IWidget {
   chapter: IChapter;
+  compact?: boolean;
   onChapterClick?: (e: IChapterClickEvent) => void;
 }
-const PaliChapterListByParaWidget = ({ chapter, onChapterClick }: IWidget) => {
+const PaliChapterListByParaWidget = ({
+  chapter,
+  compact = false,
+  onChapterClick,
+}: IWidget) => {
   const [tableData, setTableData] = useState<IPaliChapterData[]>([]);
 
   useEffect(() => {
@@ -38,6 +43,7 @@ const PaliChapterListByParaWidget = ({ chapter, onChapterClick }: IWidget) => {
   return (
     <>
       <PaliChapterList
+        compact={compact}
         onChapterClick={(e: IChapterClickEvent) => {
           if (onChapterClick) {
             onChapterClick(e);

+ 1 - 0
dashboard-v6/src/locales/en-US/buttons.ts

@@ -62,6 +62,7 @@ const items = {
   "buttons.copy.id": "copy id",
   "buttons.reply": "reply",
   "buttons.open": "open",
+  "buttons.open.original": "Open Pali text",
   "buttons.compact": "compact",
   "buttons.magic-dict": "magic-dict",
   "buttons.theme.ant": "theme ant",

+ 5 - 0
dashboard-v6/src/locales/en-US/label.ts

@@ -142,6 +142,11 @@ const items = {
   "term.fields.meaning2.tooltip": "其他意思将出现在后面的括号里",
   "term.fields.note.label": "note",
   "term.general-in-studio": "general in studio",
+  "labels.resources": "Resources",
+  "labels.char-count": "Chars",
+  "labels.paragraph-count": "Paras",
+  "labels.pagination.total": "Total: {total}",
+  "labels.channel.select": "Select version",
 };
 
 export default items;

+ 1 - 0
dashboard-v6/src/locales/zh-Hans/buttons.ts

@@ -62,6 +62,7 @@ const items = {
   "buttons.copy.id": "复制编号",
   "buttons.reply": "回复",
   "buttons.open": "打开",
+  "buttons.open.original": "打开原文",
   "buttons.compact": "紧凑",
   "buttons.magic-dict": "神奇字典",
   "buttons.theme.ant": "默认",

+ 5 - 0
dashboard-v6/src/locales/zh-Hans/label.ts

@@ -149,6 +149,11 @@ const items = {
   "term.fields.meaning2.tooltip": "其他意思将出现在后面的括号里",
   "term.fields.note.label": "注释",
   "term.general-in-studio": "通用于本Studio",
+  "labels.resources": "资源",
+  "labels.char-count": "字符",
+  "labels.paragraph-count": "段落",
+  "labels.pagination.total": "结果: {total}",
+  "labels.channel.select": "版本选择",
 };
 
 export default items;

+ 1 - 0
dashboard-v6/src/locales/zh-Hant/buttons.ts

@@ -62,6 +62,7 @@ const items = {
   "buttons.copy.id": "複製編號",
   "buttons.reply": "回覆",
   "buttons.open": "開啟",
+  "buttons.open.original": "開啟原文",
   "buttons.compact": "緊湊",
   "buttons.magic-dict": "神奇字典",
   "buttons.theme.ant": "預設",

+ 5 - 0
dashboard-v6/src/locales/zh-Hant/label.ts

@@ -149,6 +149,11 @@ const items = {
   "term.fields.meaning2.tooltip": "其他意思將出現在後面的括號裡",
   "term.fields.note.label": "註釋",
   "term.general-in-studio": "通用於本Studio",
+  "labels.resources": "資源",
+  "labels.char-count": "字元",
+  "labels.paragraph-count": "段落",
+  "labels.pagination.total": "結果: {total}",
+  "labels.channel.select": "版本選擇",
 };
 
 export default items;

+ 2 - 0
openapi/public/assets/protocol/main.yaml

@@ -725,6 +725,8 @@ paths:
     $ref: ./resources/auto/v3-search-search.yaml
   /v3/sessions:
     $ref: ./resources/auto/v3-sessions.yaml
+  '/v3/tipitaka-chapter-channels/{book}/{para}':
+    $ref: ./resources/auto/v3-tipitaka-chapter-channels-book-para.yaml
   '/v3/tipitaka-discussions/{channel}':
     $ref: ./resources/auto/v3-tipitaka-discussions-channel.yaml
   '/v3/tipitaka-reading/{channel}':

+ 1 - 1
openapi/public/assets/protocol/overrides/v2-progress.yaml

@@ -13,7 +13,7 @@ get:
                     items:
                       anyOf:
                         - type: object
-                          description: view=chapter_channels
+                          description: view=chapter_channels(已被 `GET /v3/tipitaka-chapter-channels/{book}/{para}` 取代,保留仅为 dashboard-v4 兼容,待 v4 下线后删除)
                           properties:
                             book:
                               type: integer

+ 1 - 1
openapi/public/assets/protocol/resources/auto/v2-progress.yaml

@@ -123,7 +123,7 @@ get:
                   rows:
                     type: array
                     items:
-                      anyOf: [{ type: object, description: view=chapter_channels, properties: { book: { type: integer }, para: { type: integer }, uid: { type: string }, channel_id: { type: string }, name: { type: string }, type: { type: string }, progress: { type: number }, updated_at: { type: string }, channel: { type: object, properties: { uid: { type: string }, name: { type: string }, type: { type: string } } } } }, { type: object, description: view=ids, properties: { id: { type: string } } }, { type: object, description: view=channel, properties: { channel_id: { type: string }, count: { type: integer }, studio: { type: object, properties: { id: { type: string }, nickName: { type: string }, realName: { type: string }, studioName: { type: string }, avatar: { type: string } } }, channel: { type: object, properties: { name: { type: string }, summary: { type: string, nullable: true }, lang: { type: string } } } } }]
+                      anyOf: [{ type: object, description: 'view=chapter_channels(已被 `GET /v3/tipitaka-chapter-channels/{book}/{para}` 取代,保留仅为 dashboard-v4 兼容,待 v4 下线后删除)', properties: { book: { type: integer }, para: { type: integer }, uid: { type: string }, channel_id: { type: string }, name: { type: string }, type: { type: string }, progress: { type: number }, updated_at: { type: string }, channel: { type: object, properties: { uid: { type: string }, name: { type: string }, type: { type: string } } } } }, { type: object, description: view=ids, properties: { id: { type: string } } }, { type: object, description: view=channel, properties: { channel_id: { type: string }, count: { type: integer }, studio: { type: object, properties: { id: { type: string }, nickName: { type: string }, realName: { type: string }, studioName: { type: string }, avatar: { type: string } } }, channel: { type: object, properties: { name: { type: string }, summary: { type: string, nullable: true }, lang: { type: string } } } } }]
                   count:
                     type: integer
     401:

+ 89 - 0
openapi/public/assets/protocol/resources/auto/v3-tipitaka-chapter-channels-book-para.yaml

@@ -0,0 +1,89 @@
+# 由 openapi/scripts/generate.php 生成,请勿手改;补充写到 overrides/v3-tipitaka-chapter-channels-book-para.yaml
+get:
+  summary: '列出一章的 channel'
+  tags:
+    - tipitaka-chapter-channels
+  operationId: get_api_v3_tipitaka_chapter_channels_book_para_
+  description: |-
+    按完成度从高到低。只列看得见的 channel:公开的,加上登录用户自己的、共享给他(或他所在群组)的;
+    未登录只看得到公开的。章节不存在或没有任何 channel 时返回空列表。
+    `progress` 是 0–1 的完成度;`progress_line` 是近几周每周的修改次数(从早到晚,由 `upgrade:chapter.dynamic.weekly` 每周算一次,
+    没算过就是 null),画活跃度小图用;
+    `views` 是这一章在该 channel 下被浏览的次数。
+
+    实现:`V3\TipitakaChapterChannelController@__invoke`
+  security:
+    - {  }
+    -
+      bearerAuth: []
+  parameters:
+    -
+      name: book
+      in: path
+      required: true
+      schema:
+        type: integer
+      description: 书号
+      example: 94
+    -
+      name: para
+      in: path
+      required: true
+      schema:
+        type: integer
+      description: 章节起始段落号
+      example: 385
+    -
+      name: per_page
+      in: query
+      schema:
+        type: integer
+        default: 100
+      description: '每页数量,最大 200'
+    -
+      name: page
+      in: query
+      schema:
+        type: integer
+        default: 1
+      description: 页码
+  responses:
+    200:
+      description: 成功
+      content:
+        application/json:
+          schema:
+            type: object
+            properties:
+              data:
+                type: array
+                items:
+                  type: object
+                  properties:
+                    id:
+                      type: string
+                    book:
+                      type: integer
+                    para:
+                      type: integer
+                    progress:
+                      type: number
+                    progress_line:
+                      type: array
+                      items: { type: integer }
+                      nullable: true
+                    views:
+                      type: integer
+                    updated_at:
+                      type: string
+                    channel:
+                      type: object
+                      properties: { id: { type: string }, name: { type: string }, type: { type: string }, lang: { type: string } }
+                    studio:
+                      type: object
+                      properties: { id: { type: string }, nickName: { type: string }, realName: { type: string }, avatar: { type: string, description: 仅在部分查询口径下返回。 } }
+                      nullable: true
+              meta:
+                $ref: '../../main.yaml#/components/schemas/PaginationMeta'
+    422:
+      $ref: '../../main.yaml#/components/responses/ProblemValidation'