猎豹Cheetah插件开发 – Raycast 篇

猎豹Cheetah插件开发 – Raycast 篇

本文为稀土技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

我们好,本文将继续讲解 Cheetah for Raycast 扩展的开发流程以及 Raycast 开发扩展的一些注意事项。

Raycast 介绍

Raycast 是一个速度极快、彻底可扩展的启动器,它能够快速查找本地运用、文件等等,经过装置第三方扩展完结各种各样的功用。

Raycast 还能够自定义快速连接、脚本指令等,完结一系列自动化操作。

Raycast 的扩展开发文档十分具体,让开发者清晰地了解开发扩展相关的常识、流程,快速上手。

官网:www.raycast.com

创立项目

让我们来创立一个 Raycast 扩展吧~

Create Extension

Raycast 供给了便利快捷的指令帮助开发者创立扩展,在 Raycast 输入框内输入 Create Extension 回车即可翻开创立面板。

猎豹Cheetah插件开发 - Raycast 篇

猎豹Cheetah插件开发 - Raycast 篇

Template:挑选一个扩展模板,当时挑选的是一个在 npm 官网查找依赖库的比如。
Categories:扩展的分类。
Location:是扩展项目存放的目录。
Command Name:是扩展内指令的称号,在 Raycast 输入框中查找即可运转此指令。

填写完信息后点击 Create Extension 即可完结项目创立,生成项目结构如下:

Cheetah
├─ .eslintrc.json
├─ .gitignore
├─ .prettierrc
├─ CHANGELOG.md
├─ README.md
├─ assets
│  ├─ command-icon.png
│  └─ list-icon.png
├─ package.json
├─ src
│  └─ index.tsx
└─ tsconfig.json

终端进入项目后履行:

npm install
npm run dev

此刻如果现已翻开 Raycast,会自动唤出输入面板并且本地扩展中的指令会置顶展现。

猎豹Cheetah插件开发 - Raycast 篇

指令装备

Raycast 的扩展装备集成在 package.json 内,包括基本信息、指令进口、偏好设置等等。

{
  "$schema": "https://www.raycast.com/schemas/extension.json",
  "version": "1.0.0",
  "name": "cheetah",
  "title": "Cheetah",
  "description": "Search for a local Git project and open it with the specified application.",
  "icon": "logo.png",
  "author": "ronglecat",
  "categories": [
    "Developer Tools"
  ],
  "license": "MIT",
  "commands": [ // 本扩展包括的一切指令
    {
      "name": "editor", // 指令的唯一标识,运转指令时匹配 src 目录下的同名文件并履行。
      "title": "Open With Editor", // 用户看到的指令称号
      "description": "Open the specified project using the editor.", // 指令的描述
      "mode": "view", // 为 “views” 时指令运转后展现可视化界面,进口文件为 tsx
      "icon": "command/editor.png", // 指令专属icon
      "keywords": [ // 除了输入title外,这些关键词也能够匹配到当时指令
        "editor",
        "Editor"
      ]
    },
    {
      "name": "git",
      "title": "Open With Git GUI",
      "description": "Open the specified project using the Git GUI.",
      "mode": "view",
      "icon": "command/git.png",
      "keywords": [
        "git",
        "Git"
      ]
    },
    {
      "name": "terminal",
      "title": "Open With Terminal",
      "description": "Open the specified project using the terminal.",
      "mode": "view",
      "icon": "command/terminal.png",
      "keywords": [
        "terminal",
        "Terminal"
      ]
    },
    {
      "name": "finder",
      "title": "Open With Finder",
      "description": "Open the specified project using the Finder.",
      "mode": "view",
      "icon": "command/finder.png",
      "keywords": [
        "finder",
        "Finder"
      ]
    },
    {
      "name": "chooseApp",
      "title": "Choose Application",
      "description": "Specifying applications for projects.",
      "mode": "view",
      "icon": "command/app.png"
    },
    {
      "name": "editCache",
      "title": "Edit Cache File",
      "description": "Open the cache file using the default editor.",
      "mode": "no-view", // 为 “no-view” 时,表明仅履行,无可视化界面反馈,进口文件 ts 即可
      "icon": "command/editCache.png"
    },
    {
      "name": "clearCache",
      "title": "Clear Cache",
      "description": "Clear cache files.",
      "mode": "no-view",
      "icon": "command/clearCache.png"
    }
  ],
  "preferences": [ // 偏好设置
    {
      "name": "workspaces", // 终究存储的 key 值,运用 getPreferenceValues API 读取
      "description": "The tool will search for projects in the working directory you set, with multiple directories separated by commas, Need to configure the absolute path of the directory, This directory should contain projects managed with Git.", 
      // 此装备的具体说明
      "required": true, // 是否为强制要求
      "type": "textfield", // 输入的类型,此处为文本框
      "title": "Setting working directory" // 用户看到的标题
    },
    {
      "name": "defaultEditor",
      "description": "The default editor used to open the project.",
      "required": false,
      "type": "appPicker", // Raycast 供给的运用挑选器
      "title": "Choose default Editor"
    },
    {
      "name": "defaultGitApp",
      "description": "The Git GUI application used to open the project.",
      "required": false,
      "type": "appPicker",
      "title": "Choose Git GUI"
    },
    {
      "name": "defaultTerminalApp",
      "description": "The terminal application used to open the project.",
      "required": false,
      "type": "appPicker",
      "title": "Choose Terminal App"
    }
  ],
  "dependencies": {
    ...
  },
  "devDependencies": {
   ...
  },
  "scripts": {
    ...
  }
}

