RemoteDriveAccountDialog.ets 52 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528
  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 || this.driveType === RemoteDriveType.Jellyfin || this.driveType === RemoteDriveType.Emby) {
  430. Row({ space: 12 }) {
  431. Text('启用HTTPS')
  432. .fontSize(14)
  433. .fontColor($r('app.color.index_tab_font_color'));
  434. Toggle({ type: ToggleType.Switch, isOn: this.enableHttps })
  435. .selectedColor(this.themeColor)
  436. .onChange((isOn: boolean) => {
  437. this.enableHttps = isOn;
  438. if (!this.portCustomized &&
  439. (this.driveType === RemoteDriveType.WebDav || this.driveType === RemoteDriveType.Jellyfin || this.driveType === RemoteDriveType.Emby)) {
  440. this.updatePortState(this.getDefaultPort(), false);
  441. }
  442. });
  443. }
  444. .width('100%')
  445. .justifyContent(FlexAlign.SpaceBetween)
  446. .alignItems(VerticalAlign.Center);
  447. }
  448. } // end non-baidu section
  449. // 按钮
  450. Row({ space: 12 }) {
  451. Button('取消', { type: ButtonType.Capsule })
  452. .backgroundColor($r('app.color.cancel_button_background'))
  453. .fontColor($r('app.color.cancel_button_text'))
  454. .layoutWeight(1)
  455. .onClick(() => {
  456. this.onCancel?.();
  457. });
  458. Button(this.isEditMode ? '保存' : '添加', { type: ButtonType.Capsule })
  459. .backgroundColor(this.themeColor)
  460. .layoutWeight(1)
  461. .onClick(() => {
  462. const needHost = this.driveType !== RemoteDriveType.Baidu;
  463. if (!this.accountName || (needHost && !this.host)) {
  464. ToastUtil.showToast('请填写账户名称和服务器地址');
  465. return;
  466. }
  467. if (this.driveType === RemoteDriveType.Baidu && !this.baiduAccessToken) {
  468. ToastUtil.showToast('请先完成百度网盘授权');
  469. return;
  470. }
  471. const updatedAccount = new WebDavAccount();
  472. if (this.isEditMode) {
  473. updatedAccount.id = this.account.id;
  474. }
  475. updatedAccount.name = this.accountName;
  476. updatedAccount.host = this.driveType === RemoteDriveType.Baidu ? 'pan.baidu.com' : this.host;
  477. updatedAccount.port = this.driveType === RemoteDriveType.Baidu ? 443 : this.port;
  478. updatedAccount.filepath = this.filepath;
  479. updatedAccount.account = this.driveType === RemoteDriveType.Baidu ? '' : this.username;
  480. updatedAccount.password = this.driveType === RemoteDriveType.Baidu ? '' : this.password;
  481. updatedAccount.enableHttps = this.enableHttps;
  482. updatedAccount.coverPath = this.coverPath;
  483. updatedAccount.isActivate = true;
  484. updatedAccount.localHost = '';
  485. updatedAccount.isUseLocalHost = false;
  486. updatedAccount.lyricFilePath = '';
  487. updatedAccount.uploadFilePath = '';
  488. updatedAccount.imageFilePath = '';
  489. updatedAccount.webType = this.driveType;
  490. updatedAccount.smbShare = this.shareName;
  491. updatedAccount.smbDomain = this.domain;
  492. updatedAccount.navidromeBasePath = this.normalizeNavidromeBasePath(this.navidromeBasePath);
  493. updatedAccount.jellyfinBasePath = this.normalizeJellyfinBasePath(this.jellyfinBasePath);
  494. updatedAccount.embyBasePath = this.normalizeEmbyBasePath(this.embyBasePath);
  495. updatedAccount.ftpEncoding = this.ftpEncoding && this.ftpEncoding.length > 0 ? this.ftpEncoding : 'utf-8';
  496. updatedAccount.baiduAccessToken = this.baiduAccessToken;
  497. updatedAccount.baiduRefreshToken = this.baiduRefreshToken;
  498. updatedAccount.baiduTokenExpiresAt = this.baiduTokenExpiresAt;
  499. this.onConfirm?.(updatedAccount);
  500. });
  501. }
  502. .width('100%')
  503. .margin({ top: 8 });
  504. }
  505. .padding(24)
  506. .width('90%')
  507. .borderRadius(16);
  508. }
  509. @Builder
  510. private buildTypeSelector() {
  511. Column({ space: 8 }) {
  512. Text('协议类型')
  513. .fontSize(14)
  514. .fontColor($r('app.color.index_tab_font_color'))
  515. .fontWeight(FontWeight.Medium)
  516. .alignSelf(ItemAlign.Start);
  517. Row({ space: 12 }) {
  518. this.buildTypeButton('WebDAV', RemoteDriveType.WebDav);
  519. this.buildTypeButton('SMB', RemoteDriveType.Smb);
  520. this.buildTypeButton('Navidrome', RemoteDriveType.Navidrome);
  521. this.buildTypeButton('Jellyfin', RemoteDriveType.Jellyfin);
  522. this.buildTypeButton('Emby', RemoteDriveType.Emby);
  523. this.buildTypeButton('FTP', RemoteDriveType.Ftp);
  524. this.buildTypeButton('百度网盘', RemoteDriveType.Baidu);
  525. }
  526. .width('100%');
  527. }
  528. .width('100%');
  529. }
  530. @Builder
  531. private buildTypeButton(label: string, type: RemoteDriveType) {
  532. Button(label)
  533. .type(ButtonType.Capsule)
  534. .backgroundColor(this.driveType === type ? this.themeColor : (this.isDarkMode ? '#2C2C2E' : $r('app.color.input_background')))
  535. .fontColor(this.driveType === type ? Color.White : $r('app.color.index_tab_font_color'))
  536. .onClick(() => {
  537. this.handleDriveTypeChange(type);
  538. });
  539. }
  540. private handleDriveTypeChange(type: RemoteDriveType, forceApply: boolean = false) {
  541. const changed = this.driveType !== type;
  542. if (!changed && !forceApply) {
  543. return;
  544. }
  545. if (this.driveType === RemoteDriveType.Baidu && type !== RemoteDriveType.Baidu) {
  546. this.closeBaiduAuthDialog();
  547. }
  548. this.driveType = type;
  549. if (type === RemoteDriveType.Smb || type === RemoteDriveType.Ftp) {
  550. this.enableHttps = false;
  551. }
  552. this.applyTypeDefaults(type, true);
  553. }
  554. private applyTypeDefaults(type: RemoteDriveType, forcePort: boolean = false) {
  555. if (!this.nameCustomized) {
  556. this.accountName = this.getDefaultAccountName(type);
  557. }
  558. if (type === RemoteDriveType.Navidrome && !this.navBasePathCustomized) {
  559. this.navidromeBasePath = '/rest';
  560. }
  561. if (type === RemoteDriveType.Jellyfin && !this.jellyfinBasePathCustomized) {
  562. this.jellyfinBasePath = '';
  563. }
  564. if (type === RemoteDriveType.Emby && !this.embyBasePathCustomized) {
  565. this.embyBasePath = '';
  566. }
  567. if ((type === RemoteDriveType.Jellyfin || type === RemoteDriveType.Emby) && (!this.filepath || this.filepath.length === 0)) {
  568. this.filepath = '/';
  569. }
  570. if (type === RemoteDriveType.Ftp && (!this.ftpEncoding || this.ftpEncoding.length === 0)) {
  571. this.ftpEncoding = 'utf-8';
  572. }
  573. if (type === RemoteDriveType.Baidu) {
  574. this.enableHttps = true;
  575. this.host = 'pan.baidu.com';
  576. this.username = '';
  577. this.password = '';
  578. this.shareName = '';
  579. this.domain = '';
  580. if (!this.filepath || this.filepath.length === 0) {
  581. this.filepath = '/';
  582. }
  583. }
  584. this.updatePortForType(type, forcePort);
  585. }
  586. private updatePortForType(type: RemoteDriveType, force: boolean = false) {
  587. if (!force && this.portCustomized) {
  588. return;
  589. }
  590. this.updatePortState(this.getDefaultPort(), false);
  591. }
  592. private getDefaultAccountName(type: RemoteDriveType): string {
  593. switch (type) {
  594. case RemoteDriveType.Smb:
  595. return '新建SMB';
  596. case RemoteDriveType.Navidrome:
  597. return '新建Navidrome';
  598. case RemoteDriveType.Jellyfin:
  599. return '新建Jellyfin';
  600. case RemoteDriveType.Emby:
  601. return '新建Emby';
  602. case RemoteDriveType.Ftp:
  603. return '新建FTP';
  604. case RemoteDriveType.Baidu:
  605. return '百度网盘';
  606. default:
  607. return '新建WebDAV';
  608. }
  609. }
  610. private getServerLabel(): string {
  611. return '服务器';
  612. }
  613. private getServerHint(): string {
  614. switch (this.driveType) {
  615. case RemoteDriveType.Smb:
  616. return '支持 smb://user:pass@host/share 输入,自动拆分账户、共享、路径';
  617. case RemoteDriveType.Navidrome:
  618. return '可粘贴 Navidrome/Subsonic 连接(如 https://user:pass@host:4533/rest),自动填充参数';
  619. case RemoteDriveType.Jellyfin:
  620. return '可粘贴 Jellyfin 连接(如 https://user:pass@host:8096/jellyfin),自动填充参数';
  621. case RemoteDriveType.Emby:
  622. return '可粘贴 Emby 连接(如 https://user:pass@host:8096/emby),自动填充参数';
  623. case RemoteDriveType.Ftp:
  624. return '支持 ftp://user:pass@host:21/path 输入,自动填充账户和目录';
  625. case RemoteDriveType.Baidu:
  626. return '百度网盘无需服务器地址,请使用下方按钮完成授权';
  627. default:
  628. return '支持 https://user:pass@host:port/path WebDAV 连接串,自动填充账户、端口和目录';
  629. }
  630. }
  631. private getPortPlaceholder(): string {
  632. return `默认: ${this.getDefaultPort()}`;
  633. }
  634. private getDefaultPort(): number {
  635. if (this.driveType === RemoteDriveType.Smb) {
  636. return 445;
  637. }
  638. if (this.driveType === RemoteDriveType.Navidrome) {
  639. return this.enableHttps ? 443 : 4533;
  640. }
  641. if (this.driveType === RemoteDriveType.Jellyfin) {
  642. return this.enableHttps ? 8920 : 8096;
  643. }
  644. if (this.driveType === RemoteDriveType.Emby) {
  645. return this.enableHttps ? 8920 : 8096;
  646. }
  647. if (this.driveType === RemoteDriveType.Ftp) {
  648. return 21;
  649. }
  650. if (this.driveType === RemoteDriveType.Baidu) {
  651. return 443;
  652. }
  653. return this.enableHttps ? 443 : 5005;
  654. }
  655. private updatePortState(value: number, customized: boolean, syncInput: boolean = true) {
  656. this.port = value;
  657. this.portCustomized = customized;
  658. if (syncInput) {
  659. this.portInput = value > 0 ? value.toString() : '';
  660. }
  661. }
  662. @Builder
  663. private buildHelperText(text: string) {
  664. if (text && text.trim().length > 0) {
  665. Text(text)
  666. .fontSize(12)
  667. .fontColor(this.isDarkMode ? '#8E8E93' : '#999999')
  668. .opacity(0.9)
  669. .alignSelf(ItemAlign.Start)
  670. }
  671. }
  672. @Builder
  673. private buildFileDirectoryRow() {
  674. Row({ space: 8 }) {
  675. Text('文件目录')
  676. .fontSize(14)
  677. .fontColor($r('app.color.index_tab_font_color'));
  678. TextInput({ placeholder: '远程目录', text: this.filepath })
  679. .layoutWeight(1)
  680. .maxLines(1)
  681. .onChange((value: string) => {
  682. this.filepath = value;
  683. });
  684. }
  685. .alignItems(VerticalAlign.Center);
  686. }
  687. private tryParseConnectionString(input: string): boolean {
  688. const parsed = this.parseConnectionString(input);
  689. if (!parsed) {
  690. return false;
  691. }
  692. const protocol = parsed.protocol.toLowerCase();
  693. if (protocol === 'smb' || protocol === 'cifs') {
  694. if (this.driveType !== RemoteDriveType.Smb) {
  695. this.handleDriveTypeChange(RemoteDriveType.Smb);
  696. }
  697. this.applyParsedSmb(parsed);
  698. ToastUtil.showToast('已解析 SMB 连接');
  699. return true;
  700. }
  701. if (protocol === 'http' || protocol === 'https') {
  702. if (Number(this.driveType) === RemoteDriveType.Jellyfin) {
  703. this.applyParsedJellyfin(parsed);
  704. ToastUtil.showToast('已解析 Jellyfin 连接');
  705. return true;
  706. }
  707. if (Number(this.driveType) === RemoteDriveType.Emby) {
  708. this.applyParsedEmby(parsed);
  709. ToastUtil.showToast('已解析 Emby 连接');
  710. return true;
  711. }
  712. if (Number(this.driveType) === RemoteDriveType.Navidrome) {
  713. this.applyParsedNavidrome(parsed);
  714. ToastUtil.showToast('已解析 Navidrome 连接');
  715. return true;
  716. }
  717. const looksNav = this.guessNavidromePath(parsed.path);
  718. if (looksNav) {
  719. if (Number(this.driveType) !== RemoteDriveType.Navidrome) {
  720. this.handleDriveTypeChange(RemoteDriveType.Navidrome);
  721. }
  722. this.applyParsedNavidrome(parsed);
  723. ToastUtil.showToast('已解析 Navidrome 连接');
  724. return true;
  725. }
  726. const looksJellyfin = this.guessJellyfinPath(parsed.path);
  727. if (looksJellyfin) {
  728. if (Number(this.driveType) !== RemoteDriveType.Jellyfin) {
  729. this.handleDriveTypeChange(RemoteDriveType.Jellyfin);
  730. }
  731. this.applyParsedJellyfin(parsed);
  732. ToastUtil.showToast('已解析 Jellyfin 连接');
  733. return true;
  734. }
  735. const looksEmby = this.guessEmbyPath(parsed.path);
  736. if (looksEmby) {
  737. if (Number(this.driveType) !== RemoteDriveType.Emby) {
  738. this.handleDriveTypeChange(RemoteDriveType.Emby);
  739. }
  740. this.applyParsedEmby(parsed);
  741. ToastUtil.showToast('已解析 Emby 连接');
  742. return true;
  743. }
  744. if (this.driveType !== RemoteDriveType.WebDav) {
  745. this.handleDriveTypeChange(RemoteDriveType.WebDav);
  746. }
  747. this.applyParsedWebDav(parsed);
  748. ToastUtil.showToast('已解析 WebDAV 连接');
  749. return true;
  750. }
  751. if (protocol === 'ftp' || protocol === 'ftps') {
  752. if (this.driveType !== RemoteDriveType.Ftp) {
  753. this.handleDriveTypeChange(RemoteDriveType.Ftp);
  754. }
  755. this.applyParsedFtp(parsed);
  756. ToastUtil.showToast('已解析 FTP 连接');
  757. return true;
  758. }
  759. return false;
  760. }
  761. private applyParsedWebDav(parsed: ParsedConnectionParts): void {
  762. this.enableHttps = parsed.protocol === 'https';
  763. this.host = parsed.host;
  764. if (parsed.port) {
  765. this.updatePortState(parsed.port, true);
  766. } else {
  767. this.updatePortForType(RemoteDriveType.WebDav, true);
  768. }
  769. if (parsed.username) {
  770. this.username = parsed.username;
  771. }
  772. if (parsed.password) {
  773. this.password = parsed.password;
  774. }
  775. this.filepath = parsed.path?.length ? parsed.path : '/';
  776. }
  777. private applyParsedSmb(parsed: ParsedConnectionParts): void {
  778. this.host = parsed.host;
  779. if (parsed.port) {
  780. this.updatePortState(parsed.port, true);
  781. } else {
  782. this.updatePortForType(RemoteDriveType.Smb, true);
  783. }
  784. if (parsed.username) {
  785. this.username = parsed.username;
  786. }
  787. if (parsed.password) {
  788. this.password = parsed.password;
  789. }
  790. const segments = parsed.path?.replace(/^\/+/, '').split('/') ?? [];
  791. this.shareName = segments.length > 0 && segments[0].length > 0 ? segments[0] : this.shareName;
  792. const relative = segments.length > 1 ? segments.slice(1).join('/') : '';
  793. this.filepath = relative ? `/${relative}` : '/';
  794. }
  795. private applyParsedNavidrome(parsed: ParsedConnectionParts): void {
  796. this.enableHttps = parsed.protocol === 'https';
  797. this.host = parsed.host;
  798. if (parsed.port) {
  799. this.updatePortState(parsed.port, true);
  800. } else {
  801. this.updatePortForType(RemoteDriveType.Navidrome, true);
  802. }
  803. if (parsed.username) {
  804. this.username = parsed.username;
  805. }
  806. if (parsed.password) {
  807. this.password = parsed.password;
  808. }
  809. this.navidromeBasePath = this.normalizeNavidromeBasePath(parsed.path ?? this.navidromeBasePath);
  810. this.navBasePathCustomized = true;
  811. this.filepath = '/';
  812. }
  813. private applyParsedJellyfin(parsed: ParsedConnectionParts): void {
  814. this.enableHttps = parsed.protocol === 'https';
  815. this.host = parsed.host;
  816. if (parsed.port) {
  817. this.updatePortState(parsed.port, true);
  818. } else {
  819. this.updatePortForType(RemoteDriveType.Jellyfin, true);
  820. }
  821. if (parsed.username) {
  822. this.username = parsed.username;
  823. }
  824. if (parsed.password) {
  825. this.password = parsed.password;
  826. }
  827. this.jellyfinBasePath = this.normalizeJellyfinBasePath(parsed.path ?? this.jellyfinBasePath);
  828. this.jellyfinBasePathCustomized = true;
  829. this.filepath = '/';
  830. }
  831. private applyParsedEmby(parsed: ParsedConnectionParts): void {
  832. this.enableHttps = parsed.protocol === 'https';
  833. this.host = parsed.host;
  834. if (parsed.port) {
  835. this.updatePortState(parsed.port, true);
  836. } else {
  837. this.updatePortForType(RemoteDriveType.Emby, true);
  838. }
  839. if (parsed.username) {
  840. this.username = parsed.username;
  841. }
  842. if (parsed.password) {
  843. this.password = parsed.password;
  844. }
  845. this.embyBasePath = this.normalizeEmbyBasePath(parsed.path ?? this.embyBasePath);
  846. this.embyBasePathCustomized = true;
  847. this.filepath = '/';
  848. }
  849. private applyParsedFtp(parsed: ParsedConnectionParts): void {
  850. this.enableHttps = false;
  851. this.host = parsed.host;
  852. if (parsed.port) {
  853. this.updatePortState(parsed.port, true);
  854. } else {
  855. this.updatePortForType(RemoteDriveType.Ftp, true);
  856. }
  857. if (parsed.username) {
  858. this.username = parsed.username;
  859. }
  860. if (parsed.password) {
  861. this.password = parsed.password;
  862. }
  863. this.filepath = parsed.path && parsed.path.length > 0 ? parsed.path : '/';
  864. }
  865. private guessNavidromePath(path?: string): boolean {
  866. if (!path) {
  867. return false;
  868. }
  869. const lower = path.toLowerCase();
  870. return lower.includes('/rest') || lower.includes('navidrome');
  871. }
  872. private guessJellyfinPath(path?: string): boolean {
  873. if (!path) {
  874. return false;
  875. }
  876. return path.toLowerCase().includes('jellyfin');
  877. }
  878. private guessEmbyPath(path?: string): boolean {
  879. if (!path) {
  880. return false;
  881. }
  882. return path.toLowerCase().includes('emby');
  883. }
  884. private normalizeNavidromeBasePath(value: string): string {
  885. if (!value || value.trim().length === 0) {
  886. return '/rest';
  887. }
  888. let normalized = value.trim();
  889. if (!normalized.startsWith('/')) {
  890. normalized = `/${normalized}`;
  891. }
  892. if (normalized.length > 1 && normalized.endsWith('/')) {
  893. normalized = normalized.slice(0, -1);
  894. }
  895. return normalized.length === 0 ? '/rest' : normalized;
  896. }
  897. private normalizeJellyfinBasePath(value: string): string {
  898. if (!value || value.trim().length === 0) {
  899. return '';
  900. }
  901. let normalized = value.trim();
  902. if (!normalized.startsWith('/')) {
  903. normalized = `/${normalized}`;
  904. }
  905. if (normalized.length > 1 && normalized.endsWith('/')) {
  906. normalized = normalized.slice(0, -1);
  907. }
  908. return normalized === '/' ? '' : normalized;
  909. }
  910. private normalizeEmbyBasePath(value: string): string {
  911. if (!value || value.trim().length === 0) {
  912. return '';
  913. }
  914. let normalized = value.trim();
  915. if (!normalized.startsWith('/')) {
  916. normalized = `/${normalized}`;
  917. }
  918. if (normalized.length > 1 && normalized.endsWith('/')) {
  919. normalized = normalized.slice(0, -1);
  920. }
  921. return normalized === '/' ? '' : normalized;
  922. }
  923. private parseConnectionString(input: string): ParsedConnectionParts | null {
  924. const pattern = /^([a-z][a-z0-9+\-.]*):\/\/(?:([^:@\/]+)(?::([^@\/]*))?@)?([^\/:]+)(?::(\d+))?(\/.*)?$/i;
  925. const match = input.match(pattern);
  926. if (!match) {
  927. Logger.warn('heanup RemoteDriveAccountDialog', `连接字符串解析失败: ${input}`);
  928. return null;
  929. }
  930. const protocol = match[1].toLowerCase();
  931. const username = match[2] ? this.safeDecode(match[2]) : undefined;
  932. const password = match[3] ? this.safeDecode(match[3]) : undefined;
  933. const host = match[4];
  934. const port = match[5] ? Number(match[5]) : undefined;
  935. const path = match[6] && match[6].length > 0 ? match[6] : undefined;
  936. return { protocol, username, password, host, port, path };
  937. }
  938. private safeDecode(value: string): string {
  939. try {
  940. return decodeURIComponent(value);
  941. } catch (_error) {
  942. return value;
  943. }
  944. }
  945. @Builder
  946. private buildBaiduAuthSection() {
  947. Column({ space: 12 }) {
  948. Text('百度账号授权')
  949. .fontSize(14)
  950. .fontWeight(FontWeight.Medium)
  951. .fontColor($r('app.color.index_tab_font_color'))
  952. .alignSelf(ItemAlign.Start);
  953. Row({ space: 8 }) {
  954. Button('扫码授权')
  955. .type(ButtonType.Capsule)
  956. .backgroundColor(this.isDarkMode ? '#2C2C2E' : $r('app.color.input_background'))
  957. .fontColor($r('app.color.index_tab_font_color'))
  958. .onClick(() => {
  959. this.handleBaiduDeviceAuthorize();
  960. });
  961. Button('打开百度授权页')
  962. .type(ButtonType.Capsule)
  963. .backgroundColor(this.themeColor)
  964. .fontColor(Color.White)
  965. .onClick(() => {
  966. this.handleBaiduImplicitAuthorize();
  967. });
  968. if (this.baiduAccessToken) {
  969. Button('清除授权')
  970. .type(ButtonType.Capsule)
  971. .backgroundColor(this.isDarkMode ? '#2C2C2E' : $r('app.color.input_background'))
  972. .fontColor($r('app.color.index_tab_font_color'))
  973. .onClick(() => {
  974. this.baiduAccessToken = '';
  975. this.baiduRefreshToken = '';
  976. this.baiduTokenExpiresAt = 0;
  977. this.baiduAuthStatus = '已清除授权信息';
  978. this.closeBaiduAuthDialog();
  979. });
  980. }
  981. }
  982. Text('可扫码授权或打开百度网盘授权网页,请登录并允许访问网盘文件。')
  983. .fontSize(13)
  984. .fontColor(this.isDarkMode ? '#C7C7CC' : '#666666')
  985. .maxLines(2);
  986. if (this.baiduAuthStatus) {
  987. Text(this.baiduAuthStatus)
  988. .fontSize(13)
  989. .fontColor(this.isDarkMode ? '#EBEBF5' : '#666666')
  990. .maxLines(2);
  991. }
  992. if (this.baiduAccessToken) {
  993. Text(`已获取 Access Token,${this.baiduTokenExpiresAt > 0 ? `将在 ${this.getBaiduExpireText()} 过期` : '有效期未知'}`)
  994. .fontSize(13)
  995. .fontColor(this.isDarkMode ? '#EBEBF5' : '#666666');
  996. }
  997. }
  998. .width('100%');
  999. }
  1000. @Builder
  1001. private buildBaiduAuthDialogOverlay() {
  1002. Stack() {
  1003. Column()
  1004. .width('100%')
  1005. .height('100%')
  1006. .backgroundColor('rgba(0,0,0,0.5)')
  1007. .onClick(() => {
  1008. this.closeBaiduAuthDialog();
  1009. });
  1010. Column({ space: 8 }) {
  1011. Row() {
  1012. Text('百度网盘授权')
  1013. .fontSize(16)
  1014. .fontWeight(FontWeight.Medium)
  1015. .fontColor(this.isDarkMode ? Color.White : Color.Black);
  1016. Text('关闭')
  1017. .fontSize(14)
  1018. .fontColor(this.themeColor)
  1019. .onClick(() => {
  1020. this.closeBaiduAuthDialog();
  1021. });
  1022. }
  1023. .width('100%')
  1024. .justifyContent(FlexAlign.SpaceBetween)
  1025. .alignItems(VerticalAlign.Center)
  1026. .padding({ bottom: 4 });
  1027. this.buildBaiduAuthModeSwitch();
  1028. Divider()
  1029. .color(this.isDarkMode ? '#3A3A3C' : '#E5E5EA');
  1030. if (this.baiduAuthMode === 'device') {
  1031. this.buildBaiduDeviceAuthContent();
  1032. } else {
  1033. if (this.baiduAuthProgress > 0 && this.baiduAuthProgress < 100) {
  1034. Text(`页面加载中 ${this.baiduAuthProgress}%`)
  1035. .fontSize(12)
  1036. .fontColor(this.isDarkMode ? '#C7C7CC' : '#666666');
  1037. }
  1038. Web({
  1039. src: this.baiduAuthorizeUrl,
  1040. controller: this.baiduWebController
  1041. })
  1042. .layoutWeight(1)
  1043. .width('100%')
  1044. .javaScriptAccess(true)
  1045. .domStorageAccess(true)
  1046. .onPageEnd((event) => {
  1047. if (event && event.url) {
  1048. this.handleBaiduAuthNavigation(event.url);
  1049. }
  1050. })
  1051. .onProgressChange((event) => {
  1052. if (event) {
  1053. this.baiduAuthProgress = event.newProgress;
  1054. }
  1055. });
  1056. }
  1057. }
  1058. .width('92%')
  1059. .height('85%')
  1060. .padding(16)
  1061. .backgroundColor(this.isDarkMode ? '#1C1C1E' : Color.White)
  1062. .borderRadius(16);
  1063. }
  1064. .width('100%')
  1065. .height('100%');
  1066. }
  1067. @Builder
  1068. private buildBaiduAuthModeSwitch() {
  1069. Row({ space: 8 }) {
  1070. this.buildBaiduAuthModeChip('网页登录', 'web');
  1071. this.buildBaiduAuthModeChip('扫码登录', 'device');
  1072. }
  1073. .width('100%')
  1074. .padding({ top: 4, bottom: 4 });
  1075. }
  1076. @Builder
  1077. private buildBaiduAuthModeChip(label: string, mode: 'web' | 'device') {
  1078. Button(label)
  1079. .type(ButtonType.Capsule)
  1080. .backgroundColor(this.baiduAuthMode === mode ? this.themeColor : (this.isDarkMode ? '#2C2C2E' : $r('app.color.input_background')))
  1081. .fontColor(this.baiduAuthMode === mode ? Color.White : $r('app.color.index_tab_font_color'))
  1082. .onClick(() => {
  1083. this.switchBaiduAuthMode(mode);
  1084. });
  1085. }
  1086. @Builder
  1087. private buildBaiduDeviceAuthContent() {
  1088. Column({ space: 12 }) {
  1089. if (this.baiduDeviceQrUrl) {
  1090. Image(this.baiduDeviceQrUrl)
  1091. .width(220)
  1092. .height(220)
  1093. .borderRadius(12)
  1094. .objectFit(ImageFit.Contain)
  1095. .alignSelf(ItemAlign.Center);
  1096. } else {
  1097. Column() {
  1098. Text('正在获取二维码...')
  1099. .fontColor(this.isDarkMode ? '#C7C7CC' : '#666666');
  1100. }
  1101. .width(220)
  1102. .height(220)
  1103. .alignSelf(ItemAlign.Center)
  1104. .backgroundColor(this.isDarkMode ? '#2C2C2E' : $r('app.color.input_background'))
  1105. .borderRadius(12)
  1106. .justifyContent(FlexAlign.Center)
  1107. .alignItems(HorizontalAlign.Center);
  1108. }
  1109. if (this.baiduDeviceUserCode) {
  1110. Text(`设备码: ${this.baiduDeviceUserCode}`)
  1111. .fontSize(14)
  1112. .fontWeight(FontWeight.Medium)
  1113. .fontColor($r('app.color.index_tab_font_color'));
  1114. }
  1115. if (this.baiduDeviceVerifyUrl) {
  1116. Text(`或在浏览器访问 ${this.baiduDeviceVerifyUrl} 输入设备码完成授权`)
  1117. .fontSize(12)
  1118. .fontColor(this.isDarkMode ? '#C7C7CC' : '#666666')
  1119. .maxLines(2);
  1120. }
  1121. if (this.baiduDeviceExpireAt) {
  1122. Text(`二维码将于 ${this.getBaiduDeviceRemainText()} 过期`)
  1123. .fontSize(12)
  1124. .fontColor(this.isDarkMode ? '#C7C7CC' : '#666666');
  1125. }
  1126. if (this.baiduAuthStatus) {
  1127. Text(this.baiduAuthStatus)
  1128. .fontSize(13)
  1129. .fontColor(this.isDarkMode ? '#EBEBF5' : '#666666')
  1130. .maxLines(2);
  1131. }
  1132. Row({ space: 12 }) {
  1133. Button('刷新二维码')
  1134. .type(ButtonType.Capsule)
  1135. .backgroundColor(this.themeColor)
  1136. .fontColor(Color.White)
  1137. .onClick(() => {
  1138. this.handleBaiduDeviceAuthorize();
  1139. });
  1140. Button('改用网页登录')
  1141. .type(ButtonType.Capsule)
  1142. .backgroundColor(this.isDarkMode ? '#2C2C2E' : $r('app.color.input_background'))
  1143. .fontColor($r('app.color.index_tab_font_color'))
  1144. .onClick(() => {
  1145. this.handleBaiduImplicitAuthorize();
  1146. });
  1147. }
  1148. .width('100%')
  1149. .justifyContent(FlexAlign.Start);
  1150. }
  1151. .layoutWeight(1)
  1152. .width('100%');
  1153. }
  1154. private getBaiduExpireText(): string {
  1155. if (!this.baiduTokenExpiresAt) {
  1156. return '';
  1157. }
  1158. const remain = Math.max(0, this.baiduTokenExpiresAt - Date.now());
  1159. const hours = Math.floor(remain / 3600000);
  1160. const minutes = Math.floor((remain % 3600000) / 60000);
  1161. return hours > 0 ? `${hours}小时${minutes}分后` : `${minutes}分钟后`;
  1162. }
  1163. private switchBaiduAuthMode(mode: 'web' | 'device'): void {
  1164. if (mode === this.baiduAuthMode && this.showBaiduAuthDialog) {
  1165. return;
  1166. }
  1167. if (mode === 'device') {
  1168. this.handleBaiduDeviceAuthorize();
  1169. } else {
  1170. this.handleBaiduImplicitAuthorize();
  1171. }
  1172. }
  1173. private async handleBaiduDeviceAuthorize(): Promise<void> {
  1174. this.stopBaiduDevicePolling();
  1175. this.baiduAuthMode = 'device';
  1176. this.resetBaiduDeviceState(false);
  1177. this.baiduAuthorizeUrl = '';
  1178. this.baiduAuthProgress = 0;
  1179. this.baiduAuthStateToken = '';
  1180. this.baiduAuthStatus = '正在获取扫码授权信息...';
  1181. try {
  1182. const codeInfo = await requestBaiduDeviceCode();
  1183. this.baiduDeviceCode = codeInfo.device_code;
  1184. this.baiduDeviceUserCode = codeInfo.user_code;
  1185. this.baiduDeviceVerifyUrl = codeInfo.verification_url;
  1186. this.baiduDeviceQrUrl = codeInfo.qrcode_url ?? '';
  1187. this.baiduDeviceExpireAt = Date.now() + (codeInfo.expires_in ?? 0) * 1000;
  1188. this.baiduDeviceInterval = Math.max(3, codeInfo.interval ?? 5);
  1189. this.baiduAuthStatus = '请在百度网盘App中扫码确认或输入设备码授权';
  1190. this.showBaiduAuthDialog = true;
  1191. this.startBaiduDevicePolling();
  1192. } catch (error) {
  1193. const message = (error as Error).message ?? '请求失败';
  1194. this.baiduAuthStatus = `获取设备码失败: ${message}`;
  1195. ToastUtil.showToast('获取百度扫码授权失败');
  1196. }
  1197. }
  1198. private startBaiduDevicePolling(): void {
  1199. this.stopBaiduDevicePolling();
  1200. if (!this.baiduDeviceCode) {
  1201. return;
  1202. }
  1203. const intervalMs = Math.max(3000, this.baiduDeviceInterval * 1000);
  1204. this.baiduDevicePollTimer = setInterval(() => {
  1205. this.queryBaiduDeviceToken();
  1206. }, intervalMs) as number;
  1207. }
  1208. private stopBaiduDevicePolling(): void {
  1209. if (this.baiduDevicePollTimer) {
  1210. clearInterval(this.baiduDevicePollTimer);
  1211. this.baiduDevicePollTimer = 0;
  1212. }
  1213. this.baiduDevicePolling = false;
  1214. }
  1215. private async queryBaiduDeviceToken(): Promise<void> {
  1216. if (!this.baiduDeviceCode || this.baiduDevicePolling) {
  1217. return;
  1218. }
  1219. this.baiduDevicePolling = true;
  1220. try {
  1221. const result: BaiduDeviceTokenResult = await pollBaiduDeviceToken(this.baiduDeviceCode);
  1222. const tokenResult = result as BaiduTokenResultShape;
  1223. if (tokenResult.access_token !== undefined && tokenResult.access_token.length > 0) {
  1224. this.baiduAccessToken = tokenResult.access_token;
  1225. this.baiduRefreshToken = tokenResult.refresh_token ?? '';
  1226. this.baiduTokenExpiresAt = tokenResult.expires_in ? Date.now() + tokenResult.expires_in * 1000 : 0;
  1227. this.baiduAuthStatus = '授权成功,可保存账户';
  1228. ToastUtil.showToast('百度授权成功');
  1229. this.closeBaiduAuthDialog();
  1230. return;
  1231. }
  1232. const errorResult = result as BaiduTokenResultShape;
  1233. if (errorResult.error === undefined) {
  1234. return;
  1235. }
  1236. switch (errorResult.error) {
  1237. case 'authorization_pending':
  1238. this.baiduAuthStatus = '请在百度网盘App扫码并确认';
  1239. break;
  1240. case 'slow_down':
  1241. this.baiduAuthStatus = '请求过于频繁,稍后自动重试';
  1242. this.baiduDeviceInterval += 2;
  1243. this.startBaiduDevicePolling();
  1244. break;
  1245. case 'expired_token':
  1246. case 'invalid_grant':
  1247. this.baiduAuthStatus = '二维码已过期,请刷新重新扫码';
  1248. this.stopBaiduDevicePolling();
  1249. break;
  1250. default:
  1251. this.baiduAuthStatus = `授权失败: ${errorResult.error_description ?? errorResult.error}`;
  1252. this.stopBaiduDevicePolling();
  1253. break;
  1254. }
  1255. } catch (error) {
  1256. const message = (error as Error).message ?? '未知错误';
  1257. this.baiduAuthStatus = `授权轮询失败: ${message}`;
  1258. } finally {
  1259. this.baiduDevicePolling = false;
  1260. }
  1261. }
  1262. private getBaiduDeviceRemainText(): string {
  1263. if (!this.baiduDeviceExpireAt) {
  1264. return '稍后';
  1265. }
  1266. const remain = Math.max(0, this.baiduDeviceExpireAt - Date.now());
  1267. const minutes = Math.floor(remain / 60000);
  1268. const seconds = Math.floor((remain % 60000) / 1000);
  1269. return minutes > 0 ? `${minutes}分${seconds}秒后` : `${seconds}秒后`;
  1270. }
  1271. private resetBaiduDeviceState(resetMode: boolean = true): void {
  1272. if (resetMode) {
  1273. this.baiduAuthMode = 'web';
  1274. }
  1275. this.baiduDeviceCode = '';
  1276. this.baiduDeviceUserCode = '';
  1277. this.baiduDeviceVerifyUrl = '';
  1278. this.baiduDeviceQrUrl = '';
  1279. this.baiduDeviceExpireAt = 0;
  1280. this.baiduDeviceInterval = 5;
  1281. this.baiduDevicePolling = false;
  1282. }
  1283. private handleBaiduImplicitAuthorize(): void {
  1284. this.stopBaiduDevicePolling();
  1285. this.resetBaiduDeviceState(false);
  1286. this.baiduAuthMode = 'web';
  1287. this.baiduAuthStatus = '正在打开百度授权页面...';
  1288. this.baiduAuthStateToken = `${Date.now()}`;
  1289. this.baiduAuthProgress = 0;
  1290. this.baiduAuthorizeUrl = this.buildBaiduAuthorizeUrl(this.baiduAuthStateToken);
  1291. this.showBaiduAuthDialog = true;
  1292. }
  1293. private closeBaiduAuthDialog(): void {
  1294. this.showBaiduAuthDialog = false;
  1295. this.baiduAuthorizeUrl = '';
  1296. this.baiduAuthProgress = 0;
  1297. this.baiduAuthStateToken = '';
  1298. this.stopBaiduDevicePolling();
  1299. this.resetBaiduDeviceState();
  1300. }
  1301. private buildBaiduAuthorizeUrl(state: string): string {
  1302. const params = [
  1303. `response_type=token`,
  1304. `client_id=${encodeURIComponent(BaiduConstants.APP_KEY)}`,
  1305. `redirect_uri=${encodeURIComponent(BaiduConstants.OOB_REDIRECT_PARAM)}`,
  1306. `scope=${encodeURIComponent(BaiduConstants.AUTH_SCOPE)}`,
  1307. `display=mobile`,
  1308. `state=${encodeURIComponent(state)}`
  1309. ];
  1310. return `${BaiduConstants.AUTHORIZE_URL}?${params.join('&')}`;
  1311. }
  1312. private handleBaiduAuthNavigation(url: string): void {
  1313. if (!url) {
  1314. return;
  1315. }
  1316. const loginSuccessHttps = BaiduConstants.LOGIN_SUCCESS_URL.replace('http://', 'https://');
  1317. if (!url.startsWith(BaiduConstants.LOGIN_SUCCESS_URL) && !url.startsWith(loginSuccessHttps)) {
  1318. return;
  1319. }
  1320. const fragmentIndex = url.indexOf('#');
  1321. const fragment = fragmentIndex >= 0 ? url.substring(fragmentIndex + 1) : '';
  1322. if (!fragment) {
  1323. return;
  1324. }
  1325. const params = this.parseAuthFragment(fragment);
  1326. const responseState = params.get('state') ?? '';
  1327. if (this.baiduAuthStateToken && responseState && responseState !== this.baiduAuthStateToken) {
  1328. this.baiduAuthStatus = '授权状态校验失败,请重试';
  1329. this.closeBaiduAuthDialog();
  1330. return;
  1331. }
  1332. const error = params.get('error');
  1333. if (error) {
  1334. const description = params.get('error_description') ?? error;
  1335. this.baiduAuthStatus = `授权失败: ${description}`;
  1336. this.closeBaiduAuthDialog();
  1337. return;
  1338. }
  1339. const accessToken = params.get('access_token');
  1340. if (!accessToken) {
  1341. return;
  1342. }
  1343. const expiresIn = Number(params.get('expires_in') ?? '0');
  1344. this.baiduAccessToken = accessToken;
  1345. this.baiduRefreshToken = '';
  1346. this.baiduTokenExpiresAt = expiresIn > 0 ? Date.now() + expiresIn * 1000 : 0;
  1347. this.baiduAuthStatus = '授权成功,可保存账户';
  1348. ToastUtil.showToast('百度授权成功');
  1349. this.closeBaiduAuthDialog();
  1350. }
  1351. private parseAuthFragment(fragment: string): Map<string, string> {
  1352. const params = new Map<string, string>();
  1353. const pairs = fragment.split('&');
  1354. for (let i = 0; i < pairs.length; i++) {
  1355. const pair = pairs[i];
  1356. if (!pair || pair.length === 0) {
  1357. continue;
  1358. }
  1359. const separator = pair.indexOf('=');
  1360. const key = separator >= 0 ? pair.substring(0, separator) : pair;
  1361. const value = separator >= 0 ? pair.substring(separator + 1) : '';
  1362. params.set(decodeURIComponent(key), decodeURIComponent(value));
  1363. }
  1364. return params;
  1365. }
  1366. /**
  1367. * 处理选择封面
  1368. */
  1369. private async handleSelectCover() {
  1370. try {
  1371. // 需要获取上下文,这里通过全局上下文获取
  1372. const context = getContext(this) as Context
  1373. if (!context) {
  1374. ToastUtil.showToast('获取应用上下文失败')
  1375. return
  1376. }
  1377. const selectedPath = await ImagePickerUtil.selectSingleWebDavCover(context)
  1378. if (selectedPath) {
  1379. // 如果之前有封面,删除旧封面
  1380. if (this.coverPath && this.coverPath !== this.account.coverPath) {
  1381. ImagePickerUtil.deleteImage(this.coverPath)
  1382. }
  1383. this.coverPath = selectedPath
  1384. Logger.info('heanup RemoteDriveAccountDialog', `选择封面成功: ${selectedPath}`)
  1385. }
  1386. } catch (error) {
  1387. Logger.error('heanup RemoteDriveAccountDialog', `选择封面失败: ${(error as Error).message}`)
  1388. ToastUtil.showToast('选择封面失败')
  1389. }
  1390. }
  1391. /**
  1392. * 处理移除封面
  1393. */
  1394. private handleRemoveCover() {
  1395. if (this.coverPath) {
  1396. if(!this.account){
  1397. this.coverPath = ''
  1398. return
  1399. }
  1400. // 只有当封面不是原来的封面时才删除文件
  1401. if (this.coverPath !== this.account.coverPath) {
  1402. ImagePickerUtil.deleteImage(this.coverPath)
  1403. }
  1404. this.coverPath = ''
  1405. Logger.info('heanup RemoteDriveAccountDialog', '移除封面成功')
  1406. }
  1407. }
  1408. }
  1409. export function getCloudDiskIcon(type: number): ResourceStr {
  1410. switch ( type)
  1411. {
  1412. case RemoteDriveType.WebDav:
  1413. return $r('app.media.webdav');
  1414. case RemoteDriveType.Smb:
  1415. return $r('app.media.smb');
  1416. case RemoteDriveType.Ftp:
  1417. return $r('app.media.ftp');
  1418. case RemoteDriveType.Navidrome:
  1419. return $r('app.media.navidrome');
  1420. case RemoteDriveType.Jellyfin:
  1421. return $r('app.media.jellyfin');
  1422. case RemoteDriveType.Emby:
  1423. return $r('app.media.emby');
  1424. case RemoteDriveType.Baidu:
  1425. return $r('app.media.baiduwp');
  1426. case RemoteDriveType.ALi:
  1427. return $r('app.media.ali_disk');
  1428. case RemoteDriveType.DISK_123:
  1429. return $r('app.media.ali_disk');
  1430. }
  1431. return $r('app.media.cloudDisk');
  1432. }