2026年3月

不想结婚,原生家庭有影响。自己认为不能给配偶和子女很好的条件和情感支持就不应该结婚。

比自己过的不好更令人痛苦的就是眼睁睁看着家人过的不好又无能为力。我畏惧这种痛苦,经济条件又确实不够,所以我拒绝结婚。

但是我发现自己确实很想谈恋爱,每次刷到情侣互动的视频都会看的很起劲,想象生活中有一个人可以彼此互相关心是多么幸福的事儿。

人应该对自己的伴侣坦诚,可我要如何说呢?我就想谈恋爱不想结婚。就算伴侣能接受,她的家人能接受么?人人都觉得养孩子支出很大,可人人结婚后最快提上日程的事儿就是生一个孩子。把孩子当做维系婚姻关系的纽带。如果没有孩子,有多少夫妻会选择离婚,有多少夫妻会选择继续?

那么,一个不结婚的恋爱关系又能有什么保证呢?女人付出自己的青春,男方却随时可以没有束缚的分手,这公平么?

可是两个人在一起非要有束缚做保证的话,又有什么比结婚生子更适合的呢?

就算有其它的束缚,可两个人在一起竟然要靠束缚来保证,这还能叫幸福么?人为什么要维系一段不幸福的关系?

我外星人笔记本自己拆了不好换,这个型号是倒装主板得全部拆下来才能看到 CPU 、GPU 位置,个人搞不定,把壳拆下来螺丝都还原不回去了一个哈哈。小红书上一个海淀的换液金散热要四五百,但是也有说液金是导电的,要是外部震动、掉落,液金从散热器边缘溢出,滴到主板元器件上,瞬间短路,主板直接报废的可能性大。
所以现在为了压住使用了几年的外星人的风扇声音大,起步温度都是 95 度以上了,有以下方案:
核心散热区域( CPU & GPU ):使用 霍尼韦尔 PTM7950 相变导热片。
显存与供电区域用莱尔德导热泥代替原厂硅胶垫。

所以普通电脑店估计一样搞不好外星人这种倒装主板的散热硅脂替换方案吧,另外外星人自己专利的 31 号元素散热外面没有正宗的吧。

有没有谁认识或是外星人笔记本的换过的靠谱店家推荐下啊,双休把本子拿过去搞下~

手里有张去泰国时办的 DTAC 卡一张,一直在 TOP Up 保号。上个月时间有段长竟然过了有效期了。赶紧通过 Google Pay 又交了两次 20THB ,有效期延长了 2 个月。

但 DTAC 这张卡我感觉被 Line 加黑名单了,删号,过两月后再注册还是黑名单,这不能干那不能干。所以打算再申个号。正好年前买了张 esim 白卡,前后时间站里不是有个展会 7 天免费的 esim 卡。Esim 成功写号,激活。所以考虑再弄张泰国的 esim 卡。

因为有张 DTAC 的卡了,查着 AIS 的 SIM2FLY 的 esim 可以海外激活。一开始也是奔着 AIS 的 ESIM 申的,只是很可惜,最后付款阶段,VISA,MASTERCARD,银联信用卡均失败。另外可行的付款方式是 Line Pay (那是没戏了),还有就是泰国的二维码付款 PROMPT PAY. 另外 MYAIS APP 登录需要手机网络,最好是泰国的,不能开 vpn ,不能 USD debug 。

AIS (类似泰国的移动)不行,那就换个方向 True-DTAC 毕竟用户数第一,自我安慰一下。
网址: https://www.true.th/en/international/roaming/go-travel-sim
然后选 399THB 的 Asia & Australia 就好了。https://www.true.th/store/online-store/item/L91775175?product_id=L91775175&matcode=3000106113&nas_code=0106552ATR
如果网页不是英文的右上角点击 ENG 换成英文。

选 ESIM, BUY NOW => 就可以选号了,左边 Favorite numbers ,点你喜欢的号码数字(复选),右边 Dislike numbers 不喜欢的数字,Sum of numbers 不用管。

选好后进入下一页,身份信息选 PASSPORT 护照,注意生日那是佛历年份,自己转换下正确的佛历年份。

用户信息界面,省,区,分区,可以找个泰国地址生成器生成,最好记住这个信息,激活 ESIM 卡时还要填,尽量一致,不一致好像也能过。移动号码,可以随机生成一个,我用的 DTAC 的号码。

到了付款这里了,一开始选了 Credit Card(no fee),Visa ,MASTERCARD 都显示 INVALID PAYMENT.苦恼呀
其他的选项 True Money Wallet,因为必须要泰国手机号注册,不能 vPN(印象中是),不能开辅助模式,要泰国网络(用的 DTAC 卡开的漫游套餐)。下午护照人脸认证,说是 8 WORKING HOURS 给短信,今天是没等到。
还有个 PROMPT PAY ,查了下只有护照没戏,说是 THAITAG 可以给旅游人群用,使用起来也很烦,放弃。既然是二维码扫码付款是不是很熟悉?!弄 True Money Wallet 时,APP 显示可以扫 PROMPT PAY 的,下面合作伙伴还有 ALIPAY+. 搜了下 ALIPAY 和 PROMPT PAY 真的有合作。

好了,订单删除,要不更改不了付款选项。重新下订单。选 PROMPT PAY ,输入姓名,邮箱,生成二维码。打开支付宝扫码,扫码成功,付款成功。一切顺利。

然后邮箱 TRUEMOVE H 的两封关于付款,发票的邮件。

然后 NOREPLY 又发了 KYC 的邮件,给了个链接 https://iservice.onelink.me / chrome 打开链接拍护照,人脸认证

接下来就是 ESIM 激活的另一封 NORELAY 邮件。eSIM 0658898866 Serial 896601111111111111 https://iwsheet.truecorp.co.th/esimqr/en/home/index 链接地址会再次填写用户信息,前面说的省,区,分区还要再填一遍。生成激活二维码

至此,插入白卡,写卡成功。

P.S. 喜欢 AIS 卡的也可以去试试用 PROMPT PAY 付款。但我申 AIS 卡的时候,PROMPT PAY 没有生成二维码,直接显示超时了。

后期充值保活就行了。应该可以用 DTAC 或者 TRUE APP 充值。DTAC 可以直接调用 GOOGLE PAY. TRUE 应该也可以,毕竟一个公司开发的。不买套餐的话,每个月费用就是 0.

之前一直在 Windows 上用 SecureCRT 管服务器,用了很多年。后来工作环境基本换成 macOS 了,才发现好像一直没找到特别顺手的替代。

试过 Termius 、Tabby 这些,也不是不能用,但总感觉差点意思。平时 ssh 、rdp 会来回用,服务器多了之后管理起来也有点乱。

刚好那段时间在学 Flutter ,后来索性自己写了个小工具自己用,现在日常连服务器基本都在用它。

不知道大家在 macOS 上一般都用什么 ssh 客户端?

原文地址:https://feinterview.poetries.top/blog/nextjs-tradingview-inte...

导语

TradingView 是全球最专业的金融图表可视化库之一,提供了功能强大的 K 线图、指标系统和技术分析工具。在金融行情类 Web 应用中,接入 TradingView 是提升用户体验的首选方案。

本文将基于实际项目代码,系统讲解如何在 Next.js 项目中接入 TradingView Charts,包括环境配置、Datafeed 数据馈送实现、自定义指标开发、主题样式定制、以及关键的性能优化策略。

一、项目准备与环境配置

1.1 获取 TradingView 图表库

TradingView 图表库需要从官方获取授权后下载。获取后将文件放置在项目的 public/static/charting_library 目录下:

public/
  └── static/
      └── charting_library/
          ├── charting_library.standalone.js
          └── bundles/
              ├── *.js
              └── *.css

1.2 组件目录结构

src/components/Tradingview/
├── index.tsx              # 主组件
├── datafeed.ts            # 数据馈送实现
├── widgetOpts.tsx         # 图表配置选项
├── widgetMethods.ts       # 图表方法工具
├── theme.ts               # 主题配置
├── constant.ts           # 常量定义
└── customIndicators/      # 自定义指标
    ├── ma.ts
    ├── macd.ts
    ├── kdj.ts
    └── customerRSI.ts