commands 数组内是当时扩展所包括的一切指令,其内字段意义能够看数组第一个元素中的注释,装备完结后 Raycast 中即可看到对应的指令了,别忘了在 src 下创立同名进口文件~

猎豹Cheetah插件开发 - Raycast 篇

preferences 数组内是扩展可装备的偏好设置,用户在装备后能够经过 getPreferenceValues API 获取。

调用扩展时设置项的 requiredtrue 且未装备的情况下会展现一个装备面板,提示用户完结装备。

猎豹Cheetah插件开发 - Raycast 篇

引进中心模块

与前面两篇一样,Cheetah for Raycast 也需求引进中心模块。

装置模块

npm install cheetah-core
# or 
yarn add cheetah-core

初始化模块

在运用到项目查找、缓存相关函数时需求提早初始化中心模块,这儿运用了 React 的自定义 Effect 完结初始化:

// useInitCore.ts
// 初始化猎豹中心模块
import { init, HOME_PATH } from "cheetah-core";
import { cachePath } from "../constant";
import { errorHandle, getConfig } from "../utils";
export default async () => {
  const { workspaces } = getConfig();
  if (!workspaces) {
    errorHandle(new Error("103"));
    return;
  }
  init({
    cachePath,
    workspaces: workspaces.replace(/~/gm, HOME_PATH),
  });
};

React 初学者也不知道自定义 Effect 这么用规不标准,请各位老师指教。

功用完结

查找项目并…

下面 5 个指令都用到了查找项目,只是在挑选项目后履行的操作不同,所以将查找相关的逻辑封装了一下,各个进口调用下面的函数,回来一个 List 列表组件用于烘托:

// src/lib/components/searchList.tsx
import { List } from "@raycast/api";
import { COMMAND } from "cheetah-core";
import { useEffect, useState } from "react";
import useInitCore from "../effects/useInitCore";
import useProjectFilter from "../effects/useProjectFilter";
import SearchListItem from "./searchListItem";
export default (command: COMMAND, appPath: string, forced = false) => {
  useInitCore();
  const [searchText, setSearchText] = useState("");
  const [data, loading, filterProject] = useProjectFilter();
  useEffect(() => {
    filterProject(searchText);
  }, [searchText]);
  return (
    <List
      isLoading={loading}
      onSearchTextChange={setSearchText}
      searchBarPlaceholder="Search your project..."
      throttle
      enableFiltering={false}
    >
      <List.Section title="Results" subtitle={data?.length + ""}>
        {data?.map((searchResult, index) => (
          <SearchListItem
            key={searchResult.name + index}
            searchResult={searchResult}
            appPath={appPath}
            forced={forced}
            filterProject={filterProject}
            commandType={command}
          />
        ))}
      </List.Section>
    </List>
  );
};

列表函数中调用了 useProjectFilter 自定义函数,用来查找项目。

