浏览代码

📦 按要地图上增加搜索按钮,在有限区域内搜索村社的名称

快乐的梦鱼 4 周之前
父节点
当前提交
7d53807adf
共有 7 个文件被更改,包括 275 次插入39 次删除
  1. 9 0
      .claude/settings.local.json
  2. 128 0
      CLAUDE.md
  3. 61 0
      src/api/light/TreeApi.ts
  4. 2 2
      src/pages.json
  5. 46 10
      src/pages/home/components/LightMap.vue
  6. 27 25
      src/pages/home/search/index.vue
  7. 2 2
      src/pages/index.vue

+ 9 - 0
.claude/settings.local.json

@@ -0,0 +1,9 @@
+{
+  "permissions": {
+    "allow": [
+      "mcp__chrome-devtools__new_page",
+      "mcp__chrome-devtools__take_snapshot",
+      "mcp__chrome-devtools__navigate_page"
+    ]
+  }
+}

+ 128 - 0
CLAUDE.md

@@ -0,0 +1,128 @@
+# 亮乡源·大众版 (xiangyuan-app)
+
+乡村文化数字化保护平台,大众版小程序。支持微信小程序、H5等多端。
+
+## 技术栈
+
+- **框架**: Uniapp 3.0 + Vue 3 (Composition API)
+- **语言**: TypeScript
+- **状态管理**: Pinia 3.0
+- **UI 组件库**: NaEasy UI (`src/components/`)
+- **构建工具**: Vite 5
+- **样式**: Sass/SCSS
+- **API 请求**: `@imengyu/js-request-transform` + `@imengyu/imengyu-utils`
+
+## 开发命令
+
+```bash
+npm run dev:h5            # H5 开发
+npm run dev:mp-weixin     # 微信小程序开发
+npm run build:mp-weixin   # 构建微信小程序
+npm run type-check        # TypeScript 类型检查
+```
+
+## 项目结构
+
+```
+src/
+├── api/              # API 请求模块,按业务域分组
+│   ├── auth/         # 登录/用户认证
+│   ├── agent/        # AI 智能体
+│   ├── inheritor/    # 内容模型(活动、产品等)
+│   ├── inhert/       # 村落与传承人
+│   ├── light/        # 点亮村落、乡源树
+│   ├── map/          # 地图相关
+│   ├── restful/      # RESTful 通用服务
+│   ├── system/       # 系统配置
+│   ├── BaseAppServerRequestModule.ts  # 请求拦截器/错误处理基类
+│   ├── RequestModules.ts             # 服务端请求模块
+│   └── CommonContent.ts             # 通用内容基类
+├── common/           # 通用功能
+│   ├── components/   # 共享组件
+│   ├── composeabe/   # 组合式函数 (useAppInit, useUserTools 等)
+│   ├── config/       # 配置 (AppConfig, ApiConfig, Theme)
+│   ├── style/        # 全局 SCSS
+│   └── utils/        # 工具函数
+├── components/       # NaEasy UI 组件库
+├── pages/            # 页面
+│   ├── home/         # 首页/村落相关
+│   ├── article/      # 文章
+│   ├── chat/         # AI 对话
+│   ├── dig/          # 采集投稿
+│   ├── editor/       # 编辑器
+│   └── user/         # 用户/登录
+├── store/            # Pinia 状态管理
+│   ├── auth.ts       # 认证状态
+│   ├── village.ts    # 村落数据
+│   └── collect.ts    # 采集状态
+└── pages.json        # 路由配置
+```
+
+## API 层规范
+
+### DataModel 模式
+
+所有 API 数据模型继承 `DataModel`,使用 `setNameMapperCase('Camel', 'Snake')` 自动转换字段命名:
+- 客户端使用 **camelCase**
+- 服务端使用 **snake_case**
+
+```typescript
+export class ExampleItem extends DataModel<ExampleItem> {
+  constructor() {
+    super(ExampleItem, '示例');
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+      // 需要类型转换的字段在此声明
+    };
+  }
+  id!: number;
+  name = '';
+}
+```
+
+### API 模块模式
+
+- API 类继承 `AppServerRequestModule`,按业务域组织
+- 每个模块导出为单例:`export default new XxxApi()`
+- 请求方法使用 `this.post()` / `this.get()`,第二个参数为中文标签(用于错误提示)
+- 分页列表统一使用 `parsePagedList()` 辅助方法
+
+### 请求拦截
+
+`BaseAppServerRequestModule` 自动注入:
+- `token` — 用户令牌
+- `user_id` — 用户 ID
+- `main_body_id` — 主体 ID
+
+## 编码规范
+
+### 命名
+
+- **类/类型**: PascalCase (`VillageListItem`, `UserApi`)
+- **函数/变量**: camelCase (`getVillageList`, `isLogged`)
+- **文件**: API 模块 PascalCase (`TreeApi.ts`),页面/组件 kebab-case
+- **Composable**: `use` 前缀 (`useAuthStore`, `useUserTools`)
+
+### 字段注释
+
+API 数据模型字段使用 JSDoc 单行注释标注含义,尤其是枚举类型字段需注明可选值:
+```typescript
+/** 状态: 0=完善中, 1=已归档 */
+status = 0;
+/** 列入少数民族特色村寨试点示范: 0=否, 1=是 */
+isFeaturedVillage = 0;
+```
+
+### Vue 组件
+
+- 使用 `<script setup lang="ts">` 组合式 API
+- 样式使用 `<style lang="scss" scoped>`
+
+## API 文档
+
+项目 API 文档托管在 ShowDoc:`https://www.showdoc.com.cn/minnanCE/`
+
+添加新接口时应参考文档补充完整的数据模型字段和类型转换表。
+
+**优先使用 chrome-devtools MCP 来请求文档**.