二、核心组件实现

2.1 主组件:TradingView 图表容器

// src/components/Tradingview/index.tsx
import { useEffect, useRef, useState } from 'react'
import { widget } from 'public/static/charting_library'
import { useStores } from '@/context/mobxProvider'
import { STORAGE_GET_CHART_PROPS, STORAGE_REMOVE_CHART_PROPS, ThemeConst } from './constant'
import { ColorType, applyOverrides, createWatermarkLogo, setCSSCustomProperty, setChartStyleProperties } from './widgetMethods'
import getWidgetOpts from './widgetOpts'
import { useConfig } from '@/context/configProvider'
import { useRouter } from 'next/router'
import stores from '@/stores'
import { observer } from 'mobx-react'
import { STORAGE_SET_TRADINGVIEW_RESOLUTION } from '@/utils/storage'


const Tradingview = () => {
  const chartContainerRef = useRef<HTMLDivElement>()
  const { ws } = useStores()
  const { isMobile, isPc } = useConfig()
  const router = useRouter()
  const [isChartLoading, setIsChartLoading] = useState(true)
  const [loading, setLoading] = useState(true)


  const query = {
    ...router.query,
    ...getInjectParams()
  } as any


  const datafeedParams = {
    setActiveSymbolInfo: ws.setActiveSymbolInfo,
    removeActiveSymbol: ws.removeActiveSymbol,
    getDataFeedBarCallback: ws.getDataFeedBarCallback,
    dataSourceCode: query.dataSourceCode
  }


  const params = {
    symbol: (query.symbolName || 'BTCUSDT') as string,
    locale: (query.locale || 'en') as LanguageCode,
    theme: (query.theme || 'light') as ThemeName,
    colorType: Number(query.colorType || 1) as ColorType,
    isMobile,
    bgGradientStartColor: query.bgGradientStartColor ? `#${query.bgGradientStartColor}` : '',
    bgGradientEndColor: query.bgGradientEndColor ? `#${query.bgGradientEndColor}` : ''
  }


  useEffect(() => {
    console.log('Tradingview组件初始化')
    const showBottomMACD = Number(query.showBottomMACD || 1)
    const chartType = (query.chartType !== '' ? Number(query.chartType || 1) : 1) as ChartStyle
    const theme = params.theme


    // 切换主题时清除本地缓存,避免颜色闪烁
    const defaultBgColor = theme === 'dark' ? ThemeConst.black : ThemeConst.white
    if (theme && defaultBgColor !== STORAGE_GET_CHART_PROPS('paneProperties.background')) {
      STORAGE_REMOVE_CHART_PROPS()
    }


    const widgetOptions = getWidgetOpts(params, chartContainerRef.current, datafeedParams)
    const tvWidget = new widget(widgetOptions)


    setTimeout(() => {
      setLoading(false)
    }, 200)


    tvWidget.onChartReady(async () => {
      setIsChartLoading(false)


      // 动态设置 CSS 变量
      setCSSCustomProperty({ tvWidget, theme })


      // 监听时间周期变化
      tvWidget
        .activeChart()
        .onIntervalChanged()
        .subscribe(null, (interval, timeframeObj) => {
          // 记录当前分辨率
          STORAGE_SET_TRADINGVIEW_RESOLUTION(interval)


          // 日周月级别使用 UTC 时区,分钟级别使用上海时区
          if (['D', 'W', 'M', 'Y'].some((item) => interval.endsWith(item))) {
            tvWidget.activeChart().getTimezoneApi().setTimezone('Etc/UTC')
          } else {
            tvWidget.activeChart().getTimezoneApi().setTimezone('Asia/Shanghai')
          }


          ws.activeSymbolInfo.onResetCacheNeededCallback?.()
          setTimeout(() => {
            tvWidget.activeChart().resetData()
          }, 100)
        })


      // 默认显示 MACD 指标
      if (showBottomMACD === 1) {
        tvWidget.activeChart().createStudy(
          'MACD',
          false,
          false,
          { in_0: 12, in_1: 26, in_3: 'close', in_2: 9 },
          {
            'Histogram.color.3': 'rgba(197, 71, 71, 0.7188)',
            showLabelsOnPriceScale: !!isPc
          }
        )
      }


      // 创建自定义 MA 指标
      tvWidget.activeChart().createStudy('Customer Moving Average', false, false, {}, { showLabelsOnPriceScale: false })


      // 动态切换主题
      if (query.theme && !params.bgGradientStartColor) {
        await tvWidget.changeTheme(theme)
      }


      // 设置 K 线柱样式(绿涨红跌 / 红涨绿跌)
      setChartStyleProperties({ colorType: params.colorType, tvWidget })


      // 应用覆盖样式
      applyOverrides({
        tvWidget,
        chartType,
        bgGradientStartColor: params.bgGradientStartColor,
        bgGradientEndColor: params.bgGradientEndColor
      })


      // 添加水印 Logo
      if (query.hideWatermarkLogo !== '0' && query.watermarkLogoUrl) {
        createWatermarkLogo(query.watermarkLogoUrl)
      }


      // 记录实例
      ws.setTvWidget(tvWidget)
      window.tvWidget = tvWidget
    })


    return () => {
      tvWidget.remove()
      mitt.off('symbol_change')
    }
  }, [router.query])


  return (
    <div style={{ position: 'relative' }}>
      <div id="tradingview" ref={chartContainerRef} style={{ height: 'calc(100vh - 60px)', opacity: loading ? 0 : 1 }} />
      {isChartLoading && (
        <div className="loading-container">
          <div className="loading"></div>
        </div>
      )}
    </div>
  )
}


export default observer(Tradingview)

2.2 Datafeed 数据馈送实现

Datafeed 是 TradingView 与后端数据交互的核心接口,需要实现以下方法:

// src/components/Tradingview/datafeed.ts
class DataFeedBase {
  configuration: DatafeedConfiguration


  constructor(props: Partial<ChartingLibraryWidgetOptions>) {
    this.configuration = {
      supports_time: true,
      supports_timescale_marks: true,
      supports_marks: true,
      // 支持的分辨率
      supported_resolutions: ['1', '5', '15', '30', '60', '240', '1D', '1W', '1M'],
      intraday_multipliers: ['1', '5', '15', '30', '60', '240', '1D', '1W', '1M']
    } as DatafeedConfiguration


    this.setActiveSymbolInfo = props.setActiveSymbolInfo
    this.removeActiveSymbol = props.removeActiveSymbol
    this.getDataFeedBarCallback = props.getDataFeedBarCallback
    this.isZh = props.locale === 'zh_TW'
  }


  // 图表初始化时调用,设置支持的配置
  onReady(callback) {
    setTimeout(() => {
      callback(this.configuration)
    }, 0)
  }