// src/lib/effects/useProjectFilter.ts
// 查找与关键词匹配的项目
import { useState } from "react";
import { Project, filterWithSearchResult, filterWithCache } from "cheetah-core";
import { refreshKeyword } from "../constant";
import { ResultItem } from "../types";
import { output } from "../core";
import { environment } from "@raycast/api";
import { errorHandle } from "../utils";
export default (): [
  ResultItem[],
  boolean,
  (keyword: string) => Promise<void>
] => {
  const [resultList, setResultList] = useState<ResultItem[]>([]);
  const [loading, setLoading] = useState(true);
  /**
   * @description: 查找项目
   * @param {string} keyword 用户输入的关键词
   * @return {*}
   */
  async function filterProject(keyword: string): Promise<void> {
    try {
      const needRefresh: boolean = keyword.includes(refreshKeyword);
      const searchKeyword = keyword.replace(refreshKeyword, "");
      setLoading(true);
      let projects: Project[] = await filterWithCache(searchKeyword);
      let fromCache = true;
      // 如果缓存结果为空或者需求改写缓存,则重新查找
      if (!projects.length || needRefresh) {
        projects = await filterWithSearchResult(searchKeyword);
        fromCache = false;
      }
      const result: ResultItem[] = await output(projects);
      if (fromCache) {
        result.push({
          name: "Ignore cache re search",
          description:
            "Ignore the cache and search for items in the working directory again",
          icon: `${environment.assetsPath}/refresh.png`,
          arg: searchKeyword,
          refresh: true,
        });
      }
      setResultList(result);
      setLoading(false);
    } catch (error: unknown) {
      errorHandle(error);
    }
  }
  return [resultList, loading, filterProject];
};

输入框内关键词发生变化后会履行 useProjectFilter 回来的回调函数,触发查找并回来筛选后的项目合集,searchList 依据项目调集循环烘托 searchListItem

猎豹Cheetah插件开发 - Raycast 篇

红框是整个 searchList,蓝框是 searchListItem,组件代码如下:

// src/lib/components/searchListItem.tsx
import { List } from "@raycast/api";
import { COMMAND, HOME_PATH } from "cheetah-core";
import { ResultItem } from "../types";
import Actions from "./actions";
export default ({
  searchResult,
  appPath,
  forced,
  commandType,
  filterProject,
}: {
  searchResult: ResultItem;
  appPath: string;
  forced: boolean;
  commandType: COMMAND;
  filterProject: (keyword: string) => Promise<void>;
}) => {
  const finalAppPath =
    (forced ? appPath : searchResult.idePath || appPath) || "Finder";
  return (
    <List.Item
      title={searchResult.name}
      subtitle={
        searchResult.path?.replace(HOME_PATH, "~") || searchResult.description
      }
      accessoryTitle={searchResult.hits}
      icon={searchResult.icon}
      actions={
        <Actions
          searchResult={searchResult}
          finalAppPath={finalAppPath}
          filterProject={filterProject}
          commandType={commandType}
        />
      }
    />
  );
};

注意看,searchListItem 中还引用了一个 Actions 组件,用于烘托行为面板。

猎豹Cheetah插件开发 - Raycast 篇

运用 Command + K 组合键能够翻开 List.Item 组件关联的行为面板,行为面板中的第一项会出现在上图蓝框位置,直接按回车即可履行。

// src/lib/components/actions.tsx
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { Action, ActionPanel, useNavigation, Icon } from "@raycast/api";
import { COMMAND, updateHits } from "cheetah-core";
import { refreshKeyword } from "../constant";
import { ResultItem } from "../types";
import ApplicationList from "./applicationList";
export default ({
  searchResult,
  finalAppPath,
  filterProject,
  commandType,
}: {
  searchResult: ResultItem;
  finalAppPath: string;
  filterProject: (keyword: string) => Promise<void>;
  commandType: COMMAND;
}) => {
  if (searchResult.refresh) {
    return (
      <ActionPanel>
        <Action
          title="Refresh Cache"
          icon={Icon.Repeat}
          onAction={() => {
            filterProject(`${refreshKeyword}${searchResult.arg}`);
          }}
        />
      </ActionPanel>
    );
  }
  if (commandType === COMMAND.SET_APPLICATION) {
    const { push } = useNavigation();
    return (
      <ActionPanel>
        <Action
          title="Choose Application"
          icon={Icon.Box}
          onAction={() =>
            push(<ApplicationList projectPath={searchResult.path!} />)
          }
        />
      </ActionPanel>
    );
  }
  return (
    <ActionPanel>
      <ActionPanel.Section>
        <Action.Open
          title={`Open in ${finalAppPath}`}
          target={searchResult.path!}
          application={finalAppPath}
          onOpen={async () => {
            await updateHits(searchResult.path!);
          }}
        />
      </ActionPanel.Section>
      <ActionPanel.Section>
        <Action.CopyToClipboard
          title="Copy Project Path"
          content={searchResult.path!}
          shortcut={{ modifiers: ["cmd"], key: "." }}
        />
      </ActionPanel.Section>
    </ActionPanel>
  );
};