+ 61 - 0
src/api/light/TreeApi.ts

@@ -237,6 +237,45 @@ export class UpgradePackageItem extends DataModel<UpgradePackageItem> {
   deletetime = '' as string | null;
 }
 
+export class GrowthTaskItem extends DataModel<GrowthTaskItem> {
+  constructor() {
+    super(GrowthTaskItem, '成长任务');
+    this.setNameMapperCase('Camel', 'Snake');
+    this._convertTable = {
+      id: { clientSide: 'number', serverSide: 'number', clientSideRequired: true },
+      villageId: { clientSide: 'number', serverSide: 'number' },
+      points: { clientSide: 'number', serverSide: 'number' },
+      limitNum: { clientSide: 'number', serverSide: 'number' },
+      rewardWater: { clientSide: 'number', serverSide: 'number' },
+      rewardLight: { clientSide: 'number', serverSide: 'number' },
+      rewardFruit: { clientSide: 'number', serverSide: 'number' },
+      status: { clientSide: 'number', serverSide: 'number' },
+      weight: { clientSide: 'number', serverSide: 'number' },
+    };
+  }
+
+  id!: number;
+  taskKey = '';
+  villageId = 0;
+  name = '';
+  desc = '';
+  points = 0;
+  /** 限制类型: unlimited=无限制, day=每日限制, total=次数限制 */
+  limitType = '' as 'unlimited' | 'day' | 'total' | string;
+  limitNum = 0;
+  rewardWater = 0;
+  rewardLight = 0;
+  rewardFruit = 0;
+  /** 状态: 0=未启用, 1=启用 */
+  status = 0;
+  weight = 0;
+  limitTypeText = '';
+  statusText = '';
+  createtime = '' as string | null;
+  updatetime = '' as string | null;
+  deletetime = '' as string | null;
+}
+
 export class DropFruitResult extends DataModel<DropFruitResult> {
   constructor() {
     super(DropFruitResult, '产果结果');
@@ -479,6 +518,28 @@ export class TreeApi extends AppServerRequestModule<DataModel> {
     return { list, total: list.length };
   }
 
+  /** 任务列表 */
+  async getTaskList(options?: {
+    page?: number;
+    pageSize?: number;
+    keywords?: string;
+  }) {
+    const res = await this.post<PagedGrowthResponse>(
+      '/village/growth/taskList',
+      '任务列表',
+      {
+        page: options?.page,
+        pageSize: options?.pageSize,
+        keywords: options?.keywords,
+      },
+    );
+    return this.parsePagedList<GrowthTaskItem>(
+      GrowthTaskItem,
+      res.requireData(),
+      '成长任务',
+    );
+  }
+
   /** 任务日志列表 */
   async getTaskLogList(search?: GrowthLogSearch) {
     const res = await this.post<PagedGrowthResponse>(

+ 2 - 2
src/pages.json

@@ -86,9 +86,9 @@
       }
     },
     {
-      "path": "pages/home/village/goods/index",
+      "path": "pages/home/search/index",
       "style": {
-        "navigationBarTitleText": "乡源好物",
+        "navigationBarTitleText": "村社搜索",
         "navigationStyle": "custom",
         "enablePullDownRefresh": true
       }

+ 46 - 10
src/pages/home/components/LightMap.vue

@@ -63,7 +63,7 @@
         zIndex: 100,
         backgroundColor: '#ffffff',
       }"
-      icon="search" 
+      :icon="searchKeyword ? 'close' : 'search'" 
       @click="showSearch" 
     />
     <SimpleDropDownPicker 
@@ -221,15 +221,16 @@ const mapLoader = useSimpleDataLoader<MapMarker[]>(async () => {
     && p.longitude > -180 && p.longitude < 180
     && p.latitude > -90 && p.latitude < 90
   );
-
-  mapCtx.addMarkers({
-    clear: true,
-    markers: list, 
-  })
+  asyncAddMarkers(list);
 
   if (nextNeedAutoFocus.value) {
     
-    if (res.length > 0) {
+    if (res.length == 1) {
+      mapCtx.moveToLocation({
+        latitude: Number(list[0].latitude),
+        longitude: Number(list[0].longitude),
+      });
+    } else if (res.length > 1) {
       setTimeout(() => {
         mapCtx.includePoints({
           points: list.map(p => {
@@ -261,12 +262,36 @@ const mapLoader = useSimpleDataLoader<MapMarker[]>(async () => {
     }
   }
 
+
+
   ready.value = true;
   return list;
 }, false, false);
 
+const BATCH_SIZE = 50;
+
+async function asyncAddMarkers(list: MapMarker[]) {
+  if (list.length <= BATCH_SIZE) {
+    mapCtx.addMarkers({
+      clear: true,
+      markers: list,
+    });
+    return;
+  }
+  for (let i = 0; i < list.length; i += BATCH_SIZE) {
+    const batch = list.slice(i, i + BATCH_SIZE);
+    mapCtx.addMarkers({
+      clear: i === 0,
+      markers: batch,
+    });
+    if (i + BATCH_SIZE < list.length) {
+      await waitTimeOut(50);
+    }
+  }
+}
+
 const isEmptyRegion = computed(() => {
-  return !hasResItems.value && ready.value;
+  return !hasResItems.value && ready.value && !searchKeyword.value;
 });
 
 function onMarkerTap(e: any) {
@@ -300,8 +325,14 @@ function setCurrentRegion(regionName: string) {
 }
 
 function showSearch() {
-  searchDialogShow.value = true;
-  searchKeyword.value = '';
+  if (searchKeyword.value) {
+    searchKeyword.value = '';
+    nextNeedAutoFocus.value = true;
+    mapLoader.reload();
+  } else {
+    searchDialogShow.value = true;
+    searchKeyword.value = '';
+  }
 }
 async function searchConfirm() {
   searchDialogShow.value = false;
@@ -309,7 +340,12 @@ async function searchConfirm() {
     toast('请输入搜索关键词');
     return;
   }
+  nextNeedAutoFocus.value = true;
   await mapLoader.reload();
+  if (mapLoader.content.value?.length === 0) {
+    toast('未搜索到相关村社,换个关键词再试试吧');
+    return;
+  }
 }
 
 watch(() => props.city, async (newVal) => {

+ 27 - 25
src/pages/home/search/index.vue

@@ -1,31 +1,33 @@
 <template>
   <CommonTopBanner title="搜索">
-    <SimplePageListLoader>
-      <FlexCol gap="gap.md">
-        <Empty v-if="listLoader.list.value.length === 0" description="暂无搜索结果,换个关键词试试吧" />
-        <FlexRow 
-          v-for="(item, i) in listLoader.list.value"
-          :key="i"
-          justify="space-between" align="center" gap="gap.md"
-        >
-          <ImageBlock3
-            backgroundColor="transparent"
-            :src="item.image"
-            defaultImage="https://xy.wenlvti.net/app_static/images/village/PlaceholderVillage.jpg"
-            :title="item.name"
-            :desc="item.address"
-            :imageRadius="15"
-            :imageWidth="200"
-            :imageHeight="140"
-            @click="onGoDetails(item as VillageListItem)"
-          />
-          <FlexRow justify="flex-end" align="center" gap="gap.md" width="200rpx">
-            <Tag v-if="item.isFollow" text="已关注" size="small" />
-            <Tag v-if="item.isJoined" text="已加入" size="small" type="primary" />
+    <FlexCol padding="padding.md">
+      <SimplePageListLoader :loader="listLoader">
+        <FlexCol gap="gap.md">
+          <Empty v-if="listLoader.list.value.length === 0" description="暂无搜索结果,换个关键词试试吧" />
+          <FlexRow 
+            v-for="(item, i) in listLoader.list.value"
+            :key="i"
+            justify="space-between" align="center" gap="gap.md"
+          >
+            <ImageBlock3
+              backgroundColor="transparent"
+              :src="item.image"
+              defaultImage="https://xy.wenlvti.net/app_static/images/village/PlaceholderVillage.jpg"
+              :title="item.name"
+              :desc="item.address"
+              :imageRadius="15"
+              :imageWidth="200"
+              :imageHeight="140"
+              @click="onGoDetails(item as VillageListItem)"
+            />
+            <FlexRow justify="flex-end" align="center" gap="gap.md" width="200rpx">
+              <Tag v-if="item.isFollow" text="已关注" size="small" />
+              <Tag v-if="item.isJoined" text="已加入" size="small" type="primary" />
+            </FlexRow>
           </FlexRow>
-        </FlexRow>
-      </FlexCol>
-    </SimplePageListLoader>
+        </FlexCol>
+      </SimplePageListLoader>
+    </FlexCol>
   </CommonTopBanner>
 </template>
 

+ 2 - 2
src/pages/index.vue

@@ -125,7 +125,7 @@ async function paySuccessAndRefresh() {
 
 defineExpose({
   onPageBack: (name: string, data: Record<string, unknown>) => {
-    if (data.type === 'goVillage') {
+    if (name === 'goVillage') {
       tabIndex.value = 1;
       if (data.id) {
         loadShareVillageInfo(data.id as number);
@@ -143,7 +143,7 @@ onShareTimeline(() => {
 })
 onMounted(() => {
   if (isDevEnv) {
-    tabIndex.value = 1;
+    //tabIndex.value = 1;
   }
 })
 </script>