  // 解析品种信息
  async resolveSymbol(symbolName, onSymbolResolvedCallback, onResolveErrorCallback, extension) {
    const resolution = String(STORAGE_GET_TRADINGVIEW_RESOLUTION() || '')
    const ENV = getEnv()
    const urlPrefix = ENV.isApp ? getInjectParams().baseUrl : ''


    let symbolInfo
    if (!ENV.isApp) {
      // HTTP 请求获取品种信息
      const res = await request(`${urlPrefix}/api/trade-core/coreApi/symbols/symbol/detail?symbol=${symbolName}`)
      symbolInfo = res?.data || {}
    } else {
      // APP 内获取 RN 传递的数据
      symbolInfo = {
        ...(ENV?.injectParams?.symbolInfo || {}),
        ...(stores.global.symbolInfo || {})
      }
    }


    const currentSymbol = {
      ...symbolInfo,
      precision: symbolInfo?.symbolDecimal || 2,
      description: symbolInfo?.remark || '',
      exchange: '',
      session: '24x7',
      name: symbolInfo.symbol,
      dataSourceCode: symbolInfo.dataSourceCode
    }


    const commonSymbolInfo = {
      has_intraday: true,
      has_daily: true,
      has_weekly_and_monthly: true,
      intraday_multipliers: this.configuration.intraday_multipliers,
      supported_resolutions: this.configuration.supported_resolutions,
      data_status: 'streaming',
      format: 'price',
      minmov: 1,
      pricescale: Math.pow(10, currentSymbol.precision),
      ticker: currentSymbol?.name
    } as LibrarySymbolInfo


    const currentSymbolInfo = {
      ...commonSymbolInfo,
      ...currentSymbol,
      description: this.isZh ? currentSymbol.description : currentSymbol?.name,
      exchange: this.isZh ? currentSymbol?.exchange : '',
      session: '0000-0000|0000-0000:1234567;1',
      timezone: ['D', 'W', 'M', 'Y'].some((item) => resolution.endsWith(item)) ? 'Etc/UTC' : 'Asia/Shanghai'
    } as LibrarySymbolInfo


    setTimeout(() => {
      onSymbolResolvedCallback(currentSymbolInfo)
    }, 0)
  }


  // 搜索品种
  searchSymbols(userInput, exchange, symbolType, onResultReadyCallback) {
    const keyword = userInput || ''
    const resultArr = symbolInfoArr
      .filter((item) => item.name.includes(keyword))
      .map((item) => ({
        symbol: item.name,
        name: item.name,
        full_name: `${item.name}`,
        description: this.isZh ? item.description : item.name,
        exchange: this.isZh ? item.exchange : '',
        type: item.type,
        ticker: item.name
      }))


    setTimeout(() => {
      onResultReadyCallback(resultArr)
    }, 0)
  }


  // 获取 K 线历史数据(核心方法)
  getBars(symbolInfo, resolution, periodParams, onHistoryCallback, onErrorCallback) {
    const { from, to, firstDataRequest, countBack } = periodParams
    this.setActiveSymbolInfo({ symbolInfo, resolution })
    this.getDataFeedBarCallback({
      symbolInfo,
      resolution,
      from,
      to,
      countBack,
      onHistoryCallback,
      onErrorCallback,
      firstDataRequest
    })
  }


  // 订阅实时数据更新
  subscribeBars(symbolInfo, resolution, onRealtimeCallback, subscriberUID, onResetCacheNeededCallback) {
    this.setActiveSymbolInfo({
      symbolInfo,
      resolution,
      onRealtimeCallback,
      subscriberUID,
      onResetCacheNeededCallback
    })
    mitt.on('symbol_change', () => {
      onResetCacheNeededCallback()
    })
  }


  // 取消订阅
  unsubscribeBars(subscriberUID) {
    this.removeActiveSymbol(subscriberUID)
  }
}


export default DataFeedBase

2.3 图表配置选项

// src/components/Tradingview/widgetOpts.tsx
import ma from './customIndicators/ma'


export default function getWidgetOpts(props, containerRef: any, datafeedParams: any): ChartingLibraryWidgetOptions {
  const ENV = getEnv()
  const theme = props.theme
  const bgColor = theme === 'dark' ? ThemeConst.black : ThemeConst.white
  const toolbar_bg = theme === 'dark' ? ThemeConst.black : '#fff'


  // 禁用的功能
  const disabled_features: ChartingLibraryFeatureset[] = [
    'header_compare',
    'symbol_search_hot_key',
    'study_templates',
    'header_saveload',
    'save_shortcut',
    'header_undo_redo',
    'symbol_info',
    'timeframes_toolbar',
    'scales_date_format',
    'header_fullscreen_button',
    'display_market_status'
  ]


  // 移动端额外禁用
  if (props.isMobile) {
    disabled_features.push(
      'header_symbol_search',
      'context_menus',
      'show_chart_property_page',
      'header_screenshot',
      'adaptive_logo',
      'left_toolbar'
    )
  }


  const widgetOptions: ChartingLibraryWidgetOptions = {
    fullscreen: true,
    autosize: true,
    timezone: 'exchange',
    library_path: `${ENV.isApp ? '.' : ''}/static/charting_library/`,
    datafeed: new DataFeedBase(datafeedParams),
    symbol: props.symbol,
    client_id: 'tradingview.com',
    user_id: 'public_user_id',
    locale: props.locale as LanguageCode,
    interval: isPC() ? '15' : '1',
    theme,
    toolbar_bg,
    container: containerRef,
    symbol_search_request_delay: 1000,
    auto_save_delay: 5,
    study_count_limit: 5,
    allow_symbol_change: true,
    overrides: {
      'paneProperties.background': `${bgColor}`
    },
    disabled_features,
    enabled_features: ['hide_resolution_in_legend', 'display_legend_on_all_charts'],
    custom_css_url: ENV.isApp ? `./styles/index.css` : `/static/styles/index.css`,
    favorites: {
      intervals: ['1', '5', '15', '30', '60']
    },
    custom_indicators_getter: function (PineJS) {
      return Promise.resolve([ma(PineJS)])
    },
    loading_screen: {
      backgroundColor: 'transparent',
      foregroundColor: 'transparent'
    }
  }


  return widgetOptions
}

三、K线数据与WebSocket实时更新

3.1 WebSocket Store 实现

// src/stores/ws.ts
class WsStore {
  tvWidget = null
  @observable lastbar = {}
  @observable activeSymbolInfo = {}


  // HTTP 获取历史 K 线数据
  getHttpHistoryBars = async (symbolInfo, resolution, from, to, countBack, firstDataRequest) => {
    const klineType =
      {
        1: '1min',
        5: '5min',
        15: '15min',
        30: '30min',
        60: '60min',
        240: '4hour',
        '1D': '1day',
        '1W': '1week',
        '1M': '1mon'
      }[resolution] || '1min'


    const res = await request.get(`${url}/api/trade-market/marketApi/kline/symbol/klineList`, {
      params: {
        symbol: symbolInfo.symbol,
        first: firstDataRequest,
        current: 1,
        size: document.documentElement.clientWidth >= 1200 ? 500 : 200,
        klineType,
        klineTime: to * 1000
      }
    })


    const list = res?.data || []
    return list
      .map((item) => {
        const [klineTime, open, high, low, close] = (item || '').split(',')
        return {
          open: Number(open),
          close: Number(close),
          high: Number(high),
          low: Number(low),
          time: resolution.includes('M') ? Number(klineTime) + 8 * 60 * 60 * 1000 : Number(klineTime)
        }
      })
      .reverse()
  }


  // 更新最后一条 K 线
  updateBar = (socketData, currentSymbol) => {
    const precision = currentSymbol.precision
    const lastBar = this.lastbar
    const resolution = currentSymbol.resolution
    const serverTime = socketData?.priceData?.id / 1000
    const bid = socketData?.priceData?.buy


    let rounded = serverTime
    if (!isNaN(resolution) || resolution.includes('D')) {
      const coeff = (resolution.includes('D') ? 1440 : Number(resolution)) * 60
      rounded = Math.floor(serverTime / coeff) * coeff
    }


    const lastBarSec = lastBar?.time / 1000


    if (rounded > lastBarSec) {
      // 新建 K 线
      return {
        time: rounded * 1000,
        open: Number(bid),
        high: Number(bid),
        low: Number(bid),
        close: Number(bid)
      }
    } else {
      // 更新当前 K 线
      return {
        time: lastBar.time,
        open: lastBar.open,
        high: Math.max(lastBar.high, Number(bid)),
        low: Math.min(lastBar.low, Number(bid)),
        close: Number(bid)
      }
    }
  }


  // 处理 WebSocket 消息
  @action
  message(res) {
    if (res?.header?.msgId === 'symbol') {
      const quoteBody = this.parseQuoteBodyData(res?.body)
      if (quoteBody?.symbol === this.activeSymbolInfo?.symbolInfo?.name) {
        const newLastBar = this.updateBar(quoteBody, {
          resolution: this.activeSymbolInfo.resolution,
          precision: this.activeSymbolInfo.symbolInfo.precision,
          symbolInfo: this.activeSymbolInfo.symbolInfo
        })
        if (newLastBar) {
          this.activeSymbolInfo.onRealtimeCallback?.(newLastBar)
          this.lastbar = newLastBar
        }
      }
    }
  }