Actions 内依据传入的 commandType 履行不同操作,面板中除了为项目设置这个指令外,都添加了复制项目路径的条目,便利用户运用。

Open With Editor

运转此指令将调用查找列表,在回来的项目列表中挑选项目回车,将运用编辑器翻开项目。
终究运用的编辑器优先级为:缓存内项目信息中的 idePath > 缓存内项目类型编辑器 > 偏好设置内挑选的 defaultEditor

进口文件代码如下:

// src/editor.tsx
import { Application } from "@raycast/api";
import { COMMAND } from "cheetah-core";
import searchList from "./lib/components/searchList";
import { errorHandle, getConfig } from "./lib/utils";
const command = COMMAND.OPEN; // 指令类型:编辑器翻开
const forced = false; // 是否强制运用偏好设置内装备的运用
export default () => {
  const { defaultEditor }: { defaultEditor: Application } = getConfig();
  const appPath = defaultEditor?.name ?? "";
  if (!appPath) {
    errorHandle(new Error("112"));
    return;
  }
  return searchList(command, appPath, forced);
};

传入 searchList 的参数意义为:

command:当时指令类型。
appPath:偏好设置内挑选的编辑器运用称号。
forced:是否强制运用偏好设置内装备的运用。

下图红框处会展现终究将运用什么运用翻开项目,当默许编辑器未装备时将依照错误代码 112 处理,错误处理后续具体说明。

猎豹Cheetah插件开发 - Raycast 篇

Action.Open 是 Raycast 供给的特别组件,能够直接运用指定运用翻开方针文件、文件夹。

// src/lib/components/actions.tsx 节选

<Action.Open
  title={`Open in ${finalAppPath}`}
  target={searchResult.path!}
  application={finalAppPath}
  onOpen={async () => {
    await updateHits(searchResult.path!);
  }}
/>

onOpenAction 回车履行后的回调,在此处调用 updateHits 即可完结对项目点击量的更新。

Open With Git GUI

此指令基本上与运用编辑器翻开的功用一致,履行后将运用偏好设置内装备的 Git GUI 运用翻开项目。

// src/git.tsx
import { Application } from "@raycast/api";
import { COMMAND } from "cheetah-core";
import searchList from "./lib/components/searchList";
import { errorHandle, getConfig } from "./lib/utils";
const command = COMMAND.GIT_GUI_OPEN; // 指令类型:Git GUI翻开
const forced = true; // 是否强制运用偏好设置内装备的运用
export default () => {
  const { defaultGitApp }: { defaultGitApp: Application } = getConfig();
  const appPath = defaultGitApp?.name ?? "";
  if (!appPath) {
    errorHandle(new Error("113"));
    return;
  }
  return searchList(command, appPath, forced);
};

appPathforced 的值有所不同,错误代码为 113

猎豹Cheetah插件开发 - Raycast 篇

Open With Terminal

此指令也类似,履行后将运用偏好设置内装备的终端运用翻开项目。

// src/terminal.tsx
import { Application } from "@raycast/api";
import { COMMAND } from "cheetah-core";
import searchList from "./lib/components/searchList";
import { errorHandle, getConfig } from "./lib/utils";
const command = COMMAND.TERMINAL_OPEN; // 指令类型:终端翻开
const forced = true; // 是否强制运用偏好设置内装备的运用
export default () => {
  const { defaultTerminalApp }: { defaultTerminalApp: Application } =
    getConfig();
  const appPath = defaultTerminalApp?.name ?? "";
  if (!appPath) {
    errorHandle(new Error("114"));
    return;
  }
  return searchList(command, appPath, forced);
};

