RemoteDriveAccountDialog.ets 54 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556
  1. import { ToastUtil } from '@pura/harmony-utils';
  2. import { CommonConstants } from '../common/constants/CommonConstants';
  3. import { RemoteDriveType } from '../common/enums/RemoteDriveType';
  4. import { WebDavAccount } from '../viewmodel/WebDavAccount';
  5. import { ImagePickerUtil } from '../common/util/ImagePickerUtil';
  6. import Logger from '../common/util/Logger';
  7. import { ConfigurationConstant, Context } from '@kit.AbilityKit';
  8. import { BaiduConstants } from '../common/constants/BaiduConstants';
  9. import { BaiduDeviceTokenResult, pollDeviceToken as pollBaiduDeviceToken, requestDeviceCode as requestBaiduDeviceCode } from '../common/network/BaiduPanClient';
  10. import { webview } from '@kit.ArkWeb';
  11. interface ParsedConnectionParts {
  12. protocol: string;
  13. username?: string;
  14. password?: string;
  15. host: string;
  16. port?: number;
  17. path?: string;
  18. }
  19. interface BaiduTokenResultShape {
  20. access_token?: string;
  21. refresh_token?: string;
  22. expires_in?: number;
  23. error?: string;
  24. error_description?: string;
  25. }
  26. // 工具函数:将 #RRGGBB 字符串和透明度转为 'rgba(r,g,b,a)' 字符串,深色模式下可返回深色变体
  27. function themeColorWithAlpha(themeColor: string, alpha: number, isDarkMode: boolean = false): string {
  28. if (isDarkMode) {
  29. // 深色模式下返回更深的灰色或半透明黑色
  30. return `rgba(34,34,34,${alpha + 0.2 > 1 ? 1 : alpha + 0.2})`;
  31. }
  32. const color = themeColor.replace('#', '');
  33. const r = parseInt(color.substring(0, 2), 16);
  34. const g = parseInt(color.substring(2, 4), 16);
  35. const b = parseInt(color.substring(4, 6), 16);
  36. return `rgba(${r},${g},${b},${alpha})`;
  37. }
  38. // 网盘账户对话框
  39. @Component
  40. export struct RemoteDriveAccountDialog {
  41. @Prop isEditMode: boolean = false;
  42. @Prop account: WebDavAccount;
  43. onConfirm?: (account: WebDavAccount) => void;
  44. onCancel?: () => void;
  45. @State accountName: string = '我的网盘';
  46. @State host: string = '';
  47. @State port: number = -1;
  48. @State filepath: string = '/';
  49. @State username: string = '';
  50. @State password: string = '';
  51. @State enableHttps: boolean = false;
  52. @Prop initialDriveType: RemoteDriveType = RemoteDriveType.WebDav;
  53. @State driveType: RemoteDriveType = RemoteDriveType.WebDav;
  54. @State shareName: string = '';
  55. @State domain: string = '';
  56. @State navidromeBasePath: string = '/rest';
  57. @State jellyfinBasePath: string = '';
  58. @State embyBasePath: string = '';
  59. @State coverPath: string = '';
  60. @State isDarkMode: boolean = false;
  61. @State ftpEncoding: string = 'utf-8';
  62. @State baiduAuthStatus: string = '';
  63. @State baiduAccessToken: string = '';
  64. @State baiduRefreshToken: string = '';
  65. @State baiduTokenExpiresAt: number = 0;
  66. @State showBaiduAuthDialog: boolean = false;
  67. @State baiduAuthorizeUrl: string = '';
  68. @State baiduAuthProgress: number = 0;
  69. @State baiduAuthMode: 'web' | 'device' = 'web';
  70. @State baiduDeviceCode: string = '';
  71. @State baiduDeviceUserCode: string = '';
  72. @State baiduDeviceVerifyUrl: string = '';
  73. @State baiduDeviceQrUrl: string = '';
  74. @State baiduDeviceExpireAt: number = 0;
  75. @State baiduDeviceInterval: number = 5;
  76. @State baiduDevicePolling: boolean = false;
  77. @StorageProp('themeColor') themeColor: string = CommonConstants.DEFAULT_THEME_COLOR;
  78. @StorageProp('currentColorMode') @Watch('onColorModeChange') currentMode: number =
  79. ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT;
  80. @State portInput: string = '';
  81. private nameCustomized: boolean = false;
  82. private portCustomized: boolean = false;
  83. private navBasePathCustomized: boolean = false;
  84. private jellyfinBasePathCustomized: boolean = false;
  85. private embyBasePathCustomized: boolean = false;
  86. private baiduAuthStateToken: string = '';
  87. private baiduWebController: webview.WebviewController = new webview.WebviewController();
  88. private baiduDevicePollTimer: number = 0;
  89. onColorModeChange() {
  90. this.isDarkMode = this.currentMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK
  91. }
  92. aboutToAppear(): void {
  93. console.info('heanup driveType = ' + this.driveType);
  94. this.isDarkMode = this.currentMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK;
  95. if (this.isEditMode) {
  96. this.accountName = this.account.name;
  97. this.host = this.account.host;
  98. this.updatePortState(this.account.port, true);
  99. this.filepath = this.account.filepath;
  100. this.username = this.account.account;
  101. this.password = this.account.password;
  102. this.enableHttps = this.account.enableHttps;
  103. this.driveType = this.account.webType ?? RemoteDriveType.WebDav;
  104. this.shareName = this.account.smbShare ?? '';
  105. this.domain = this.account.smbDomain ?? '';
  106. this.coverPath = this.account.coverPath || '';
  107. this.ftpEncoding = this.account.ftpEncoding ?? 'utf-8';
  108. this.baiduAccessToken = this.account.baiduAccessToken ?? '';
  109. this.baiduRefreshToken = this.account.baiduRefreshToken ?? '';
  110. this.baiduTokenExpiresAt = this.account.baiduTokenExpiresAt ?? 0;
  111. this.nameCustomized = true;
  112. if (this.driveType === RemoteDriveType.Navidrome) {
  113. this.navidromeBasePath = this.normalizeNavidromeBasePath(this.account.navidromeBasePath ?? '/rest');
  114. this.navBasePathCustomized = true;
  115. }
  116. if (this.driveType === RemoteDriveType.Jellyfin) {
  117. this.jellyfinBasePath = this.normalizeJellyfinBasePath(this.account.jellyfinBasePath ?? '');
  118. this.jellyfinBasePathCustomized = true;
  119. }
  120. if (this.driveType === RemoteDriveType.Emby) {
  121. this.embyBasePath = this.normalizeEmbyBasePath(this.account.embyBasePath ?? '');
  122. this.embyBasePathCustomized = true;
  123. }
  124. Logger.info('heanup RemoteDriveAccountDialog', `编辑模式加载账户: ${this.accountName}, 封面路径: ${this.coverPath}`);
  125. if (this.coverPath) {
  126. Logger.info('heanup RemoteDriveAccountDialog', `封面文件是否存在: ${ImagePickerUtil.imageExists(this.coverPath)}`);
  127. }
  128. } else {
  129. this.driveType = this.initialDriveType ?? RemoteDriveType.WebDav;
  130. this.baiduAccessToken = '';
  131. this.baiduRefreshToken = '';
  132. this.baiduTokenExpiresAt = 0;
  133. this.jellyfinBasePathCustomized = false;
  134. this.navBasePathCustomized = false;
  135. this.handleDriveTypeChange(this.driveType, true);
  136. }
  137. }
  138. aboutToDisappear(): void {
  139. this.closeBaiduAuthDialog();
  140. }
  141. build() {
  142. Stack() {
  143. Scroll() {
  144. this.contentBuilder();
  145. }
  146. .height('100%')
  147. .width('100%')
  148. .scrollBar(BarState.Off);
  149. if (this.showBaiduAuthDialog) {
  150. this.buildBaiduAuthDialogOverlay();
  151. }
  152. }
  153. .height('100%')
  154. .width('100%');
  155. }
  156. @Builder
  157. contentBuilder() {
  158. Column({ space: 16 }) {
  159. // 标题
  160. Text(this.isEditMode ? '编辑账户' : '添加账户')
  161. .fontSize(18)
  162. .fontWeight(FontWeight.Bold)
  163. .fontColor($r('app.color.index_tab_font_color'));
  164. // 类型选择 + 封面
  165. // this.buildTypeSelector();
  166. // 账户封面选择
  167. Row({ space: 8 }) {
  168. Text('账户封面')
  169. .fontSize(14)
  170. .fontColor(this.isDarkMode ? '#EBEBF5' : '#666666')
  171. .fontWeight(FontWeight.Medium)
  172. .alignSelf(ItemAlign.Center);
  173. // 封面选择区域
  174. Row({ space: 12 }) {
  175. // 封面预览
  176. Stack() {
  177. if (this.coverPath) {
  178. Image(this.coverPath)
  179. .width(50)
  180. .height(50)
  181. .borderRadius(8)
  182. .objectFit(ImageFit.Cover);
  183. } else {
  184. // 默认封面图标
  185. Column() {
  186. Image(getCloudDiskIcon(this.driveType))
  187. .width(30)
  188. .height(30)
  189. }
  190. .width(50)
  191. .height(50)
  192. .backgroundColor(this.isDarkMode ? '#2C2C2E' : $r('app.color.input_background'))
  193. .borderRadius(8)
  194. .justifyContent(FlexAlign.Center);
  195. }
  196. }
  197. .onClick(() => {
  198. this.handleSelectCover();
  199. });
  200. // 操作按钮
  201. Column({ space: 8 }) {
  202. Button('选择封面(可选)')
  203. .width(120)
  204. .height(36)
  205. .fontSize(11)
  206. .fontColor(this.isDarkMode ? '#FFFFFF' : $r('app.color.text_color'))
  207. .backgroundColor(this.isDarkMode ? '#2C2C2E' : $r('app.color.input_background'))
  208. .onClick(() => {
  209. this.handleSelectCover();
  210. });
  211. if (this.coverPath) {
  212. Button('移除封面')
  213. .width(120)
  214. .height(36)
  215. .fontSize(12)
  216. .fontColor(this.isDarkMode ? '#FF453A' : Color.Red)
  217. .backgroundColor(this.isDarkMode ? 'rgba(255,69,58,0.2)' : '#FFE5E5')
  218. .onClick(() => {
  219. this.handleRemoveCover();
  220. });
  221. }
  222. }
  223. .alignItems(HorizontalAlign.Start);
  224. }
  225. .width('100%')
  226. .justifyContent(FlexAlign.Start);
  227. }
  228. .width('100%')
  229. .alignItems(VerticalAlign.Center);
  230. // 账户名称
  231. Row({ space: 8 }) {
  232. Text('名称')
  233. .fontSize(14)
  234. .fontColor($r('app.color.index_tab_font_color'));
  235. TextArea({ placeholder: '请输入账户名称', text: this.accountName })
  236. .layoutWeight(1)
  237. .maxLines(1)
  238. .onChange((value: string) => {
  239. this.accountName = value;
  240. this.nameCustomized = true;
  241. });
  242. }
  243. .width('100%')
  244. .alignItems(VerticalAlign.Center);
  245. if (this.driveType === RemoteDriveType.Baidu) {
  246. this.buildBaiduAuthSection();
  247. } else {
  248. // 服务器地址
  249. Row({ space: 8 }) {
  250. Text(this.getServerLabel())
  251. .fontSize(14)
  252. .maxLines(2)
  253. .fontColor($r('app.color.index_tab_font_color'));
  254. TextArea({ placeholder: '服务器地址或连接串', text: this.host })
  255. .layoutWeight(1)
  256. .onChange((value: string) => {
  257. const trimmed = value.trim();
  258. if (trimmed.includes('://') && this.tryParseConnectionString(trimmed)) {
  259. return;
  260. }
  261. this.host = trimmed;
  262. });
  263. }
  264. .alignItems(VerticalAlign.Center);
  265. this.buildHelperText(this.getServerHint())
  266. // 端口
  267. Row({ space: 8 }) {
  268. Text('端口')
  269. .fontSize(14)
  270. .fontColor($r('app.color.index_tab_font_color'));
  271. TextInput({ placeholder: this.getPortPlaceholder(), text: this.portInput })
  272. .layoutWeight(1)
  273. .maxLines(1)
  274. .type(InputType.Number)
  275. .onChange((value: string) => {
  276. const trimmed = value.trim();
  277. this.portInput = trimmed;
  278. const defaultPort = this.getDefaultPort();
  279. if (trimmed.length === 0) {
  280. this.portCustomized = false;
  281. this.port = defaultPort;
  282. return;
  283. }
  284. const parsed = parseInt(trimmed);
  285. if (Number.isNaN(parsed) || parsed === -1) {
  286. return;
  287. }
  288. this.updatePortState(parsed, true, false);
  289. });
  290. }
  291. .alignItems(VerticalAlign.Center);
  292. // this.buildHelperText('若留空将自动使用协议默认端口')
  293. if (this.driveType === RemoteDriveType.Smb) {
  294. // SMB 共享名称
  295. Row({ space: 8 }) {
  296. Text('共享名称')
  297. .fontSize(14)
  298. .fontColor($r('app.color.index_tab_font_color'));
  299. TextInput({ placeholder: '共享名称', text: this.shareName })
  300. .layoutWeight(1)
  301. .maxLines(1)
  302. .onChange((value: string) => {
  303. this.shareName = value;
  304. });
  305. }
  306. .alignItems(VerticalAlign.Center)
  307. .visibility(Visibility.Visible)
  308. // this.buildHelperText('NAS 上共享根目录的名称,例如 music、share')
  309. Row({ space: 8 }) {
  310. Text('域/工作组')
  311. .fontSize(14)
  312. .fontColor($r('app.color.index_tab_font_color'));
  313. TextInput({ placeholder: '可选项', text: this.domain })
  314. .layoutWeight(1)
  315. .maxLines(1)
  316. .onChange((value: string) => {
  317. this.domain = value;
  318. });
  319. }
  320. .alignItems(VerticalAlign.Center)
  321. .visibility(Visibility.Visible)
  322. // this.buildHelperText('可选,用于需要域/工作组认证的 SMB 服务器')
  323. }
  324. if (this.driveType === RemoteDriveType.Ftp) {
  325. Row({ space: 8 }) {
  326. Text('编码')
  327. .fontSize(14)
  328. .fontColor($r('app.color.index_tab_font_color'));
  329. TextInput({ placeholder: '默认 utf-8,可填 gbk 等', text: this.ftpEncoding })
  330. .layoutWeight(1)
  331. .maxLines(1)
  332. .onChange((value: string) => {
  333. this.ftpEncoding = value.trim();
  334. });
  335. }
  336. .alignItems(VerticalAlign.Center);
  337. this.buildHelperText('若目录或文件名为中文,可根据服务器设置调整编码');
  338. }
  339. if (this.driveType === RemoteDriveType.Jellyfin) {
  340. Row({ space: 8 }) {
  341. Text('API路径')
  342. .fontSize(14)
  343. .fontColor($r('app.color.index_tab_font_color'));
  344. TextInput({ placeholder: '可选,如 /jellyfin', text: this.jellyfinBasePath })
  345. .layoutWeight(1)
  346. .maxLines(1)
  347. .onChange((value: string) => {
  348. this.jellyfinBasePath = this.normalizeJellyfinBasePath(value);
  349. this.jellyfinBasePathCustomized = true;
  350. });
  351. }
  352. .alignItems(VerticalAlign.Center);
  353. this.buildHelperText('Jellyfin 部署在子路径时填写,默认留空表示根路径');
  354. }
  355. if (this.driveType === RemoteDriveType.Emby) {
  356. Row({ space: 8 }) {
  357. Text('API路径')
  358. .fontSize(14)
  359. .fontColor($r('app.color.index_tab_font_color'));
  360. TextInput({ placeholder: '可选,如 /emby', text: this.embyBasePath })
  361. .layoutWeight(1)
  362. .maxLines(1)
  363. .onChange((value: string) => {
  364. this.embyBasePath = this.normalizeEmbyBasePath(value);
  365. this.embyBasePathCustomized = true;
  366. });
  367. }
  368. .alignItems(VerticalAlign.Center);
  369. this.buildHelperText('Emby 部署在子路径时填写,默认留空表示根路径');
  370. }
  371. // if (this.driveType === RemoteDriveType.Navidrome) {
  372. // Row({ space: 8 }) {
  373. // Text('API路径')
  374. // .fontSize(14)
  375. // .fontColor($r('app.color.index_tab_font_color'));
  376. // TextInput({ placeholder: '/rest', text: this.navidromeBasePath })
  377. // .layoutWeight(1)
  378. // .maxLines(1)
  379. // .onChange((value: string) => {
  380. // this.navidromeBasePath = this.normalizeNavidromeBasePath(value);
  381. // this.navBasePathCustomized = true;
  382. // });
  383. // }
  384. // .alignItems(VerticalAlign.Center);
  385. // this.buildHelperText('Navidrome/Subsonic REST 前缀,默认 /rest,可填 /navidrome/rest 等路径')
  386. // }
  387. // 文件目录
  388. Row({ space: 8 }) {
  389. Text('文件目录')
  390. .fontSize(14)
  391. .fontColor($r('app.color.index_tab_font_color'));
  392. TextInput({ placeholder: '远程目录', text: this.filepath })
  393. .layoutWeight(1)
  394. .maxLines(1)
  395. .onChange((value: string) => {
  396. this.filepath = value;
  397. });
  398. }
  399. .alignItems(VerticalAlign.Center)
  400. .visibility(this.driveType === RemoteDriveType.Navidrome || this.driveType === RemoteDriveType.Jellyfin || this.driveType === RemoteDriveType.Emby
  401. ? Visibility.None
  402. : Visibility.Visible)
  403. // this.buildHelperText('从共享根开始的路径,例如 /music 或 /音乐/歌单1')
  404. Row({ space: 8 }) {
  405. Text('用户名')
  406. .fontSize(14)
  407. .fontColor($r('app.color.index_tab_font_color'));
  408. TextInput({ placeholder: '请输入用户名', text: this.username })
  409. .layoutWeight(1)
  410. .maxLines(1)
  411. .onChange((value: string) => {
  412. this.username = value;
  413. });
  414. }
  415. .alignItems(VerticalAlign.Center);
  416. Row({ space: 8 }) {
  417. Text('密码')
  418. .fontSize(14)
  419. .fontColor($r('app.color.index_tab_font_color'));
  420. TextInput({ placeholder: '请输入密码', text: this.password })
  421. .type(InputType.Password)
  422. .layoutWeight(1)
  423. .maxLines(2)
  424. .onChange((value: string) => {
  425. this.password = value;
  426. });
  427. }
  428. .alignItems(VerticalAlign.Center);
  429. if (this.driveType === RemoteDriveType.WebDav
  430. || this.driveType === RemoteDriveType.Jellyfin
  431. || this.driveType === RemoteDriveType.Emby
  432. || this.driveType === RemoteDriveType.AudioStation
  433. || this.driveType === RemoteDriveType.Plex) {
  434. Row({ space: 12 }) {
  435. Text('启用HTTPS')
  436. .fontSize(14)
  437. .fontColor($r('app.color.index_tab_font_color'));
  438. Toggle({ type: ToggleType.Switch, isOn: this.enableHttps })
  439. .selectedColor(this.themeColor)
  440. .onChange((isOn: boolean) => {
  441. this.enableHttps = isOn;
  442. if (!this.portCustomized &&
  443. (this.driveType === RemoteDriveType.WebDav
  444. || this.driveType === RemoteDriveType.Jellyfin
  445. || this.driveType === RemoteDriveType.Emby
  446. || this.driveType === RemoteDriveType.AudioStation
  447. || this.driveType === RemoteDriveType.Plex)) {
  448. this.updatePortState(this.getDefaultPort(), false);
  449. }
  450. });
  451. }
  452. .width('100%')
  453. .justifyContent(FlexAlign.SpaceBetween)
  454. .alignItems(VerticalAlign.Center);
  455. }
  456. } // end non-baidu section
  457. // 按钮
  458. Row({ space: 12 }) {
  459. Button('取消', { type: ButtonType.Capsule })
  460. .backgroundColor($r('app.color.cancel_button_background'))
  461. .fontColor($r('app.color.cancel_button_text'))
  462. .layoutWeight(1)
  463. .onClick(() => {
  464. this.onCancel?.();
  465. });
  466. Button(this.isEditMode ? '保存' : '添加', { type: ButtonType.Capsule })
  467. .backgroundColor(this.themeColor)
  468. .layoutWeight(1)
  469. .onClick(() => {
  470. const needHost = this.driveType !== RemoteDriveType.Baidu;
  471. if (!this.accountName || (needHost && !this.host)) {
  472. ToastUtil.showToast('请填写账户名称和服务器地址');
  473. return;
  474. }
  475. if (this.driveType === RemoteDriveType.Baidu && !this.baiduAccessToken) {
  476. ToastUtil.showToast('请先完成百度网盘授权');
  477. return;
  478. }
  479. const updatedAccount = new WebDavAccount();
  480. if (this.isEditMode) {
  481. updatedAccount.id = this.account.id;
  482. }
  483. updatedAccount.name = this.accountName;
  484. updatedAccount.host = this.driveType === RemoteDriveType.Baidu ? 'pan.baidu.com' : this.host;
  485. updatedAccount.port = this.driveType === RemoteDriveType.Baidu ? 443 : this.port;
  486. updatedAccount.filepath = this.filepath;
  487. updatedAccount.account = this.driveType === RemoteDriveType.Baidu ? '' : this.username;
  488. updatedAccount.password = this.driveType === RemoteDriveType.Baidu ? '' : this.password;
  489. updatedAccount.enableHttps = this.enableHttps;
  490. updatedAccount.coverPath = this.coverPath;
  491. updatedAccount.isActivate = true;
  492. updatedAccount.localHost = '';
  493. updatedAccount.isUseLocalHost = false;
  494. updatedAccount.lyricFilePath = '';
  495. updatedAccount.uploadFilePath = '';
  496. updatedAccount.imageFilePath = '';
  497. updatedAccount.webType = this.driveType;
  498. updatedAccount.smbShare = this.shareName;
  499. updatedAccount.smbDomain = this.domain;
  500. updatedAccount.navidromeBasePath = this.normalizeNavidromeBasePath(this.navidromeBasePath);
  501. updatedAccount.jellyfinBasePath = this.normalizeJellyfinBasePath(this.jellyfinBasePath);
  502. updatedAccount.embyBasePath = this.normalizeEmbyBasePath(this.embyBasePath);
  503. updatedAccount.ftpEncoding = this.ftpEncoding && this.ftpEncoding.length > 0 ? this.ftpEncoding : 'utf-8';
  504. updatedAccount.baiduAccessToken = this.baiduAccessToken;
  505. updatedAccount.baiduRefreshToken = this.baiduRefreshToken;
  506. updatedAccount.baiduTokenExpiresAt = this.baiduTokenExpiresAt;
  507. this.onConfirm?.(updatedAccount);
  508. });
  509. }
  510. .width('100%')
  511. .margin({ top: 8 });
  512. }
  513. .padding(24)
  514. .width('90%')
  515. .borderRadius(16);
  516. }
  517. @Builder
  518. private buildTypeSelector() {
  519. Column({ space: 8 }) {
  520. Text('协议类型')
  521. .fontSize(14)
  522. .fontColor($r('app.color.index_tab_font_color'))
  523. .fontWeight(FontWeight.Medium)
  524. .alignSelf(ItemAlign.Start);
  525. Row({ space: 12 }) {
  526. this.buildTypeButton('WebDAV', RemoteDriveType.WebDav);
  527. this.buildTypeButton('SMB', RemoteDriveType.Smb);
  528. this.buildTypeButton('Navidrome', RemoteDriveType.Navidrome);
  529. this.buildTypeButton('Jellyfin', RemoteDriveType.Jellyfin);
  530. this.buildTypeButton('Emby', RemoteDriveType.Emby);
  531. this.buildTypeButton('AudioStation', RemoteDriveType.AudioStation);
  532. this.buildTypeButton('Plex', RemoteDriveType.Plex);
  533. this.buildTypeButton('FTP', RemoteDriveType.Ftp);
  534. this.buildTypeButton('百度网盘', RemoteDriveType.Baidu);
  535. }
  536. .width('100%');
  537. }
  538. .width('100%');
  539. }
  540. @Builder
  541. private buildTypeButton(label: string, type: RemoteDriveType) {
  542. Button(label)
  543. .type(ButtonType.Capsule)
  544. .backgroundColor(this.driveType === type ? this.themeColor : (this.isDarkMode ? '#2C2C2E' : $r('app.color.input_background')))
  545. .fontColor(this.driveType === type ? Color.White : $r('app.color.index_tab_font_color'))
  546. .onClick(() => {
  547. this.handleDriveTypeChange(type);
  548. });
  549. }
  550. private handleDriveTypeChange(type: RemoteDriveType, forceApply: boolean = false) {
  551. const changed = this.driveType !== type;
  552. if (!changed && !forceApply) {
  553. return;
  554. }
  555. if (this.driveType === RemoteDriveType.Baidu && type !== RemoteDriveType.Baidu) {
  556. this.closeBaiduAuthDialog();
  557. }
  558. this.driveType = type;
  559. if (type === RemoteDriveType.Smb || type === RemoteDriveType.Ftp) {
  560. this.enableHttps = false;
  561. }
  562. this.applyTypeDefaults(type, true);
  563. }
  564. private applyTypeDefaults(type: RemoteDriveType, forcePort: boolean = false) {
  565. if (!this.nameCustomized) {
  566. this.accountName = this.getDefaultAccountName(type);
  567. }
  568. if (type === RemoteDriveType.Navidrome && !this.navBasePathCustomized) {
  569. this.navidromeBasePath = '/rest';
  570. }
  571. if (type === RemoteDriveType.Jellyfin && !this.jellyfinBasePathCustomized) {
  572. this.jellyfinBasePath = '';
  573. }
  574. if (type === RemoteDriveType.Emby && !this.embyBasePathCustomized) {
  575. this.embyBasePath = '';
  576. }
  577. if ((type === RemoteDriveType.Jellyfin || type === RemoteDriveType.Emby) && (!this.filepath || this.filepath.length === 0)) {
  578. this.filepath = '/';
  579. }
  580. if (type === RemoteDriveType.Ftp && (!this.ftpEncoding || this.ftpEncoding.length === 0)) {
  581. this.ftpEncoding = 'utf-8';
  582. }
  583. if (type === RemoteDriveType.Baidu) {
  584. this.enableHttps = true;
  585. this.host = 'pan.baidu.com';
  586. this.username = '';
  587. this.password = '';
  588. this.shareName = '';
  589. this.domain = '';
  590. if (!this.filepath || this.filepath.length === 0) {
  591. this.filepath = '/';
  592. }
  593. }
  594. this.updatePortForType(type, forcePort);
  595. }
  596. private updatePortForType(type: RemoteDriveType, force: boolean = false) {
  597. if (!force && this.portCustomized) {
  598. return;
  599. }
  600. this.updatePortState(this.getDefaultPort(), false);
  601. }
  602. private getDefaultAccountName(type: RemoteDriveType): string {
  603. switch (type) {
  604. case RemoteDriveType.Smb:
  605. return '新建SMB';
  606. case RemoteDriveType.Navidrome:
  607. return '新建Navidrome';
  608. case RemoteDriveType.Jellyfin:
  609. return '新建Jellyfin';
  610. case RemoteDriveType.Emby:
  611. return '新建Emby';
  612. case RemoteDriveType.AudioStation:
  613. return '新建AudioStation';
  614. case RemoteDriveType.Plex:
  615. return '新建Plex';
  616. case RemoteDriveType.Ftp:
  617. return '新建FTP';
  618. case RemoteDriveType.Baidu:
  619. return '百度网盘';
  620. default:
  621. return '新建WebDAV';
  622. }
  623. }
  624. private getServerLabel(): string {
  625. return '服务器';
  626. }
  627. private getServerHint(): string {
  628. switch (this.driveType) {
  629. case RemoteDriveType.Smb:
  630. return '支持 smb://user:pass@host/share 输入,自动拆分账户、共享、路径';
  631. case RemoteDriveType.Navidrome:
  632. return '可粘贴 Navidrome/Subsonic 连接(如 https://user:pass@host:4533/rest),自动填充参数';
  633. case RemoteDriveType.Jellyfin:
  634. return '可粘贴 Jellyfin 连接(如 https://user:pass@host:8096/jellyfin),自动填充参数';
  635. case RemoteDriveType.Emby:
  636. return '可粘贴 Emby 连接(如 https://user:pass@host:8096/emby),自动填充参数';
  637. case RemoteDriveType.AudioStation:
  638. return '可粘贴 AudioStation 连接(如 https://user:pass@host:5001),自动填充参数';
  639. case RemoteDriveType.Plex:
  640. return '可粘贴 Plex 连接(如 http://user:pass@host:32400),自动填充参数';
  641. case RemoteDriveType.Ftp:
  642. return '支持 ftp://user:pass@host:21/path 输入,自动填充账户和目录';
  643. case RemoteDriveType.Baidu:
  644. return '百度网盘无需服务器地址,请使用下方按钮完成授权';
  645. default:
  646. return '支持 https://user:pass@host:port/path WebDAV 连接串,自动填充账户、端口和目录';
  647. }
  648. }
  649. private getPortPlaceholder(): string {
  650. return `默认: ${this.getDefaultPort()}`;
  651. }
  652. private getDefaultPort(): number {
  653. if (this.driveType === RemoteDriveType.Smb) {
  654. return 445;
  655. }
  656. if (this.driveType === RemoteDriveType.Navidrome) {
  657. return this.enableHttps ? 443 : 4533;
  658. }
  659. if (this.driveType === RemoteDriveType.Jellyfin) {
  660. return this.enableHttps ? 8920 : 8096;
  661. }
  662. if (this.driveType === RemoteDriveType.Emby) {
  663. return this.enableHttps ? 8920 : 8096;
  664. }
  665. if (this.driveType === RemoteDriveType.AudioStation) {
  666. return this.enableHttps ? 5001 : 5000;
  667. }
  668. if (this.driveType === RemoteDriveType.Plex) {
  669. return 32400;
  670. }
  671. if (this.driveType === RemoteDriveType.Ftp) {
  672. return 21;
  673. }
  674. if (this.driveType === RemoteDriveType.Baidu) {
  675. return 443;
  676. }
  677. return this.enableHttps ? 443 : 5005;
  678. }
  679. private updatePortState(value: number, customized: boolean, syncInput: boolean = true) {
  680. this.port = value;
  681. this.portCustomized = customized;
  682. if (syncInput) {
  683. this.portInput = value > 0 ? value.toString() : '';
  684. }
  685. }
  686. @Builder
  687. private buildHelperText(text: string) {
  688. if (text && text.trim().length > 0) {
  689. Text(text)
  690. .fontSize(12)
  691. .fontColor(this.isDarkMode ? '#8E8E93' : '#999999')
  692. .opacity(0.9)
  693. .alignSelf(ItemAlign.Start)
  694. }
  695. }
  696. @Builder
  697. private buildFileDirectoryRow() {
  698. Row({ space: 8 }) {
  699. Text('文件目录')
  700. .fontSize(14)
  701. .fontColor($r('app.color.index_tab_font_color'));
  702. TextInput({ placeholder: '远程目录', text: this.filepath })
  703. .layoutWeight(1)
  704. .maxLines(1)
  705. .onChange((value: string) => {
  706. this.filepath = value;
  707. });
  708. }
  709. .alignItems(VerticalAlign.Center);
  710. }
  711. private tryParseConnectionString(input: string): boolean {
  712. const parsed = this.parseConnectionString(input);
  713. if (!parsed) {
  714. return false;
  715. }
  716. const protocol = parsed.protocol.toLowerCase();
  717. if (protocol === 'smb' || protocol === 'cifs') {
  718. if (this.driveType !== RemoteDriveType.Smb) {
  719. this.handleDriveTypeChange(RemoteDriveType.Smb);
  720. }
  721. this.applyParsedSmb(parsed);
  722. ToastUtil.showToast('已解析 SMB 连接');
  723. return true;
  724. }
  725. if (protocol === 'http' || protocol === 'https') {
  726. if (Number(this.driveType) === RemoteDriveType.Jellyfin) {
  727. this.applyParsedJellyfin(parsed);
  728. ToastUtil.showToast('已解析 Jellyfin 连接');
  729. return true;
  730. }
  731. if (Number(this.driveType) === RemoteDriveType.Emby) {
  732. this.applyParsedEmby(parsed);
  733. ToastUtil.showToast('已解析 Emby 连接');
  734. return true;
  735. }
  736. if (Number(this.driveType) === RemoteDriveType.Navidrome) {
  737. this.applyParsedNavidrome(parsed);
  738. ToastUtil.showToast('已解析 Navidrome 连接');
  739. return true;
  740. }
  741. const looksNav = this.guessNavidromePath(parsed.path);
  742. if (looksNav) {
  743. if (Number(this.driveType) !== RemoteDriveType.Navidrome) {
  744. this.handleDriveTypeChange(RemoteDriveType.Navidrome);
  745. }
  746. this.applyParsedNavidrome(parsed);
  747. ToastUtil.showToast('已解析 Navidrome 连接');
  748. return true;
  749. }
  750. const looksJellyfin = this.guessJellyfinPath(parsed.path);
  751. if (looksJellyfin) {
  752. if (Number(this.driveType) !== RemoteDriveType.Jellyfin) {
  753. this.handleDriveTypeChange(RemoteDriveType.Jellyfin);
  754. }
  755. this.applyParsedJellyfin(parsed);
  756. ToastUtil.showToast('已解析 Jellyfin 连接');
  757. return true;
  758. }
  759. const looksEmby = this.guessEmbyPath(parsed.path);
  760. if (looksEmby) {
  761. if (Number(this.driveType) !== RemoteDriveType.Emby) {
  762. this.handleDriveTypeChange(RemoteDriveType.Emby);
  763. }
  764. this.applyParsedEmby(parsed);
  765. ToastUtil.showToast('已解析 Emby 连接');
  766. return true;
  767. }
  768. if (this.driveType !== RemoteDriveType.WebDav) {
  769. this.handleDriveTypeChange(RemoteDriveType.WebDav);
  770. }
  771. this.applyParsedWebDav(parsed);
  772. ToastUtil.showToast('已解析 WebDAV 连接');
  773. return true;
  774. }
  775. if (protocol === 'ftp' || protocol === 'ftps') {
  776. if (this.driveType !== RemoteDriveType.Ftp) {
  777. this.handleDriveTypeChange(RemoteDriveType.Ftp);
  778. }
  779. this.applyParsedFtp(parsed);
  780. ToastUtil.showToast('已解析 FTP 连接');
  781. return true;
  782. }
  783. return false;
  784. }
  785. private applyParsedWebDav(parsed: ParsedConnectionParts): void {
  786. this.enableHttps = parsed.protocol === 'https';
  787. this.host = parsed.host;
  788. if (parsed.port) {
  789. this.updatePortState(parsed.port, true);
  790. } else {
  791. this.updatePortForType(RemoteDriveType.WebDav, true);
  792. }
  793. if (parsed.username) {
  794. this.username = parsed.username;
  795. }
  796. if (parsed.password) {
  797. this.password = parsed.password;
  798. }
  799. this.filepath = parsed.path?.length ? parsed.path : '/';
  800. }
  801. private applyParsedSmb(parsed: ParsedConnectionParts): void {
  802. this.host = parsed.host;
  803. if (parsed.port) {
  804. this.updatePortState(parsed.port, true);
  805. } else {
  806. this.updatePortForType(RemoteDriveType.Smb, true);
  807. }
  808. if (parsed.username) {
  809. this.username = parsed.username;
  810. }
  811. if (parsed.password) {
  812. this.password = parsed.password;
  813. }
  814. const segments = parsed.path?.replace(/^\/+/, '').split('/') ?? [];
  815. this.shareName = segments.length > 0 && segments[0].length > 0 ? segments[0] : this.shareName;
  816. const relative = segments.length > 1 ? segments.slice(1).join('/') : '';
  817. this.filepath = relative ? `/${relative}` : '/';
  818. }
  819. private applyParsedNavidrome(parsed: ParsedConnectionParts): void {
  820. this.enableHttps = parsed.protocol === 'https';
  821. this.host = parsed.host;
  822. if (parsed.port) {
  823. this.updatePortState(parsed.port, true);
  824. } else {
  825. this.updatePortForType(RemoteDriveType.Navidrome, true);
  826. }
  827. if (parsed.username) {
  828. this.username = parsed.username;
  829. }
  830. if (parsed.password) {
  831. this.password = parsed.password;
  832. }
  833. this.navidromeBasePath = this.normalizeNavidromeBasePath(parsed.path ?? this.navidromeBasePath);
  834. this.navBasePathCustomized = true;
  835. this.filepath = '/';
  836. }
  837. private applyParsedJellyfin(parsed: ParsedConnectionParts): void {
  838. this.enableHttps = parsed.protocol === 'https';
  839. this.host = parsed.host;
  840. if (parsed.port) {
  841. this.updatePortState(parsed.port, true);
  842. } else {
  843. this.updatePortForType(RemoteDriveType.Jellyfin, true);
  844. }
  845. if (parsed.username) {
  846. this.username = parsed.username;
  847. }
  848. if (parsed.password) {
  849. this.password = parsed.password;
  850. }
  851. this.jellyfinBasePath = this.normalizeJellyfinBasePath(parsed.path ?? this.jellyfinBasePath);
  852. this.jellyfinBasePathCustomized = true;
  853. this.filepath = '/';
  854. }
  855. private applyParsedEmby(parsed: ParsedConnectionParts): void {
  856. this.enableHttps = parsed.protocol === 'https';
  857. this.host = parsed.host;
  858. if (parsed.port) {
  859. this.updatePortState(parsed.port, true);
  860. } else {
  861. this.updatePortForType(RemoteDriveType.Emby, true);
  862. }
  863. if (parsed.username) {
  864. this.username = parsed.username;
  865. }
  866. if (parsed.password) {
  867. this.password = parsed.password;
  868. }
  869. this.embyBasePath = this.normalizeEmbyBasePath(parsed.path ?? this.embyBasePath);
  870. this.embyBasePathCustomized = true;
  871. this.filepath = '/';
  872. }
  873. private applyParsedFtp(parsed: ParsedConnectionParts): void {
  874. this.enableHttps = false;
  875. this.host = parsed.host;
  876. if (parsed.port) {
  877. this.updatePortState(parsed.port, true);
  878. } else {
  879. this.updatePortForType(RemoteDriveType.Ftp, true);
  880. }
  881. if (parsed.username) {
  882. this.username = parsed.username;
  883. }
  884. if (parsed.password) {
  885. this.password = parsed.password;
  886. }
  887. this.filepath = parsed.path && parsed.path.length > 0 ? parsed.path : '/';
  888. }
  889. private guessNavidromePath(path?: string): boolean {
  890. if (!path) {
  891. return false;
  892. }
  893. const lower = path.toLowerCase();
  894. return lower.includes('/rest') || lower.includes('navidrome');
  895. }
  896. private guessJellyfinPath(path?: string): boolean {
  897. if (!path) {
  898. return false;
  899. }
  900. return path.toLowerCase().includes('jellyfin');
  901. }
  902. private guessEmbyPath(path?: string): boolean {
  903. if (!path) {
  904. return false;
  905. }
  906. return path.toLowerCase().includes('emby');
  907. }
  908. private normalizeNavidromeBasePath(value: string): string {
  909. if (!value || value.trim().length === 0) {
  910. return '/rest';
  911. }
  912. let normalized = value.trim();
  913. if (!normalized.startsWith('/')) {
  914. normalized = `/${normalized}`;
  915. }
  916. if (normalized.length > 1 && normalized.endsWith('/')) {
  917. normalized = normalized.slice(0, -1);
  918. }
  919. return normalized.length === 0 ? '/rest' : normalized;
  920. }
  921. private normalizeJellyfinBasePath(value: string): string {
  922. if (!value || value.trim().length === 0) {
  923. return '';
  924. }
  925. let normalized = value.trim();
  926. if (!normalized.startsWith('/')) {
  927. normalized = `/${normalized}`;
  928. }
  929. if (normalized.length > 1 && normalized.endsWith('/')) {
  930. normalized = normalized.slice(0, -1);
  931. }
  932. return normalized === '/' ? '' : normalized;
  933. }
  934. private normalizeEmbyBasePath(value: string): string {
  935. if (!value || value.trim().length === 0) {
  936. return '';
  937. }
  938. let normalized = value.trim();
  939. if (!normalized.startsWith('/')) {
  940. normalized = `/${normalized}`;
  941. }
  942. if (normalized.length > 1 && normalized.endsWith('/')) {
  943. normalized = normalized.slice(0, -1);
  944. }
  945. return normalized === '/' ? '' : normalized;
  946. }
  947. private parseConnectionString(input: string): ParsedConnectionParts | null {
  948. const pattern = /^([a-z][a-z0-9+\-.]*):\/\/(?:([^:@\/]+)(?::([^@\/]*))?@)?([^\/:]+)(?::(\d+))?(\/.*)?$/i;
  949. const match = input.match(pattern);
  950. if (!match) {
  951. Logger.warn('heanup RemoteDriveAccountDialog', `连接字符串解析失败: ${input}`);
  952. return null;
  953. }
  954. const protocol = match[1].toLowerCase();
  955. const username = match[2] ? this.safeDecode(match[2]) : undefined;
  956. const password = match[3] ? this.safeDecode(match[3]) : undefined;
  957. const host = match[4];
  958. const port = match[5] ? Number(match[5]) : undefined;
  959. const path = match[6] && match[6].length > 0 ? match[6] : undefined;
  960. return { protocol, username, password, host, port, path };
  961. }
  962. private safeDecode(value: string): string {
  963. try {
  964. return decodeURIComponent(value);
  965. } catch (_error) {
  966. return value;
  967. }
  968. }
  969. @Builder
  970. private buildBaiduAuthSection() {
  971. Column({ space: 12 }) {
  972. Text('百度账号授权')
  973. .fontSize(14)
  974. .fontWeight(FontWeight.Medium)
  975. .fontColor($r('app.color.index_tab_font_color'))
  976. .alignSelf(ItemAlign.Start);
  977. Row({ space: 8 }) {
  978. Button('扫码授权')
  979. .type(ButtonType.Capsule)
  980. .backgroundColor(this.isDarkMode ? '#2C2C2E' : $r('app.color.input_background'))
  981. .fontColor($r('app.color.index_tab_font_color'))
  982. .onClick(() => {
  983. this.handleBaiduDeviceAuthorize();
  984. });
  985. Button('打开百度授权页')
  986. .type(ButtonType.Capsule)
  987. .backgroundColor(this.themeColor)
  988. .fontColor(Color.White)
  989. .onClick(() => {
  990. this.handleBaiduImplicitAuthorize();
  991. });
  992. if (this.baiduAccessToken) {
  993. Button('清除授权')
  994. .type(ButtonType.Capsule)
  995. .backgroundColor(this.isDarkMode ? '#2C2C2E' : $r('app.color.input_background'))
  996. .fontColor($r('app.color.index_tab_font_color'))
  997. .onClick(() => {
  998. this.baiduAccessToken = '';
  999. this.baiduRefreshToken = '';
  1000. this.baiduTokenExpiresAt = 0;
  1001. this.baiduAuthStatus = '已清除授权信息';
  1002. this.closeBaiduAuthDialog();
  1003. });
  1004. }
  1005. }
  1006. Text('可扫码授权或打开百度网盘授权网页,请登录并允许访问网盘文件。')
  1007. .fontSize(13)
  1008. .fontColor(this.isDarkMode ? '#C7C7CC' : '#666666')
  1009. .maxLines(2);
  1010. if (this.baiduAuthStatus) {
  1011. Text(this.baiduAuthStatus)
  1012. .fontSize(13)
  1013. .fontColor(this.isDarkMode ? '#EBEBF5' : '#666666')
  1014. .maxLines(2);
  1015. }
  1016. if (this.baiduAccessToken) {
  1017. Text(`已获取 Access Token,${this.baiduTokenExpiresAt > 0 ? `将在 ${this.getBaiduExpireText()} 过期` : '有效期未知'}`)
  1018. .fontSize(13)
  1019. .fontColor(this.isDarkMode ? '#EBEBF5' : '#666666');
  1020. }
  1021. }
  1022. .width('100%');
  1023. }
  1024. @Builder
  1025. private buildBaiduAuthDialogOverlay() {
  1026. Stack() {
  1027. Column()
  1028. .width('100%')
  1029. .height('100%')
  1030. .backgroundColor('rgba(0,0,0,0.5)')
  1031. .onClick(() => {
  1032. this.closeBaiduAuthDialog();
  1033. });
  1034. Column({ space: 8 }) {
  1035. Row() {
  1036. Text('百度网盘授权')
  1037. .fontSize(16)
  1038. .fontWeight(FontWeight.Medium)
  1039. .fontColor(this.isDarkMode ? Color.White : Color.Black);
  1040. Text('关闭')
  1041. .fontSize(14)
  1042. .fontColor(this.themeColor)
  1043. .onClick(() => {
  1044. this.closeBaiduAuthDialog();
  1045. });
  1046. }
  1047. .width('100%')
  1048. .justifyContent(FlexAlign.SpaceBetween)
  1049. .alignItems(VerticalAlign.Center)
  1050. .padding({ bottom: 4 });
  1051. this.buildBaiduAuthModeSwitch();
  1052. Divider()
  1053. .color(this.isDarkMode ? '#3A3A3C' : '#E5E5EA');
  1054. if (this.baiduAuthMode === 'device') {
  1055. this.buildBaiduDeviceAuthContent();
  1056. } else {
  1057. if (this.baiduAuthProgress > 0 && this.baiduAuthProgress < 100) {
  1058. Text(`页面加载中 ${this.baiduAuthProgress}%`)
  1059. .fontSize(12)
  1060. .fontColor(this.isDarkMode ? '#C7C7CC' : '#666666');
  1061. }
  1062. Web({
  1063. src: this.baiduAuthorizeUrl,
  1064. controller: this.baiduWebController
  1065. })
  1066. .layoutWeight(1)
  1067. .width('100%')
  1068. .javaScriptAccess(true)
  1069. .domStorageAccess(true)
  1070. .onPageEnd((event) => {
  1071. if (event && event.url) {
  1072. this.handleBaiduAuthNavigation(event.url);
  1073. }
  1074. })
  1075. .onProgressChange((event) => {
  1076. if (event) {
  1077. this.baiduAuthProgress = event.newProgress;
  1078. }
  1079. });
  1080. }
  1081. }
  1082. .width('92%')
  1083. .height('85%')
  1084. .padding(16)
  1085. .backgroundColor(this.isDarkMode ? '#1C1C1E' : Color.White)
  1086. .borderRadius(16);
  1087. }
  1088. .width('100%')
  1089. .height('100%');
  1090. }
  1091. @Builder
  1092. private buildBaiduAuthModeSwitch() {
  1093. Row({ space: 8 }) {
  1094. this.buildBaiduAuthModeChip('网页登录', 'web');
  1095. this.buildBaiduAuthModeChip('扫码登录', 'device');
  1096. }
  1097. .width('100%')
  1098. .padding({ top: 4, bottom: 4 });
  1099. }
  1100. @Builder
  1101. private buildBaiduAuthModeChip(label: string, mode: 'web' | 'device') {
  1102. Button(label)
  1103. .type(ButtonType.Capsule)
  1104. .backgroundColor(this.baiduAuthMode === mode ? this.themeColor : (this.isDarkMode ? '#2C2C2E' : $r('app.color.input_background')))
  1105. .fontColor(this.baiduAuthMode === mode ? Color.White : $r('app.color.index_tab_font_color'))
  1106. .onClick(() => {
  1107. this.switchBaiduAuthMode(mode);
  1108. });
  1109. }
  1110. @Builder
  1111. private buildBaiduDeviceAuthContent() {
  1112. Column({ space: 12 }) {
  1113. if (this.baiduDeviceQrUrl) {
  1114. Image(this.baiduDeviceQrUrl)
  1115. .width(220)
  1116. .height(220)
  1117. .borderRadius(12)
  1118. .objectFit(ImageFit.Contain)
  1119. .alignSelf(ItemAlign.Center);
  1120. } else {
  1121. Column() {
  1122. Text('正在获取二维码...')
  1123. .fontColor(this.isDarkMode ? '#C7C7CC' : '#666666');
  1124. }
  1125. .width(220)
  1126. .height(220)
  1127. .alignSelf(ItemAlign.Center)
  1128. .backgroundColor(this.isDarkMode ? '#2C2C2E' : $r('app.color.input_background'))
  1129. .borderRadius(12)
  1130. .justifyContent(FlexAlign.Center)
  1131. .alignItems(HorizontalAlign.Center);
  1132. }
  1133. if (this.baiduDeviceUserCode) {
  1134. Text(`设备码: ${this.baiduDeviceUserCode}`)
  1135. .fontSize(14)
  1136. .fontWeight(FontWeight.Medium)
  1137. .fontColor($r('app.color.index_tab_font_color'));
  1138. }
  1139. if (this.baiduDeviceVerifyUrl) {
  1140. Text(`或在浏览器访问 ${this.baiduDeviceVerifyUrl} 输入设备码完成授权`)
  1141. .fontSize(12)
  1142. .fontColor(this.isDarkMode ? '#C7C7CC' : '#666666')
  1143. .maxLines(2);
  1144. }
  1145. if (this.baiduDeviceExpireAt) {
  1146. Text(`二维码将于 ${this.getBaiduDeviceRemainText()} 过期`)
  1147. .fontSize(12)
  1148. .fontColor(this.isDarkMode ? '#C7C7CC' : '#666666');
  1149. }
  1150. if (this.baiduAuthStatus) {
  1151. Text(this.baiduAuthStatus)
  1152. .fontSize(13)
  1153. .fontColor(this.isDarkMode ? '#EBEBF5' : '#666666')
  1154. .maxLines(2);
  1155. }
  1156. Row({ space: 12 }) {
  1157. Button('刷新二维码')
  1158. .type(ButtonType.Capsule)
  1159. .backgroundColor(this.themeColor)
  1160. .fontColor(Color.White)
  1161. .onClick(() => {
  1162. this.handleBaiduDeviceAuthorize();
  1163. });
  1164. Button('改用网页登录')
  1165. .type(ButtonType.Capsule)
  1166. .backgroundColor(this.isDarkMode ? '#2C2C2E' : $r('app.color.input_background'))
  1167. .fontColor($r('app.color.index_tab_font_color'))
  1168. .onClick(() => {
  1169. this.handleBaiduImplicitAuthorize();
  1170. });
  1171. }
  1172. .width('100%')
  1173. .justifyContent(FlexAlign.Start);
  1174. }
  1175. .layoutWeight(1)
  1176. .width('100%');
  1177. }
  1178. private getBaiduExpireText(): string {
  1179. if (!this.baiduTokenExpiresAt) {
  1180. return '';
  1181. }
  1182. const remain = Math.max(0, this.baiduTokenExpiresAt - Date.now());
  1183. const hours = Math.floor(remain / 3600000);
  1184. const minutes = Math.floor((remain % 3600000) / 60000);
  1185. return hours > 0 ? `${hours}小时${minutes}分后` : `${minutes}分钟后`;
  1186. }
  1187. private switchBaiduAuthMode(mode: 'web' | 'device'): void {
  1188. if (mode === this.baiduAuthMode && this.showBaiduAuthDialog) {
  1189. return;
  1190. }
  1191. if (mode === 'device') {
  1192. this.handleBaiduDeviceAuthorize();
  1193. } else {
  1194. this.handleBaiduImplicitAuthorize();
  1195. }
  1196. }
  1197. private async handleBaiduDeviceAuthorize(): Promise<void> {
  1198. this.stopBaiduDevicePolling();
  1199. this.baiduAuthMode = 'device';
  1200. this.resetBaiduDeviceState(false);
  1201. this.baiduAuthorizeUrl = '';
  1202. this.baiduAuthProgress = 0;
  1203. this.baiduAuthStateToken = '';
  1204. this.baiduAuthStatus = '正在获取扫码授权信息...';
  1205. try {
  1206. const codeInfo = await requestBaiduDeviceCode();
  1207. this.baiduDeviceCode = codeInfo.device_code;
  1208. this.baiduDeviceUserCode = codeInfo.user_code;
  1209. this.baiduDeviceVerifyUrl = codeInfo.verification_url;
  1210. this.baiduDeviceQrUrl = codeInfo.qrcode_url ?? '';
  1211. this.baiduDeviceExpireAt = Date.now() + (codeInfo.expires_in ?? 0) * 1000;
  1212. this.baiduDeviceInterval = Math.max(3, codeInfo.interval ?? 5);
  1213. this.baiduAuthStatus = '请在百度网盘App中扫码确认或输入设备码授权';
  1214. this.showBaiduAuthDialog = true;
  1215. this.startBaiduDevicePolling();
  1216. } catch (error) {
  1217. const message = (error as Error).message ?? '请求失败';
  1218. this.baiduAuthStatus = `获取设备码失败: ${message}`;
  1219. ToastUtil.showToast('获取百度扫码授权失败');
  1220. }
  1221. }
  1222. private startBaiduDevicePolling(): void {
  1223. this.stopBaiduDevicePolling();
  1224. if (!this.baiduDeviceCode) {
  1225. return;
  1226. }
  1227. const intervalMs = Math.max(3000, this.baiduDeviceInterval * 1000);
  1228. this.baiduDevicePollTimer = setInterval(() => {
  1229. this.queryBaiduDeviceToken();
  1230. }, intervalMs) as number;
  1231. }
  1232. private stopBaiduDevicePolling(): void {
  1233. if (this.baiduDevicePollTimer) {
  1234. clearInterval(this.baiduDevicePollTimer);
  1235. this.baiduDevicePollTimer = 0;
  1236. }
  1237. this.baiduDevicePolling = false;
  1238. }
  1239. private async queryBaiduDeviceToken(): Promise<void> {
  1240. if (!this.baiduDeviceCode || this.baiduDevicePolling) {
  1241. return;
  1242. }
  1243. this.baiduDevicePolling = true;
  1244. try {
  1245. const result: BaiduDeviceTokenResult = await pollBaiduDeviceToken(this.baiduDeviceCode);
  1246. const tokenResult = result as BaiduTokenResultShape;
  1247. if (tokenResult.access_token !== undefined && tokenResult.access_token.length > 0) {
  1248. this.baiduAccessToken = tokenResult.access_token;
  1249. this.baiduRefreshToken = tokenResult.refresh_token ?? '';
  1250. this.baiduTokenExpiresAt = tokenResult.expires_in ? Date.now() + tokenResult.expires_in * 1000 : 0;
  1251. this.baiduAuthStatus = '授权成功,可保存账户';
  1252. ToastUtil.showToast('百度授权成功');
  1253. this.closeBaiduAuthDialog();
  1254. return;
  1255. }
  1256. const errorResult = result as BaiduTokenResultShape;
  1257. if (errorResult.error === undefined) {
  1258. return;
  1259. }
  1260. switch (errorResult.error) {
  1261. case 'authorization_pending':
  1262. this.baiduAuthStatus = '请在百度网盘App扫码并确认';
  1263. break;
  1264. case 'slow_down':
  1265. this.baiduAuthStatus = '请求过于频繁,稍后自动重试';
  1266. this.baiduDeviceInterval += 2;
  1267. this.startBaiduDevicePolling();
  1268. break;
  1269. case 'expired_token':
  1270. case 'invalid_grant':
  1271. this.baiduAuthStatus = '二维码已过期,请刷新重新扫码';
  1272. this.stopBaiduDevicePolling();
  1273. break;
  1274. default:
  1275. this.baiduAuthStatus = `授权失败: ${errorResult.error_description ?? errorResult.error}`;
  1276. this.stopBaiduDevicePolling();
  1277. break;
  1278. }
  1279. } catch (error) {
  1280. const message = (error as Error).message ?? '未知错误';
  1281. this.baiduAuthStatus = `授权轮询失败: ${message}`;
  1282. } finally {
  1283. this.baiduDevicePolling = false;
  1284. }
  1285. }
  1286. private getBaiduDeviceRemainText(): string {
  1287. if (!this.baiduDeviceExpireAt) {
  1288. return '稍后';
  1289. }
  1290. const remain = Math.max(0, this.baiduDeviceExpireAt - Date.now());
  1291. const minutes = Math.floor(remain / 60000);
  1292. const seconds = Math.floor((remain % 60000) / 1000);
  1293. return minutes > 0 ? `${minutes}分${seconds}秒后` : `${seconds}秒后`;
  1294. }
  1295. private resetBaiduDeviceState(resetMode: boolean = true): void {
  1296. if (resetMode) {
  1297. this.baiduAuthMode = 'web';
  1298. }
  1299. this.baiduDeviceCode = '';
  1300. this.baiduDeviceUserCode = '';
  1301. this.baiduDeviceVerifyUrl = '';
  1302. this.baiduDeviceQrUrl = '';
  1303. this.baiduDeviceExpireAt = 0;
  1304. this.baiduDeviceInterval = 5;
  1305. this.baiduDevicePolling = false;
  1306. }
  1307. private handleBaiduImplicitAuthorize(): void {
  1308. this.stopBaiduDevicePolling();
  1309. this.resetBaiduDeviceState(false);
  1310. this.baiduAuthMode = 'web';
  1311. this.baiduAuthStatus = '正在打开百度授权页面...';
  1312. this.baiduAuthStateToken = `${Date.now()}`;
  1313. this.baiduAuthProgress = 0;
  1314. this.baiduAuthorizeUrl = this.buildBaiduAuthorizeUrl(this.baiduAuthStateToken);
  1315. this.showBaiduAuthDialog = true;
  1316. }
  1317. private closeBaiduAuthDialog(): void {
  1318. this.showBaiduAuthDialog = false;
  1319. this.baiduAuthorizeUrl = '';
  1320. this.baiduAuthProgress = 0;
  1321. this.baiduAuthStateToken = '';
  1322. this.stopBaiduDevicePolling();
  1323. this.resetBaiduDeviceState();
  1324. }
  1325. private buildBaiduAuthorizeUrl(state: string): string {
  1326. const params = [
  1327. `response_type=token`,
  1328. `client_id=${encodeURIComponent(BaiduConstants.APP_KEY)}`,
  1329. `redirect_uri=${encodeURIComponent(BaiduConstants.OOB_REDIRECT_PARAM)}`,
  1330. `scope=${encodeURIComponent(BaiduConstants.AUTH_SCOPE)}`,
  1331. `display=mobile`,
  1332. `state=${encodeURIComponent(state)}`
  1333. ];
  1334. return `${BaiduConstants.AUTHORIZE_URL}?${params.join('&')}`;
  1335. }
  1336. private handleBaiduAuthNavigation(url: string): void {
  1337. if (!url) {
  1338. return;
  1339. }
  1340. const loginSuccessHttps = BaiduConstants.LOGIN_SUCCESS_URL.replace('http://', 'https://');
  1341. if (!url.startsWith(BaiduConstants.LOGIN_SUCCESS_URL) && !url.startsWith(loginSuccessHttps)) {
  1342. return;
  1343. }
  1344. const fragmentIndex = url.indexOf('#');
  1345. const fragment = fragmentIndex >= 0 ? url.substring(fragmentIndex + 1) : '';
  1346. if (!fragment) {
  1347. return;
  1348. }
  1349. const params = this.parseAuthFragment(fragment);
  1350. const responseState = params.get('state') ?? '';
  1351. if (this.baiduAuthStateToken && responseState && responseState !== this.baiduAuthStateToken) {
  1352. this.baiduAuthStatus = '授权状态校验失败,请重试';
  1353. this.closeBaiduAuthDialog();
  1354. return;
  1355. }
  1356. const error = params.get('error');
  1357. if (error) {
  1358. const description = params.get('error_description') ?? error;
  1359. this.baiduAuthStatus = `授权失败: ${description}`;
  1360. this.closeBaiduAuthDialog();
  1361. return;
  1362. }
  1363. const accessToken = params.get('access_token');
  1364. if (!accessToken) {
  1365. return;
  1366. }
  1367. const expiresIn = Number(params.get('expires_in') ?? '0');
  1368. this.baiduAccessToken = accessToken;
  1369. this.baiduRefreshToken = '';
  1370. this.baiduTokenExpiresAt = expiresIn > 0 ? Date.now() + expiresIn * 1000 : 0;
  1371. this.baiduAuthStatus = '授权成功,可保存账户';
  1372. ToastUtil.showToast('百度授权成功');
  1373. this.closeBaiduAuthDialog();
  1374. }
  1375. private parseAuthFragment(fragment: string): Map<string, string> {
  1376. const params = new Map<string, string>();
  1377. const pairs = fragment.split('&');
  1378. for (let i = 0; i < pairs.length; i++) {
  1379. const pair = pairs[i];
  1380. if (!pair || pair.length === 0) {
  1381. continue;
  1382. }
  1383. const separator = pair.indexOf('=');
  1384. const key = separator >= 0 ? pair.substring(0, separator) : pair;
  1385. const value = separator >= 0 ? pair.substring(separator + 1) : '';
  1386. params.set(decodeURIComponent(key), decodeURIComponent(value));
  1387. }
  1388. return params;
  1389. }
  1390. /**
  1391. * 处理选择封面
  1392. */
  1393. private async handleSelectCover() {
  1394. try {
  1395. // 需要获取上下文,这里通过全局上下文获取
  1396. const context = getContext(this) as Context
  1397. if (!context) {
  1398. ToastUtil.showToast('获取应用上下文失败')
  1399. return
  1400. }
  1401. const selectedPath = await ImagePickerUtil.selectSingleWebDavCover(context)
  1402. if (selectedPath) {
  1403. // 如果之前有封面,删除旧封面
  1404. if (this.coverPath && this.coverPath !== this.account.coverPath) {
  1405. ImagePickerUtil.deleteImage(this.coverPath)
  1406. }
  1407. this.coverPath = selectedPath
  1408. Logger.info('heanup RemoteDriveAccountDialog', `选择封面成功: ${selectedPath}`)
  1409. }
  1410. } catch (error) {
  1411. Logger.error('heanup RemoteDriveAccountDialog', `选择封面失败: ${(error as Error).message}`)
  1412. ToastUtil.showToast('选择封面失败')
  1413. }
  1414. }
  1415. /**
  1416. * 处理移除封面
  1417. */
  1418. private handleRemoveCover() {
  1419. if (this.coverPath) {
  1420. if(!this.account){
  1421. this.coverPath = ''
  1422. return
  1423. }
  1424. // 只有当封面不是原来的封面时才删除文件
  1425. if (this.coverPath !== this.account.coverPath) {
  1426. ImagePickerUtil.deleteImage(this.coverPath)
  1427. }
  1428. this.coverPath = ''
  1429. Logger.info('heanup RemoteDriveAccountDialog', '移除封面成功')
  1430. }
  1431. }
  1432. }
  1433. export function getCloudDiskIcon(type: number): ResourceStr {
  1434. switch ( type)
  1435. {
  1436. case RemoteDriveType.WebDav:
  1437. return $r('app.media.webdav');
  1438. case RemoteDriveType.Smb:
  1439. return $r('app.media.smb');
  1440. case RemoteDriveType.Ftp:
  1441. return $r('app.media.ftp');
  1442. case RemoteDriveType.Navidrome:
  1443. return $r('app.media.navidrome');
  1444. case RemoteDriveType.Jellyfin:
  1445. return $r('app.media.jellyfin');
  1446. case RemoteDriveType.Emby:
  1447. return $r('app.media.emby');
  1448. case RemoteDriveType.AudioStation:
  1449. return $r('app.media.cloudDisk');
  1450. case RemoteDriveType.Plex:
  1451. return $r('app.media.cloudDisk');
  1452. case RemoteDriveType.Baidu:
  1453. return $r('app.media.baiduwp');
  1454. case RemoteDriveType.ALi:
  1455. return $r('app.media.ali_disk');
  1456. case RemoteDriveType.DISK_123:
  1457. return $r('app.media.ali_disk');
  1458. }
  1459. return $r('app.media.cloudDisk');
  1460. }