  // Datafeed 回调
  @action
  getDataFeedBarCallback = (obj = {}) => {
    const { symbolInfo, resolution, firstDataRequest, from, to, countBack, onHistoryCallback } = obj
    this.getHttpHistoryBars(symbolInfo, resolution, from, to, countBack, firstDataRequest).then((bars) => {
      if (bars?.length) {
        onHistoryCallback(bars, { noData: false })
        this.lastbar = bars.at(-1)
      } else {
        onHistoryCallback(bars, { noData: true })
      }
    })
  }
}


export default wsStore

四、自定义指标开发

4.1 自定义 MA 指标示例

// src/components/Tradingview/customIndicators/ma.ts
const customerMovingAverage = (PineJS: PineJS) => {
  const indicators: CustomIndicator = {
    name: 'Customer Moving Average',
    metainfo: {
      _metainfoVersion: 51,
      id: 'Customer Moving Average@tv-basicstudies-1',
      name: 'Customer Moving Average',
      description: 'Customer Moving Average',
      shortDescription: 'MA',
      is_price_study: true,
      isCustomIndicator: true,
      format: { type: 'price' },
      defaults: {
        styles: {
          plot_0: { linestyle: 0, linewidth: 1, plottype: 0, trackPrice: false, transparency: 35, visible: true, color: '#FF0000' },
          plot_1: { linestyle: 0, linewidth: 1, plottype: 0, trackPrice: false, transparency: 35, visible: true, color: '#00FF00' },
          plot_2: { linestyle: 0, linewidth: 1, plottype: 0, trackPrice: false, transparency: 35, visible: true, color: '#00FFFF' }
        },
        inputs: { in_0: 5, in_1: 10, in_2: 30 },
        precision: 4
      },
      plots: [
        { id: 'plot_0', type: 'line' },
        { id: 'plot_1', type: 'line' },
        { id: 'plot_2', type: 'line' }
      ],
      inputs: [
        { id: 'in_0', name: 'Length', defval: 9, type: 'integer', min: 1, max: 1e4 },
        { id: 'in_1', name: 'Length1', defval: 10, type: 'integer', min: 1, max: 1e4 },
        { id: 'in_2', name: 'Length2', defval: 30, type: 'integer', min: 1, max: 1e4 }
      ]
    },
    constructor: function (this: LibraryPineStudy<IPineStudyResult>) {
      this.main = function (context, inputCallback) {
        const close = PineJS.Std.close(context)
        const len1 = inputCallback(0)
        const len2 = inputCallback(1)
        const len3 = inputCallback(2)


        const value1 = PineJS.Std.sma(close, len1, context)
        const value2 = PineJS.Std.sma(close, len2, context)
        const value3 = PineJS.Std.sma(close, len3, context)


        return [
          { value: value1, offset: 0 },
          { value: value2, offset: 0 },
          { value: value3, offset: 0 }
        ]
      }
    }
  }
  return indicators
}


export default customerMovingAverage

五、主题与样式定制

5.1 主题配置

// src/components/Tradingview/theme.ts
export const getTradingviewThemeCssVar = (theme: ThemeName) => {
  const primary = ThemeConst.primary
  const textPrimary = ThemeConst.textPrimary
  const isDark = theme === 'dark'


  return {
    '--tv-color-toolbar-button-text': '#7B7E80',
    '--tv-color-toolbar-button-text-active': textPrimary,
    '--tv-color-toolbar-button-text-active-hover': textPrimary,
    '--tv-color-toolbar-toggle-button-background-active': primary,
    '--tv-color-toolbar-toggle-button-background-active-hover': primary,
    '--tv-color-popup-element-text-active': '#131722',
    '--tv-color-popup-element-background-active': '#f0f3fa',
    ...(isDark ? { '--tv-color-pane-background': ThemeConst.black } : {})
  }
}

5.2 K线颜色与涨跌色设置

// src/components/Tradingview/widgetMethods.ts
export type ColorType = 1 | 2 // 1绿涨红跌 2红涨绿跌


export function setChartStyleProperties(props: { colorType: ColorType; tvWidget: IChartingLibraryWidget }) {
  const { colorType, tvWidget } = props
  const red = ThemeConst.red // #C54747
  const green = ThemeConst.green // #45A48A


  let upColor = Number(colorType) === 2 ? red : green
  let downColor = Number(colorType) === 2 ? green : red


  // 蜡烛图样式
  tvWidget.chart().getSeries().setChartStyleProperties(1, {
    upColor,
    downColor,
    wickUpColor: upColor,
    wickDownColor: downColor,
    borderUpColor: upColor,
    borderDownColor: downColor
  })


  // 空心蜡烛图样式
  tvWidget.chart().getSeries().setChartStyleProperties(9, {
    upColor,
    downColor,
    wickUpColor: upColor,
    wickDownColor: downColor,
    borderUpColor: upColor,
    borderDownColor: downColor
  })
}

六、性能优化策略

6.1 数据加载优化

// 1. 按需加载历史数据
getHttpHistoryBars = async (symbolInfo, resolution, from, to, countBack, firstDataRequest) => {
  const size = document.documentElement.clientWidth >= 1200 ? 500 : 200
  // 根据屏幕宽度调整加载数量,移动端减少请求数据量
}


// 2. 数据缓存策略
@action
getDataFeedBarCallback = (obj = {}) => {
  const { firstDataRequest } = obj


  if (firstDataRequest) {
    // 首次请求完整数据
    this.getHttpHistoryBars(symbolInfo, resolution, from, to, countBack, true)
  } else {
    // 后续请求只获取增量数据
    this.getHttpHistoryBars(symbolInfo, resolution, from, this.lastBarTime, countBack, false)
  }
}

6.2 WebSocket 连接优化

// 使用 reconnecting-websocket 实现自动重连
this.socket = new ReconnectingWebSocket(wsUrl, ['WebSocket', token], {
  minReconnectionDelay: 1,
  connectionTimeout: 3000,
  maxEnqueuedMessages: 0,
  maxRetries: 10000
})


// 心跳保活
startHeartbeat() {
  this.heartbeatInterval = setInterval(() => {
    this.send({}, { msgId: 'heartbeat' })
  }, 20000)
}

6.3 图表渲染优化

// 1. 使用 loading 状态避免闪烁
const [loading, setLoading] = useState(true)
setTimeout(() => {
  setLoading(false)
}, 200)


// 2. 延迟初始化避免阻塞
useEffect(() => {
  // 延迟加载图表
  setTimeout(() => {
    const tvWidget = new widget(widgetOptions)
  }, 100)
}, [])


// 3. 缓存主题配置
const defaultBgColor = theme === 'dark' ? ThemeConst.black : ThemeConst.white
if (theme && defaultBgColor !== STORAGE_GET_CHART_PROPS('paneProperties.background')) {
  STORAGE_REMOVE_CHART_PROPS()
}

6.4 内存管理与清理

useEffect(() => {
  return () => {
    // 组件卸载时清理
    tvWidget.remove() // 销毁图表实例
    mitt.off('symbol_change') // 取消事件订阅
    this.stopHeartbeat() // 停止心跳
    this.socket?.close() // 关闭 WebSocket
  }
}, [])

七、常见问题与解决方案

7.1 主题切换不生效

// 问题:切换主题后图表颜色不变
// 解决:清除本地缓存 + 动态调用 changeTheme


// 1. 切换主题时清除缓存
STORAGE_REMOVE_CHART_PROPS()


// 2. 动态切换主题
tvWidget.changeTheme(theme)


// 3. 设置 CSS 变量
setCSSCustomProperty({ tvWidget, theme })

7.2 数据请求重复

// 问题:多次调用 getBars
// 解决:使用 lastBarTime 缓存截止时间