appPathforced 的值有所不同,错误代码为 114

Open With Finder

运转此指令将运用 Finder 翻开项目。

// src/finder.tsx
import { COMMAND } from "cheetah-core";
import searchList from "./lib/components/searchList";
const command = COMMAND.FOLDER_OPEN; // 指令类型:Finder 翻开
const forced = true; // 是否强制运用偏好设置内装备的运用
export default () => {
  return searchList(command, "Finder", forced);
};

Choose Application

运转此指令挑选项目今后回车将烘托当时体系中装置的运用列表,挑选运用后将挑选的运用写入缓存中对应项目的 idePath 中。

经过 Raycast 供给的 API getApplications 能够获得当时体系中装置的运用合集,添加自定义 Effect 如下:

// src/lib/effects/useGetApplicationList.ts
// 获取体系已装置的运用列表
import { Application, getApplications } from "@raycast/api";
import { useState } from "react";
export default (): [Application[], boolean, () => Promise<void>] => {
  const [resultList, setResultList] = useState<Application[]>([]);
  const [loading, setLoading] = useState(true);
  async function getApplicationList() {
    setLoading(true);
    const applications: Application[] = await getApplications();
    setResultList(applications);
    setLoading(false);
  }
  return [resultList, loading, getApplicationList];
};

获取到运用列表后再经过 List 烘托,添加 applicationList 组件:

// src/lib/components/applicationList.tsx
import {
  ActionPanel,
  Action,
  List,
  Application,
  open,
  showHUD,
} from "@raycast/api";
import { setProjectApp } from "cheetah-core";
import { useEffect } from "react";
import useGetApplicationList from "../effects/useGetApplicationList";
export default ({ projectPath }: { projectPath: string }) => {
  const [applicationList, isLoading, getApplicationList] =
    useGetApplicationList();
  useEffect(() => {
    getApplicationList();
  }, []);
  return (
    <List
      isLoading={isLoading}
      searchBarPlaceholder="Search application name..."
      throttle
    >
      <List.Section title="Results" subtitle={applicationList?.length + ""}>
        {applicationList?.map((searchResult: Application) => (
          <SearchListItem
            key={searchResult.name}
            searchResult={searchResult}
            projectPath={projectPath}
          />
        ))}
      </List.Section>
    </List>
  );
};
function SearchListItem({
  searchResult,
  projectPath,
}: {
  searchResult: Application;
  projectPath: string;
}) {
  return (
    <List.Item
      title={searchResult.name}
      subtitle={searchResult.path}
      accessoryTitle={searchResult.bundleId}
      actions={
        <ActionPanel>
          <ActionPanel.Section>
            <Action
              title="Choose and Complete"
              onAction={async () => {
                await setProjectApp(projectPath, searchResult.name);
                await open(projectPath, searchResult.name);
                await showHUD("The application is set up and tries to open");
              }}
            />
          </ActionPanel.Section>
        </ActionPanel>
      }
    />
  );
}

searchListItemActions 组件中判断如果 command 类型为挑选运用,则经过 Raycast 中页面栈操作 API useNavigation 将 applicationList 烘托出来。

// src/lib/components/actions.tsx 节选
...
const { push } = useNavigation();
...
<ActionPanel>
  <Action
    title="Choose Application"
    icon={Icon.Box}
    onAction={() =>
      push(<ApplicationList projectPath={searchResult.path!} />)
    }
  />
</ActionPanel>
...

下图为运用列表,挑选运用后回车即可为项目设置运用。

猎豹Cheetah插件开发 - Raycast 篇

Edit Cache File

此指令为无视图指令,履行后将运用偏好设置中装备的默许编辑器翻开装备文件。

// src/editCache.ts
import { open } from "@raycast/api";
import { errorHandle, getConfig } from "./lib/utils";
import { cachePath } from "./lib/constant";
export default async () => {
  const { defaultEditor } = getConfig();
  const appPath = defaultEditor?.name ?? "";
  if (!appPath) {
    errorHandle(new Error("112"));
    return;
  }
  await open(cachePath, appPath);
};

除了 Action.Open 外,Raycast 还为开发者供给了运用指定运用翻开文件、文件夹的 API open,只需求传入方针文件、文件夹称号,运用称号或绝对路径即可。