this.lastBarTime = bars[0]?.time / 1000
if (this.lastBarTime === bars[0]?.time / 1000) {
  this.datafeedBarCallbackObj.onHistoryCallback([], { noData: true })
}

7.3 移动端适配

// 移动端禁用多余功能
if (props.isMobile) {
  disabled_features.push('header_symbol_search', 'context_menus', 'show_chart_property_page', 'header_screenshot', 'left_toolbar')
}


// 禁止双指缩放
document.body.addEventListener(
  'touchstart',
  (e) => {
    if (e.touches.length > 1) {
      e.preventDefault()
    }
  },
  { passive: false }
)

八、完整调用示例

// src/pages/index.tsx
import Tradingview from '@/components/Tradingview'


export default function ChartPage() {
  return (
    <div>
      <Tradingview />
    </div>
  )
}

URL 参数说明:

  • symbolName: 交易品种,如 BTCUSDT
  • theme: 主题,light 或 dark
  • locale: 语言,如 en、zh\_TW
  • colorType: 涨跌颜色,1 绿涨红跌,2 红涨绿跌
  • chartType: 图表类型,1 蜡烛图、2 折线图等

总结

本文详细讲解了 Next.js 项目中接入 TradingView 图表的完整方案,涵盖了:

  1. 环境配置:类型定义、目录结构
  2. 核心实现:主组件、Datafeed、配置选项
  3. 数据交互:HTTP 历史数据 + WebSocket 实时更新
  4. 自定义开发:自定义指标、主题定制
  5. 性能优化:数据加载、WebSocket、渲染优化、内存管理
  6. 常见问题:主题切换、数据重复、移动端适配

通过以上方案,可以在 Next.js 项目中快速构建专业的金融图表应用。如需更高级的功能(如图表保存加载、自定义交易品种等),可以参考 TradingView 官方文档

Copy Files from Host to Docker Container

Docker 是一个用于在容器内部开发、发布和运行应用程序的平台。使用 Docker 时的一个常见任务是
在主机和容器之间传输文件。在本文中,我们将探讨如何使用将文件从主机复制到 Docker 容器内部。

docker cp 命令

docker cp 命令允许您在容器和本地文件系统之间复制文件或目录,基本语法如下:

docker cp [OPTIONS] SRC_PATH CONTAINER:DEST_PATH
  • SRC_PATH : 这是要复制的主机上的源文件或目录。
  • CONTAINER : 这是要将文件复制到的 Docker 容器的名称或 ID。
  • DEST_PATH : 这是 Docker 容器内的目标路径。

复制文件到 Docker 容器

在将文件复制到容器之前,先找出容器的名称或 id

docker ps

例如,将 example.txt 复制到 mycontainer 容器下的 /tmp 目录

docker cp example.txt mycontainer:/tmp/

检查文件是否正确复制,运行如下命令

docker exec mycontainer ls /tmp/

注意事项

Overwriting

docker cp 命令会在没有任何警告的情况下覆盖容器内的文件,所以一定要确保目标路径是正确的,并且不包含相同的文件或目录名称,除非您打算覆盖它们。

Permissions

文件复制到容器将保持其权限,您可能需要在复制文件后调整文件权限。

Alternatives

除了 docker cp 命令,您还可以使用 volume 在主机和容器之间共享文件。volume 是更高级的主题,如果需要持久化数据或希望在主机和容器文件系统之间实现实时同步。

我的开源项目

酷瓜云课堂-开源知识付费解决方案

白山一提起,大家可能最新想到的是 CDN ,现在他们也出了 AI 广场,可以调用 GLM-5,miniMax ,注册并认证送 150 元,这个额度不用白不用,白山怎么说也算是个正经厂了

教程

1. 注意事项,需要注册并实名认证,就送 150 元永久额度

aff: https://ai.baishan.com/auth/login?referralCode=akCUuc9eOq
邀请码:akCUuc9eOq

1.1 看在分享羊毛的份下,辛苦大家用下我的邀请码注册,谢谢~

2. 首次调用后,加送 300 元额度( curl 我都准备好了)

curl --request POST \
  --url https://api.edgefn.net/v1/chat/completions \
  --header 'Authorization: Bearer 你的 APIkey' \
  --header 'Content-Type: application/json' \
  --data '{
  "model": "MiniMax-M2.5",
  "messages": [{"role": "user", "content": "Hello, how are you?"}]
}'

一、事件背景:高光时刻的突然转折

讽刺的是,这一事件发生在Qwen用户增长最快、影响力最大的时期:

  • 用户暴增:Qwen移动应用的月活跃用户从2026年1月的约3100万暴增至2月的2.03亿,550%的用户增长使Qwen跃居全球第三大AI产品,仅次于ChatGPT和豆包
  • 开源影响力:截至2026年1月,Qwen模型在Hugging Face上累计下载量超过7亿次,阿里已开源近400个Qwen模型,社区创建了超过18万个微调衍生模型

image

二、核心人物:林俊旸是谁?

项目信息
年龄32岁
学历2019年北京大学毕业(计算机科学与语言学)
职级阿里巴巴最年轻的P10级高管(高级领导层级别)
贡献阿里早期AI工作核心贡献者(M6、OFA模型),将Qwen从一个不起眼的副项目发展成为世界上最具影响力的开源LLM系列

三、事件时间线

2026年1月

  • Qwen Code负责人惠必远离职加入Meta,成为核心成员流失的先兆
  • 林俊旸在1月峰会上坦言:团队大部分算力被用于"交付需求",前沿研究空间受限

2026年春节(除夕夜)

  • Qwen 3.5发布,但据传被内部高管评价为"半成品"
  • 林俊旸在内部会议上与高层存在分歧

2026年3月3日(周一)下午

  • 林俊旸正式向阿里巴巴提交辞呈

2026年3月4日凌晨

  • 林俊旸在X平台发布告别消息:

    "me stepping down. bye my beloved qwen."(我卸任了,再见,我心爱的Qwen)

image

2026年3月4日白天

  • 消息在Qwen团队内部传开,引发团队成员强烈情绪反应
  • Qwen后训练负责人余博文同日宣布辞职
  • Qwen 3.5和Qwen-VL核心贡献者李凯欣发布告别消息
  • 林俊旸评论区被"Qwen is nothing without its people"刷屏

image

image

2026年3月4日下午

  • 通义实验室紧急召开内部会议
  • 出席高管:CEO吴泳铭、首席人才官蒋芳、阿里云CTO周靖人等
  • 林俊旸发布新消息:"抱歉大家,今天我不会回复消息或电话。我真的需要休息。Qwen的兄弟们,按原计划继续,没问题。"

2026年3月5日上午

  • CEO吴泳铭发内部邮件:"公司已决定批准林俊旸同学的辞职,感谢林俊旸过去在岗位上的付出。"

四、离职深层原因分析

1. 组织架构重组冲突

  • 阿里正在重组Qwen团队:从垂直整合的类创业公司运营模式,转向将预训练、后训练、文本、多模态分成不同团队的分散结构
  • 这种重组将直接限制林俊旸的管理范围,与他对AI开发应该如何运作的理念不符
  • 关键问题:在没有充分信息沟通的情况下,矛盾爆发

2. 外部空降高管带来的紧张

  • 周浩的到来:由阿里云CTO周靖人亲自从Google DeepMind招募
  • 周浩背景:中科大本科、威斯康星大学博士,曾在Meta工作3年、DeepMind约4年,是Gemini 3.0核心贡献者
  • 这与字节跳动、腾讯从硅谷引进研究人员的做法类似

3. 资源分配问题

  • 算力与研究错配:技术天才被当成"业务的技术外包"使用
  • 内部尖锐质疑:外部客户购买阿里云算力用得顺畅,内部团队反而在算力、招聘名额上捉襟见肘
  • 周靖人承认团队"一直资源紧张",但未展开说明

4. 错位的考核体系

  • 据知情人士透露:阿里云内部竟尝试用DAU(日活)这类C端产品指标来考核底层基座模型团队
  • 这种"指鹿为马"的评价体系,让专注技术的科学家感到极大不适

5. 开源模式商业化困惑

  • 对开源模式商业化效率存在内部分歧

image

五、阿里高管/HR回应及争议

CEO吴泳铭

  • 表态Qwen是第一优先级,"尽了中国CEO最大的努力"
  • 承认:"中国国情特殊,资源很难大家都满意,应该要更早知道资源(分配)的问题"
  • 最终决定:批准林俊旸辞职

首席人才官蒋芳(争议最大)

  • 争议表态:"不能把任何人推上神坛,不接受非理性的要求、不计代价来挽留"
  • 这一言论被广泛批评为态度强硬、缺乏对核心人才的尊重
  • 承认沟通不足:"这次组织形式没沟通好,新人引入肯定会带来阵型变化,我们可能没处理好"
  • 网友评论:"看来HR在阿里体系内话语权确实很大"

阿里云CTO周靖人

  • 回应算力、招聘名额问题时表示:内外差异有很多历史原因,未来正在做整体规划
  • 没有进一步展开说明

内部匿名评价

  • 有通义离职员工称:"走就走了,大概率不会挽留",通义实验室内部现存技术大牛"有些实力显著在林俊旸之上"
  • 这一评价被认为反映了阿里内部部分人对林俊旸贡献的轻视

image

六、核心成员离职潮

姓名职位离职时间去向
惠必远Qwen Code负责人2026年1月加入Meta
林俊旸Qwen技术负责人2026年3月3日未知
余博文Qwen后训练负责人2026年3月4日未知
李凯欣Qwen 3.5/Qwen-VL核心贡献者2026年3月4日未知

七、社区与外界反应

团队成员

  • Qwen贡献者陈诚:"心碎了","I know leaving wasn't your choice"(我知道离开不是你的选择)
  • 研究科学家Wenting Zhao:将离职描述为"一个时代的终结"
  • 多位研究人员在Twitter和小红书上发布低落情绪帖子

行业人士

  • Hugging Face亚太生态系统负责人王铁震:称这是Qwen项目的"巨大损失"

网友评论

  • "果然很阿里"
  • "阿里跌回80不是梦,幸好没买他家股票"
  • "花了七年培养最年轻的P10,在他做出最大成绩的时候让他走了。下一个愿意在阿里从校招拼到P10的人,看到这个先例,还会有多少信心?"

八、事件本质总结

维度问题
人才管理"不能推上神坛"表态引发寒蝉效应
沟通机制组织架构调整缺乏充分信息沟通
资源分配算力、招聘名额内外不平衡
考核体系用C端DAU指标考核基座模型团队
技术尊重高管称Qwen 3.5为"半成品"
权力博弈空降高管引发原有团队不安

九、事件影响与反思

此次事件引发了业界对以下问题的广泛讨论:

  1. 大厂如何留住核心AI人才?
  2. 开源AI项目的商业化与技术理想如何平衡?
  3. 组织扩张期如何处理老功臣与空降兵的关系?
  4. HR在技术公司的话语权边界在哪里?

正如批评者所言:当技术天才被当成"业务的技术外包"使用,离心力便产生了。

事件仍在持续发酵中,林俊旸的下一步去向尚未公布。

进去看了下,一进去一堆人给你发什么老公,就是一个频道 几个人挂在上面 刷礼物他们语音配料,刷三十块钱才唱首歌,这朋友之前也是,要不然干主播,要不然干那啥,反正就是类似的工作,但是听她说这玩意确实挺挣钱.. 那种大叔挺多的。我比较好奇这种一般什么人会去点啊,一百块钱的礼物陪聊一个小时,我按摩不过一百出头..

PHP 的异步编程 该怎么选择

PHP 的传统执行模型是同步的,这意味着代码按照语句出现的顺序逐条执行。这本身并非问题,因为同步思维往往更为简单。

当要求 PHP 开发者实现 SQL 分页展示时,他们通常会先执行一条统计总数的查询,再执行第二条查询获取当前页的数据。总记录数对于生成分页链接(首页、下一页、末页等)是必需的。

当 SQL 服务器处理第一条计数查询时,PHP 服务器处于等待状态,收到响应后才执行第二条查询。

当然,存在一次性获取两种信息的方法,但那不是本文的主题,请保持专注。

从这个分页示例中,我们可以看到潜在的优化空间:在 SQL 服务器处理第一条查询的同时启动第二条查询。但要注意,在拿到计数结果之前我们不会显示分页链接,因此即使计数查询先完成,也需要等待另一条查询的结果。

由此可见,异步操作的管理不仅限于并行执行任务,还包括管理响应的处理顺序。

存在许多需要异步执行代码的场景,这通常与 I/O 操作相关:HTTP 请求、数据库访问、文件读写或启动外部进程。

PHP 是异步的吗?

要判断 PHP 是否"异步",首先需要理解"异步"的含义。异步指的是:不同时发生。当某项操作耗时时,与其等待完成,不如先去做其他事情,等操作完成后再回来继续。因此,异步的核心在于操作是非阻塞的。

人们常常混淆异步和并行。

打个比方:异步如同一位厨师将锅接满水放在灶台上开火,趁水烧开的工夫去切蔬菜。等蔬菜切好、水也烧开,就开始烹饪。

并行则是两位厨师:一位切蔬菜的同时,另一位负责烧水。蔬菜切好、水也烧开后,由第一位厨师负责烹饪。

并行节省了时间,因为切蔬菜与烧水准备是同时进行的。但两种模式下,水烧开的过程中都可以去做其他事情。

具体而言,我们的"厨师"就是机器的 CPU/GPU。

PHP 的异步能力

从 2002 年 PHP 4.3 发布起,一项重要功能被引入:Streams。通过 stream_set_blocking()stream_select() 函数,PHP 进入了异步编程时代。

$h = fopen(__FILE__, 'r');
stream_set_blocking($h, false);
$content = '';
while (!feof($h)) {
    $read = array($h);
    $write = $except = null;
    // 检查是否有可读内容,最多等待 1000 微秒
    // 永远不要设为 0,否则会导致 CPU 过度占用
    $ready = stream_select($read, $write, $except, 1000);

    if ($ready === 0) {
        // 没有可读内容,稍作等待
        // 或者去做其他事情...
        usleep(1000);
        continue;
    }
    $chunk = fgets($h, 1024);
    if ($chunk !== false) {
        $content .= $chunk;
    }
}

fclose($h);

echo $content;

注意,这段示例代码刻意简化,未处理错误等情况。

usleep(1000) 的位置,可以执行其他操作,比如读取另一个文件,甚至向其他服务器发起 HTTP 请求。不过,如果你的文件系统很快,可能不会进入等待时间。这种技术更适合处理慢速文件系统或其他类型的 I/O 操作。

23 年前 PHP 就已支持异步编程,然而几年前人们还说 PHP 不是异步语言,为什么?

因为实现异步不仅仅是启动非阻塞处理,还需要有机制来管理这些等待时间。

这就引入了协程的概念。协程是一种可以被挂起、之后恢复的函数。

协程与 Fiber

2013 年 6 月,PHP 5.5 引入生成器(Generators)后,开发者开始将其改造为协程使用。

$generator = (function() {
    $count = 3;
    echo "开始\n";
    while(true) {
        yield; // 挂起函数(生成器)
        echo "有结果了吗?\n";
        $count--;
        if ($count === 0) {
            return; // 收到结果,停止
        }
    }
})();

$generator->current(); // 启动处理
do {
    echo "做其他事情\n";
    $generator->next(); // 恢复函数执行(从 yield 处继续)
} while ($generator->valid()); // 函数是否结束?
echo "结束\n";

PHP 8.1 的发布标志着 PHP 向异步编程迈出了重要一步,引入了 Fiber 作为真正的协程技术基础。

$fiber = new Fiber(function() {
    $count = 3;
    echo "开始\n";
    while(true) {
        Fiber::suspend(); // 挂起 fiber
        echo "有结果了吗?\n";
        $count--;
        if ($count === 0) {
            return; // 收到结果,停止
        }
    }
});