Clear Cache

此指令也是无视图指令,履行后将删除缓存文件,由中心模块供给支持。

// src/clearCache.ts
import { confirmAlert, showHUD } from "@raycast/api";
import { clearCache } from "cheetah-core";
import { errorHandle } from "./lib/utils";
import useInitCore from "./lib/effects/useInitCore";
export default async () => {
  try {
    if (
      await confirmAlert({
        title: "Sure to clear the cache?",
        message:
          "After clearing the cache, the type application configuration, the project application configuration, and the number of hits will all disappear.",
      })
    ) {
      useInitCore();
      await clearCache();
      await showHUD("Cache cleared");
    }
  } catch (error) {
    errorHandle(error);
  }
};

删除缓存是个十分有破坏性的操作,所以加了二次确认,运用了 Raycast 供给的 confirmAlert API。

猎豹Cheetah插件开发 - Raycast 篇

错误处理

上面的指令中许多当地都运用了 errorHandle 函数,它的作用是依据代码运转中抛出的错误代码处理错误信息。

/**
 * @description: 错误处理并输出
 * @param {any} error
 * @return {*}
 */
export async function errorHandle(error: any): Promise<void> {
  const errorCode: string = error.message;
  const needHandleCodes = ["112", "113", "114", "103"];
  await showHUD(ErrorCodeMessage[errorCode]);
  popToRoot({ clearSearchBar: true });
  if (needHandleCodes.includes(errorCode)) {
    openExtensionPreferences();
  }
}

在错误代码为 103112113114 时表明偏好装备不完整,将跳转到扩展的偏好设置界面,便利用户进行装备。
虽然在中心模块中有定义过错误代码对应的报错信息,可是 Raycast 要求扩展最好运用单一语言,美式英语最佳,所以机翻了一份错误信息:

// 错误代码对应的文字提示
export const ErrorCodeMessage: { [code: string]: string } = {
  "100": "File read failure",
  "101": "File write failure",
  "102": "File deletion failed",
  "103": "Working directory not configured",
  "104": "Cache file path not configured",
  "105": "System platform not configured",
  "106": "Environment variable read failure",
  "107": "Cache file write failure",
  "108": "Failed to read folder",
  "109": "Unknown terminal program, downgraded to folder open",
  "110": "No such item in the cache",
  "111": "Application path is empty",
  "112": "Please configure the default editor first",
  "113": "Please configure the Git GUI application first",
  "114": "Please configure the terminal application first",
};

发布审核

完结功用开发后,需求将扩展发布到运用商场。

自检

发布前,先运用 Raycast 扩展项目中装备好的脚本指令进行检查。

// package.json
{
 ...
 "scripts": {
    "build": "ray build -e dist",
    "dev": "ray develop",
    "fix-lint": "ray lint --fix",
    "lint": "ray lint",
    "publish": "ray publish"
  }
  ...
}
// 终端运转
yarn fix-lint

猎豹Cheetah插件开发 - Raycast 篇

履行结果悉数为 ready 时,发布前准备工作就算完结了

Pull Request

与 uTools 的发布模式不同,Raycast 运用 git 库房办理扩展商场,开发者先 fork 库房,将开发好的扩展添加到库房内再建议 Pull Request,建议后 git 将运转 Checks,两项检查经过将符号此 Pull Request 为 new extension

具体的发布流程能够查阅官方文档。

猎豹Cheetah插件开发 - Raycast 篇

猎豹Cheetah插件开发 - Raycast 篇

之后审核员将进行 Code Review,指出扩展的问题,修复完后审核员会将代码兼并,兼并后即可在 Raycast Store 中查找扩展了。

tips:审核时间很长,需求耐性等待,Cheetah 扩展提审到上架大概花费了 1 周时间。

小结

U1S1,Raycast 的开发体会比 uTools 好了不少,可是审核的流程略拖沓,没有 uTools 直接艾特审核员这么便利(手动狗头)。

本篇为本系列正文的结束篇,希望看官们能够下载 Cheetah 试试效果,万一能进步一点点功率呢~

后续可能还有番外篇,想到了一个玩具项目,Cheetah for VSCode,是不是有点套娃的意思了,哈哈哈哈哈。

很感谢我们能够看到这儿,有缘再会~