$fiber->start(); // 启动处理
do {
    echo "做其他事情\n";
    $fiber->resume(); // 恢复 fiber 执行
} while (!$fiber->isTerminated()); // fiber 是否结束?
echo "结束\n";

你会发现代码与使用生成器时几乎没什么变化。

虽然 PHP 从 4.3 版本就具备底层异步能力,但 PHP 8.1 引入的 Fiber 标志着一个转折点。Fiber 提供了原生且强大的异步编程工具,使其变得更加自然。

Event Loop

既然我们已经知道如何中断协程并执行非阻塞处理,接下来需要管理多个并行任务,因为单个异步处理的意义不大。

谈到并行,人们常会想到线程——线程提供进程间的自然隔离,并能利用多核 CPU,这对计算密集型任务非常有吸引力。

然而,并行、特别是多线程的实现更为复杂,调试更困难,还存在死锁和内存并发访问的风险。

正是出于这些原因,Web 领域更倾向于使用另一种模式:EventLoop。Web 场景的特点是并发连接数可能非常高。

EventLoop 是一个无限循环,它监听事件队列(如结果到达),并以串行方式逐个处理。

我们将待处理的任务加入这个队列,然后启动循环。

问题是如何告知 EventLoop 如何处理任务的结果?很简单,我们指定一个回调函数,当结果可用时 EventLoop 会调用它。

注意:下面代码中的 EventLoop 是虚构的,但代表了大多数 EventLoop 的工作方式。

$loop = EventLoop::get();
$loop->addReadStream('file.txt', function(string $data) {
    echo "读取到的数据:{$data}";
});
echo "启动 EventLoop\n";
$loop->run();

这段代码的预期输出:

启动 EventLoop
读取到的数据:<file.txt 的内容>

同时读取两个文件的情况:

$loop = EventLoop::get();
$loop->addReadStream('/dev/cdrom/file1.txt', function(string $data) {
    echo "数据 1 已读取:{$data}";
});
$loop->addReadStream('/dev/fb0/file2.txt', function(string $data) {
    echo "数据 2 已读取:{$data}";
});
echo "启动 EventLoop\n";
$loop->run();

根据存储介质的性能,输出可能是:

启动 EventLoop
数据 2 已读取:<软盘数据>
数据 1 已读取:<光盘数据>

Promise

当需要链式执行异步操作时,就会陷入回调地狱(或末日金字塔):回调函数层层嵌套。

$loop = EventLoop::get();
$loop->addReadStream('file.txt', function(string $data) {
    EventLoop::get()->defer(function() use ($data) {
        return compressData($data);
    }, function ($compressedData) {
        EventLoop::get()->addWriteStream(
            'http://foo', 
            $compressedData, 
            function (Response $response) {
                echo "数据已发送\n";
            });
    });
});
echo "启动 EventLoop\n";
$loop->run();

如果再加上错误处理,代码会更加复杂难读。

为了改善可读性和更好地管理异步,Promise(承诺)的概念值得考虑。

Promise 的概念于 80 年代在 Multilisp 等语言中引入,但真正流行是在 2009 年,Dojo、Q、jQuery.Deferred 等 JavaScript 库率先实现了它。

Promise 是什么?它是一个包含处理结果(当前或未来)的对象。打个比方:

"我不会立即给你处理结果,但我承诺稍后会在这个对象里给你。"

示例代码:

$promise = new Promise(function ($resolve, $reject) {
    echo "启动 Promise\n";
    $resolve("Hello, world!");
});

运行这段代码会看到 "启动 Promise",但 "Hello, world!" 在哪里?为什么要调用 $resolve()

实际上,需要使用 then() 方法配合回调函数:

$promise = new Promise(function ($resolve, $reject) {
    echo "启动 Promise\n";
    $resolve("Hello, world!");
});

$promise->then(
    function ($value) {
        echo "Promise 结果:$value\n";
    }
);

输出:

启动 Promise
Promise 结果:Hello, world!

如果 Promise 没有被解决(resolve),什么都不会发生,只会显示启动信息。

具体来说,当 Promise 被解决时,then() 中的回调会被执行。这种情况可能发生在 Promise 内部包含协程时——协程经过长时间处理收到结果后调用 $resolve()

配合 EventLoop 的完整示例:

$loop = EventLoop::get();

$promise = new Promise(function ($resolve, $reject) use ($loop) {
    echo "启动 Promise\n";
    $loop->addTimer(1, function () use ($resolve) {
        echo "解决 Promise\n";
        $resolve("Hello, World!");
    });
});

$promise->then(
    function ($value) {
        echo "结果:$value\n";
    }
);

$loop->run();

这段代码使用异步定时器在 1 秒后解决 Promise。输出:

启动 Promise
解决 Promise
结果:Hello, World!

Promise 的价值体现在哪里?回到回调地狱的问题。使用 Promise 后,代码可以这样写:

readFileAsync('file.txt')
    ->then(function ($data) {
        return compressDataAsync($data);
    })
    ->then(function ($compressedData) {
        return sendDataAsync('http://foo', $compressedData);
    })
    ->catch(function ($error) {
        echo "错误:{$error}\n";
    });

readFileAsync() 返回一个使用 EventLoop 的 Promise,在获得结果时解决。
compressDataAsync()sendDataAsync() 同样返回 Promise。

catch() 用于处理链中任何环节的错误。现在我们不再是嵌套回调,而是回调链。

你也可以在回调中返回值,这个值会被转换为立即解决的 Promise。如果不返回任何内容,相当于返回一个值为 NULL 的已解决 Promise。

如果需要在各阶段处理错误,then() 方法接受第二个参数作为拒绝(错误)时的回调:

readFileAsync('file.txt')
    ->then(
        function ($data) {
            return compressDataAsync($data);
        },
        function ($error) {
            echo "文件读取错误:{$error}\n";
        }
    )
    ->then(function ($compressedData) {
        return sendDataAsync('http://foo', $compressedData);
    })
    ->catch(function ($error) {
        echo "错误:{$error}\n";
    });

需要注意的是,如果错误回调返回了值(或没有 return),后续的 then() 会收到一个已解决的 Promise。因此需要返回一个错误状态的 Promise 或抛出异常。

这是 then(onResolve, onReject) 中处理错误的常见陷阱之一——需要在后续所有 then() 中处理错误。上面的代码中,sendDataAsync() 会收到包含 NULL 的 $compressedData

包选型建议

在 Packagist 上搜索 "promise" 会发现有 4 个包较为突出。

Guzzle/promises 和 php-http/promise

guzzle/promises 的下载量遥遥领先,很大程度上是因为它被流行的 HTTP 客户端 guzzle/guzzle 直接使用。

如果你已经在使用 Guzzle,可能无需选择其他包,因为它已经相当完善。

但 Guzzle/Promises 最初是为处理异步 HTTP 请求设计的,使用内部不暴露的 EventLoop,这使得集成其他类型的 I/O(如 Mysqli 异步查询或进程)更加困难。

php-http/promise 情况类似,同样专注于 HTTP 请求。

ReactPHP 和 Amp

剩下的两个重要选择是 react/promiseamphp/amp

ReactPHP 提供了简单且高性能的 JavaScript Promises/A+ 标准实现(Promise 最初是 JavaScript 语言中涌现的标准,没告诉过你吧?)。

Amp 则没有完全实现 Promise:3.0 版本中没有 then(),但它实现了另一种机制——Futures,设计用于在基于生成器或 Fiber 的协程中通过 await() 等待。

因此,一边是 Promise 链式管理,另一边是面向协程的管理。

如果你用过 JavaScript 的 Promise,ReactPHP 可能更容易上手;否则 Amp 的协程方式代码可读性更好,更接近我们习惯的"同步" PHP 写法。

但无论选择 ReactPHP 还是 Amp,都需要 EventLoop。

ReactPHP 提供 react/event-loop 包,Amp 推荐使用 revolt/event-loop——这是 Amp 团队发起的项目,旨在围绕现代事件循环标准统一 PHP 异步生态。Revolt 可通过适配器与 ReactPHP 互操作。

怎么选?

如果你想使用 Promise 模式,毫无疑问应该选择 react/promise

另一方面,Amp 提供了一种不同的写法,对某些人来说可能更"自然",建议你两种都试试看哪个更适合。

对于 EventLoop,建议选择 Revolt,其统一生态的愿景在中期来看可能会带来回报。

还有一个参考因素:Amp v3 使用 PHP 8.1 的 Fiber,而 ReactPHP 可以在 PHP 7.1 上运行。

PHP 的异步编程 该怎么选择

📰 今日新闻精选:

  • 2026 年政府工作报告出炉:GDP 增长目标 4.5%-5%;城镇调查失业率 5.5% 左右;居民消费价格涨幅 2% 左右
  • 代表任敏建议:80 岁以上农民免缴个人医保;代表庞永辉建议:三孩家庭每月补贴 5000 元至孩子 3 岁
  • 代表田轩建议:尽量不要调休,尽量扩大公共假期;委员吕国泉建议:下班后有权拒回工作消息
  • 委员袁小彬建议:取消私家车年审制度,实施远程监测;代表张强建议:节假日免费开放公共停车位
  • 上海试点推出网约公交服务:车票价仅 1 元,乘客可通过小程序预约乘车
  • 央行:3 月 6 日将开展 8000 亿元买断式逆回购操作,期限为 3 个月
  • 江苏、四川等地春假落地,清明和五一假期前出行热度猛增,成都、南京等地出发机票同比翻倍增长
  • 比亚迪第二代刀片电池发布:电量从 10% 充到 70% 仅需五分钟,从 10% 充到 97% 仅需 9 分钟,刷新充电速度新纪录
  • 2026 胡润全球富豪榜出炉:中国十亿美金企业家达 1110 位排名第一,其中张一鸣身家 5500 亿元坐稳中国首富
  • 外媒:一艘货船因挂出 “中国所有” 安全通过霍尔木兹海峡,伊朗表示目前仅针对美以欧及其支持方的船只关闭
  • 美媒:美国对全球 15% 关税或本周生效,非法退税程序或加速推进
  • 美媒:美国国会参议院限制总统战争权力的议案未获通过;特朗普称若满分 10 分,我给美对伊行动打 15 分
  • 美媒:特朗普称需要亲自参与挑选伊朗下一任领导人,不接受哈梅内伊儿子接任最高领袖
  • 英媒:英国官员表示未来不排除参与打击伊朗导弹设施;德防长表示德国不会参与针对伊朗的战争
  • 外媒:伊朗发射携带 1 吨重弹头的导弹,打击以色列首都、机场以及空军基地;伊朗称击落一架美军 F-15 战斗机,美军否认

📅 今日信息:

  • 公历:2026-03-06 星期五 双鱼座
  • 农历:二〇二六年正月十八
  • 公历纪念日:世界青光眼日
  • 下一节气:2026-03-20,春分
  • 今年进度:17.81%(已过 65 天,剩余 299 天)

🌟 历史上的今天

  • 1475 年:米开朗基罗诞生,文艺复兴时期的杰出艺术家,创作了《大卫》和西斯廷教堂天顶画等不朽作品。
  • 1981 年:美国宇航局发射了旅行者 1 号探测器,后来成为第一个进入星际空间的人造物体。

目地址:

大家好,我是 NextClaw 的作者。

发这个帖,主要是想把项目讲清楚,也收集一轮真实反馈。

我自己的观察是:现在很多 OpenClaw 替代品更偏学习或二次开发。真到日常使用阶段,在功能和生态完整度上,经常会和 OpenClaw 拉开差距。NextClaw 想补的是这个空位:尽量保留 OpenClaw 生态兼容,同时把易用性做上去。

先说背景。NextClaw 是受 OpenClaw 启发做的,我们一直把 OpenClaw 当作很重要的参考。两边不是替代关系,更像两种取舍。

愿景方向:

  1. 自知与自治
  2. 插件化拓展与生态繁荣
  3. 数字世界全能基础设施
  4. 开箱即用

值得一试的理由:

  1. 一行命令安装,启动后直接在 UI 配置,默认就能体验,不需要先啃复杂命令行。
  2. 对国内场景更友好:内置 QQ 、飞书、企微、钉钉等高频渠道,不用自己从零折腾适配层,能更快落地到真实聊天场景。
  3. 兼容 OpenClaw 生态,迁移和复用成本更低。
  4. 提供完整中文界面和中文文档。
  5. 支持 Windows 、macOS 、Linux 、云服务器和 Docker 。
  6. 代码开源,体量轻,当前代码量约为 OpenClaw 的 1/20 。
  7. 对话体验是“真流式 + 可恢复”:回复实时输出,会话中断后可继续恢复进行中的任务。
  8. Qwen 支持浏览器授权,也支持从本机 Qwen CLI 一键导入凭证,少折腾一层账号配置。
  9. 架构上坚持插件化拆分,目标是更可维护、更快迭代。

当前能力也补充一下:

  1. 接多家模型服务:OpenRouter 、OpenAI 、Anthropic 、Gemini 、DeepSeek 、Groq 、MiniMax 、Moonshot 、DashScope 、Zhipu 、AiHubMix 、vLLM 。
  2. 接多种消息渠道:Discord 、Telegram 、Slack 、WhatsApp 、飞书、钉钉、企业微信、QQ 、Email 、Mochat 。
  3. 做自动化:内置 Cron + Heartbeat ,可以跑定时任务和后台任务。
  4. 本地运行:配置、会话、密钥默认留在本机。
  5. 用同一个 UI 管理聊天、Provider 、渠道和技能配置。

如果你想快速搭一个可长期维护的个人 AI 中枢,这个项目可能适合你。

体验方式:

npm i -g nextclaw
nextclaw start

打开 http://127.0.0.1:18791 即可。

欢迎直接回帖提意见,尤其是你觉得最难用的一步是什么。

点赞 + 关注 + 收藏 = 学会了

💡整理了一个 NAS 专属玩法专栏,感兴趣的工友可以戳这里关注 👉 《NAS邪修》

每次注册新账号,是不是都在“123456”和“大写字母+生日”之间反复横跳?

PSWD 是一个极简、隐私、开源的随机密码生成工具。部署在自己的 NAS 上,既不用担心在线生成器的安全性,又能随时生成符合各种复杂度要求的“赛博护符”。

本次使用绿联 NAS 安装 PSWD,其他品牌的 NAS 操作步骤也是差不多的。

打开 文件管理,在 docker 文件夹下新建一个文件夹,命名为 pswd

打开 Docker 应用,进入 项目 面板,点击“创建项目”。

  • 项目名称:pswd
  • 存放路径:选择刚才创建的 /docker/pswd 文件夹。

Compose配置如下:

services:
  timesy:
    image: ghcr.io/remvze/pswd
    logging:
      options:
        max-size: 1g
    restart: always
    ports:
      - 3337:8080 # 前面 3337 是访问端口,可自定义

这里的 3337 是我指定的访问端口,如果和你现有的服务冲突,改成任意没被占用的数字即可(比如 5678)。

等项目构建成功后,切换到「容器」面板找到 PSWD,点击它旁边的小箭头,或者在浏览器输入 NAS_IP:3337 就能使用 PSWD 了。

PSWD 不只是生成一串乱码,它支持全能型随机密码(Password:大小写、数字、特殊符号随心控)、密码短语(Passphrase:随机单词组合)、Pin(纯数字模式)

你可以根据不同的应用场景,一键配置复杂度,甚至连长度都能拉满


以上就是本文的全部内容啦~

你有发现什么冷门但好玩的镜像吗?欢迎在评论区留言“踢”我一下!

想了解更多NAS玩法记得关注《NAS邪修》👏

往期推荐:

点赞 + 关注 + 收藏 = 学会了